Merge remote-tracking branch 'upstream/dev'

This commit is contained in:
Anish Sarkar 2026-03-09 10:46:55 +05:30
commit 4e7e8ccd7e
141 changed files with 5771 additions and 5223 deletions

View file

@ -0,0 +1,39 @@
"""103_add_last_login_to_user
Revision ID: 103
Revises: 102
Create Date: 2026-03-08
Adds last_login timestamp column to the user table so we can track
when each user last authenticated. The column is nullable existing
rows will have NULL until the user's next login.
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "103"
down_revision: str | None = "102"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
conn = op.get_bind()
existing_columns = [col["name"] for col in sa.inspect(conn).get_columns("user")]
if "last_login" not in existing_columns:
op.add_column(
"user",
sa.Column("last_login", sa.TIMESTAMP(timezone=True), nullable=True),
)
def downgrade() -> None:
op.drop_column("user", "last_login")

View file

@ -1720,6 +1720,8 @@ if config.AUTH_TYPE == "GOOGLE":
display_name = Column(String, nullable=True) display_name = Column(String, nullable=True)
avatar_url = Column(String, nullable=True) avatar_url = Column(String, nullable=True)
last_login = Column(TIMESTAMP(timezone=True), nullable=True)
# Refresh tokens for this user # Refresh tokens for this user
refresh_tokens = relationship( refresh_tokens = relationship(
"RefreshToken", "RefreshToken",
@ -1820,6 +1822,8 @@ else:
display_name = Column(String, nullable=True) display_name = Column(String, nullable=True)
avatar_url = Column(String, nullable=True) avatar_url = Column(String, nullable=True)
last_login = Column(TIMESTAMP(timezone=True), nullable=True)
# Refresh tokens for this user # Refresh tokens for this user
refresh_tokens = relationship( refresh_tokens = relationship(
"RefreshToken", "RefreshToken",

View file

@ -109,12 +109,12 @@ SUMMARY_PROMPT_TEMPLATE = PromptTemplate(
# Chat Title Generation Prompt # Chat Title Generation Prompt
# ============================================================================= # =============================================================================
TITLE_GENERATION_PROMPT = """Generate a concise, descriptive title for the following conversation. TITLE_GENERATION_PROMPT = """Generate a concise, descriptive title for the following user query.
<rules> <rules>
- The title MUST be between 1 and 6 words - The title MUST be between 1 and 6 words
- The title MUST be on a single line - The title MUST be on a single line
- Capture the main topic or intent of the conversation - Capture the main topic or intent of the query
- Do NOT use quotes, punctuation, or formatting - Do NOT use quotes, punctuation, or formatting
- Do NOT include words like "Chat about" or "Discussion of" - Do NOT include words like "Chat about" or "Discussion of"
- Return ONLY the title, nothing else - Return ONLY the title, nothing else
@ -124,13 +124,9 @@ TITLE_GENERATION_PROMPT = """Generate a concise, descriptive title for the follo
{user_query} {user_query}
</user_query> </user_query>
<assistant_response>
{assistant_response}
</assistant_response>
Title:""" Title:"""
TITLE_GENERATION_PROMPT_TEMPLATE = PromptTemplate( TITLE_GENERATION_PROMPT_TEMPLATE = PromptTemplate(
input_variables=["user_query", "assistant_response"], input_variables=["user_query"],
template=TITLE_GENERATION_PROMPT, template=TITLE_GENERATION_PROMPT,
) )

View file

@ -320,6 +320,8 @@ async def read_documents(
page_size: int = 50, page_size: int = 50,
search_space_id: int | None = None, search_space_id: int | None = None,
document_types: str | None = None, document_types: str | None = None,
sort_by: str = "created_at",
sort_order: str = "desc",
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user), user: User = Depends(current_active_user),
): ):
@ -392,6 +394,19 @@ async def read_documents(
total_result = await session.execute(count_query) total_result = await session.execute(count_query)
total = total_result.scalar() or 0 total = total_result.scalar() or 0
# Apply sorting
from sqlalchemy import asc as sa_asc, desc as sa_desc
sort_column_map = {
"created_at": Document.created_at,
"title": Document.title,
"document_type": Document.document_type,
}
sort_col = sort_column_map.get(sort_by, Document.created_at)
query = query.order_by(
sa_desc(sort_col) if sort_order == "desc" else sa_asc(sort_col)
)
# Calculate offset # Calculate offset
offset = 0 offset = 0
if skip is not None: if skip is not None:

View file

@ -10,7 +10,7 @@ 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
from sqlalchemy import desc, func, select, update from sqlalchemy import desc, func, literal, literal_column, select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.db import Notification, User, get_async_session from app.db import Notification, User, get_async_session
@ -23,9 +23,26 @@ SYNC_WINDOW_DAYS = 14
# Valid notification types - must match frontend InboxItemTypeEnum # Valid notification types - must match frontend InboxItemTypeEnum
NotificationType = Literal[ NotificationType = Literal[
"connector_indexing", "document_processing", "new_mention", "page_limit_exceeded" "connector_indexing",
"connector_deletion",
"document_processing",
"new_mention",
"comment_reply",
"page_limit_exceeded",
] ]
# Category-to-types mapping for filtering by tab
NotificationCategory = Literal["comments", "status"]
CATEGORY_TYPES: dict[str, tuple[str, ...]] = {
"comments": ("new_mention", "comment_reply"),
"status": (
"connector_indexing",
"connector_deletion",
"document_processing",
"page_limit_exceeded",
),
}
class NotificationResponse(BaseModel): class NotificationResponse(BaseModel):
"""Response model for a single notification.""" """Response model for a single notification."""
@ -69,6 +86,21 @@ class MarkAllReadResponse(BaseModel):
updated_count: int updated_count: int
class SourceTypeItem(BaseModel):
"""A single source type with its category and count."""
key: str
type: str
category: str # "connector" or "document"
count: int
class SourceTypesResponse(BaseModel):
"""Response for notification source types used in status tab filter."""
sources: list[SourceTypeItem]
class UnreadCountResponse(BaseModel): class UnreadCountResponse(BaseModel):
"""Response for unread count with split between recent and older items.""" """Response for unread count with split between recent and older items."""
@ -76,12 +108,86 @@ class UnreadCountResponse(BaseModel):
recent_unread: int # Within SYNC_WINDOW_DAYS recent_unread: int # Within SYNC_WINDOW_DAYS
@router.get("/source-types", response_model=SourceTypesResponse)
async def get_notification_source_types(
search_space_id: int | None = Query(None, description="Filter by search space ID"),
user: User = Depends(current_active_user),
session: AsyncSession = Depends(get_async_session),
) -> SourceTypesResponse:
"""
Get all distinct connector types and document types from the user's
status notifications. Used to populate the filter dropdown in the
inbox Status tab so that all types are shown regardless of pagination.
"""
base_filter = [Notification.user_id == user.id]
if search_space_id is not None:
base_filter.append(
(Notification.search_space_id == search_space_id)
| (Notification.search_space_id.is_(None))
)
connector_type_expr = Notification.notification_metadata["connector_type"].astext
connector_query = (
select(
connector_type_expr.label("source_type"),
literal("connector").label("category"),
func.count(Notification.id).label("cnt"),
)
.where(
*base_filter,
Notification.type.in_(("connector_indexing", "connector_deletion")),
connector_type_expr.isnot(None),
)
.group_by(literal_column("source_type"))
)
document_type_expr = Notification.notification_metadata["document_type"].astext
document_query = (
select(
document_type_expr.label("source_type"),
literal("document").label("category"),
func.count(Notification.id).label("cnt"),
)
.where(
*base_filter,
Notification.type.in_(("document_processing",)),
document_type_expr.isnot(None),
)
.group_by(literal_column("source_type"))
)
connector_result = await session.execute(connector_query)
document_result = await session.execute(document_query)
sources = []
for source_type, category, count in [
*connector_result.all(),
*document_result.all(),
]:
if not source_type:
continue
sources.append(
SourceTypeItem(
key=f"{category}:{source_type}",
type=source_type,
category=category,
count=count,
)
)
return SourceTypesResponse(sources=sources)
@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( type_filter: NotificationType | None = Query(
None, alias="type", description="Filter by notification type" None, alias="type", description="Filter by notification type"
), ),
category: NotificationCategory | None = Query(
None, description="Filter by category: 'comments' or 'status'"
),
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:
@ -116,6 +222,10 @@ async def get_unread_count(
if type_filter: if type_filter:
base_filter.append(Notification.type == type_filter) base_filter.append(Notification.type == type_filter)
# Filter by category (maps to multiple types)
if category:
base_filter.append(Notification.type.in_(CATEGORY_TYPES[category]))
# 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)
@ -141,6 +251,17 @@ async def list_notifications(
type_filter: NotificationType | None = Query( type_filter: NotificationType | None = Query(
None, alias="type", description="Filter by notification type" None, alias="type", description="Filter by notification type"
), ),
category: NotificationCategory | None = Query(
None, description="Filter by category: 'comments' or 'status'"
),
source_type: str | None = Query(
None,
description="Filter by source type, e.g. 'connector:GITHUB_CONNECTOR' or 'doctype:FILE'",
),
filter: str | None = Query(
None,
description="Filter preset: 'unread' for unread only, 'errors' for failed/error items only",
),
before_date: str | None = Query( before_date: str | None = Query(
None, description="Get notifications before this ISO date (for pagination)" None, description="Get notifications before this ISO date (for pagination)"
), ),
@ -182,6 +303,45 @@ async def list_notifications(
query = query.where(Notification.type == type_filter) query = query.where(Notification.type == type_filter)
count_query = count_query.where(Notification.type == type_filter) count_query = count_query.where(Notification.type == type_filter)
# Filter by category (maps to multiple types)
if category:
cat_types = CATEGORY_TYPES[category]
query = query.where(Notification.type.in_(cat_types))
count_query = count_query.where(Notification.type.in_(cat_types))
# Filter by source type (connector or document type from JSONB metadata)
if source_type:
if source_type.startswith("connector:"):
connector_val = source_type[len("connector:") :]
source_filter = Notification.type.in_(
("connector_indexing", "connector_deletion")
) & (
Notification.notification_metadata["connector_type"].astext
== connector_val
)
query = query.where(source_filter)
count_query = count_query.where(source_filter)
elif source_type.startswith("doctype:"):
doctype_val = source_type[len("doctype:") :]
source_filter = Notification.type.in_(("document_processing",)) & (
Notification.notification_metadata["document_type"].astext
== doctype_val
)
query = query.where(source_filter)
count_query = count_query.where(source_filter)
# Filter by preset: 'unread' or 'errors'
if filter == "unread":
unread_filter = Notification.read == False # noqa: E712
query = query.where(unread_filter)
count_query = count_query.where(unread_filter)
elif filter == "errors":
error_filter = (Notification.type == "page_limit_exceeded") | (
Notification.notification_metadata["status"].astext == "failed"
)
query = query.where(error_filter)
count_query = count_query.where(error_filter)
# Filter by date (for efficient pagination of older items) # Filter by date (for efficient pagination of older items)
if before_date: if before_date:
try: try:

View file

@ -510,6 +510,7 @@ async def list_members(
"user_email": member_user.email if member_user else None, "user_email": member_user.email if member_user else None,
"user_display_name": member_user.display_name if member_user else None, "user_display_name": member_user.display_name if member_user else None,
"user_avatar_url": member_user.avatar_url if member_user else None, "user_avatar_url": member_user.avatar_url if member_user else None,
"user_last_login": member_user.last_login if member_user else None,
} }
response.append(membership_dict) response.append(membership_dict)
@ -602,6 +603,7 @@ async def update_member_role(
"created_at": db_membership.created_at, "created_at": db_membership.created_at,
"role": db_membership.role, "role": db_membership.role,
"user_email": member_user.email if member_user else None, "user_email": member_user.email if member_user else None,
"user_last_login": member_user.last_login if member_user else None,
} }
except HTTPException: except HTTPException:

View file

@ -77,6 +77,7 @@ class MembershipRead(BaseModel):
user_email: str | None = None user_email: str | None = None
user_display_name: str | None = None user_display_name: str | None = None
user_avatar_url: str | None = None user_avatar_url: str | None = None
user_last_login: datetime | None = None
class Config: class Config:
from_attributes = True from_attributes = True

View file

@ -1366,6 +1366,38 @@ async def stream_new_chat(
del mentioned_documents, mentioned_surfsense_docs, recent_reports del mentioned_documents, mentioned_surfsense_docs, recent_reports
del langchain_messages, final_query del langchain_messages, final_query
# Check if this is the first assistant response so we can generate
# a title in parallel with the agent stream (better UX than waiting
# until after the full response).
assistant_count_result = await session.execute(
select(func.count(NewChatMessage.id)).filter(
NewChatMessage.thread_id == chat_id,
NewChatMessage.role == "assistant",
)
)
is_first_response = (assistant_count_result.scalar() or 0) == 0
title_task: asyncio.Task[str | None] | None = None
if is_first_response:
async def _generate_title() -> str | None:
try:
title_chain = TITLE_GENERATION_PROMPT_TEMPLATE | llm
title_result = await title_chain.ainvoke(
{"user_query": user_query[:500]}
)
if title_result and hasattr(title_result, "content"):
raw_title = title_result.content.strip()
if raw_title and len(raw_title) <= 100:
return raw_title.strip("\"'")
except Exception:
pass
return None
title_task = asyncio.create_task(_generate_title())
title_emitted = False
_t_stream_start = time.perf_counter() _t_stream_start = time.perf_counter()
_first_event_logged = False _first_event_logged = False
async for sse in _stream_agent_events( async for sse in _stream_agent_events(
@ -1390,6 +1422,23 @@ async def stream_new_chat(
_first_event_logged = True _first_event_logged = True
yield sse yield sse
# Inject title update mid-stream as soon as the background task finishes
if title_task is not None and title_task.done() and not title_emitted:
generated_title = title_task.result()
if generated_title:
async with shielded_async_session() as title_session:
title_thread_result = await title_session.execute(
select(NewChatThread).filter(NewChatThread.id == chat_id)
)
title_thread = title_thread_result.scalars().first()
if title_thread:
title_thread.title = generated_title
await title_session.commit()
yield streaming_service.format_thread_title_update(
chat_id, generated_title
)
title_emitted = True
_perf_log.info( _perf_log.info(
"[stream_new_chat] Agent stream completed in %.3fs (chat_id=%s)", "[stream_new_chat] Agent stream completed in %.3fs (chat_id=%s)",
time.perf_counter() - _t_stream_start, time.perf_counter() - _t_stream_start,
@ -1398,62 +1447,28 @@ async def stream_new_chat(
log_system_snapshot("stream_new_chat_END") log_system_snapshot("stream_new_chat_END")
if stream_result.is_interrupted: if stream_result.is_interrupted:
if title_task is not None and not title_task.done():
title_task.cancel()
yield streaming_service.format_finish_step() yield streaming_service.format_finish_step()
yield streaming_service.format_finish() yield streaming_service.format_finish()
yield streaming_service.format_done() yield streaming_service.format_done()
return return
accumulated_text = stream_result.accumulated_text # If the title task didn't finish during streaming, await it now
if title_task is not None and not title_emitted:
assistant_count_result = await session.execute( generated_title = await title_task
select(func.count(NewChatMessage.id)).filter(
NewChatMessage.thread_id == chat_id,
NewChatMessage.role == "assistant",
)
)
assistant_message_count = assistant_count_result.scalar() or 0
# Only generate title on the first response (no prior assistant messages)
if assistant_message_count == 0:
generated_title = None
try:
# Generate title using the same LLM
title_chain = TITLE_GENERATION_PROMPT_TEMPLATE | llm
# Truncate inputs to avoid context length issues
truncated_query = user_query[:500]
truncated_response = accumulated_text[:1000]
title_result = await title_chain.ainvoke(
{
"user_query": truncated_query,
"assistant_response": truncated_response,
}
)
# Extract and clean the title
if title_result and hasattr(title_result, "content"):
raw_title = title_result.content.strip()
# Validate the title (reasonable length)
if raw_title and len(raw_title) <= 100:
# Remove any quotes or extra formatting
generated_title = raw_title.strip("\"'")
except Exception:
generated_title = None
# Only update if LLM succeeded (keep truncated prompt title as fallback)
if generated_title: if generated_title:
# Fetch thread and update title async with shielded_async_session() as title_session:
thread_result = await session.execute( title_thread_result = await title_session.execute(
select(NewChatThread).filter(NewChatThread.id == chat_id) select(NewChatThread).filter(NewChatThread.id == chat_id)
)
thread = thread_result.scalars().first()
if thread:
thread.title = generated_title
await session.commit()
# Notify frontend of the title update
yield streaming_service.format_thread_title_update(
chat_id, generated_title
) )
title_thread = title_thread_result.scalars().first()
if title_thread:
title_thread.title = generated_title
await title_session.commit()
yield streaming_service.format_thread_title_update(
chat_id, generated_title
)
# Finish the step and message # Finish the step and message
yield streaming_service.format_finish_step() yield streaming_service.format_finish_step()

View file

@ -1,5 +1,6 @@
import logging import logging
import uuid import uuid
from datetime import UTC, datetime
import httpx import httpx
from fastapi import Depends, Request, Response from fastapi import Depends, Request, Response
@ -12,6 +13,7 @@ from fastapi_users.authentication import (
) )
from fastapi_users.db import SQLAlchemyUserDatabase from fastapi_users.db import SQLAlchemyUserDatabase
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import update
from app.config import config from app.config import config
from app.db import ( from app.db import (
@ -123,6 +125,23 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
return user return user
async def on_after_login(
self,
user: User,
request: Request | None = None,
response: Response | None = None,
) -> None:
try:
async with async_session_maker() as session:
await session.execute(
update(User)
.where(User.id == user.id)
.values(last_login=datetime.now(UTC))
)
await session.commit()
except Exception as e:
logger.warning(f"Failed to update last_login for user {user.id}: {e}")
async def on_after_register(self, user: User, request: Request | None = None): async def on_after_register(self, user: User, request: Request | None = None):
""" """
Called after a user registers. Creates a default search space for the user Called after a user registers. Creates a default search space for the user

View file

@ -1,10 +1,29 @@
"use client"; "use client";
import { CTAHomepage } from "@/components/homepage/cta"; import dynamic from "next/dynamic";
import { FeaturesBentoGrid } from "@/components/homepage/features-bento-grid";
import { FeaturesCards } from "@/components/homepage/features-card";
import { HeroSection } from "@/components/homepage/hero-section"; import { HeroSection } from "@/components/homepage/hero-section";
import ExternalIntegrations from "@/components/homepage/integrations";
const FeaturesCards = dynamic(
() => import("@/components/homepage/features-card").then((m) => ({ default: m.FeaturesCards })),
{ ssr: false }
);
const FeaturesBentoGrid = dynamic(
() =>
import("@/components/homepage/features-bento-grid").then((m) => ({
default: m.FeaturesBentoGrid,
})),
{ ssr: false }
);
const ExternalIntegrations = dynamic(() => import("@/components/homepage/integrations"), {
ssr: false,
});
const CTAHomepage = dynamic(
() => import("@/components/homepage/cta").then((m) => ({ default: m.CTAHomepage })),
{ ssr: false }
);
export default function HomePage() { export default function HomePage() {
return ( return (

View file

@ -13,9 +13,7 @@ import {
llmPreferencesAtom, llmPreferencesAtom,
} from "@/atoms/new-llm-config/new-llm-config-query.atoms"; } from "@/atoms/new-llm-config/new-llm-config-query.atoms";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms"; import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { ConnectorIndicator } from "@/components/assistant-ui/connector-popup";
import { DocumentUploadDialogProvider } from "@/components/assistant-ui/document-upload-popup"; import { DocumentUploadDialogProvider } from "@/components/assistant-ui/document-upload-popup";
import { DashboardBreadcrumb } from "@/components/dashboard-breadcrumb";
import { LayoutDataProvider } from "@/components/layout"; import { LayoutDataProvider } from "@/components/layout";
import { OnboardingTour } from "@/components/onboarding-tour"; import { OnboardingTour } from "@/components/onboarding-tour";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@ -27,8 +25,6 @@ export function DashboardClientLayout({
}: { }: {
children: React.ReactNode; children: React.ReactNode;
searchSpaceId: string; searchSpaceId: string;
navSecondary?: any[];
navMain?: any[];
}) { }) {
const t = useTranslations("dashboard"); const t = useTranslations("dashboard");
const router = useRouter(); const router = useRouter();
@ -190,11 +186,7 @@ export function DashboardClientLayout({
return ( return (
<DocumentUploadDialogProvider> <DocumentUploadDialogProvider>
<OnboardingTour /> <OnboardingTour />
<LayoutDataProvider searchSpaceId={searchSpaceId} breadcrumb={<DashboardBreadcrumb />}> <LayoutDataProvider searchSpaceId={searchSpaceId}>{children}</LayoutDataProvider>
{children}
</LayoutDataProvider>
{/* Global connector dialog - triggered from documents page */}
<ConnectorIndicator hideTrigger />
</DocumentUploadDialogProvider> </DocumentUploadDialogProvider>
); );
} }

View file

@ -1,32 +1,9 @@
"use client"; "use client";
import { useSetAtom } from "jotai"; import { ListFilter, Search, Upload, X } from "lucide-react";
import {
CircleAlert,
FileType,
ListFilter,
Search,
SlidersHorizontal,
Trash,
Upload,
X,
} from "lucide-react";
import { motion } from "motion/react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import React, { useMemo, useRef, useState } from "react"; import React, { useCallback, useMemo, useRef, useState } from "react";
import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms";
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup"; import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@ -36,18 +13,14 @@ import { getDocumentTypeIcon, getDocumentTypeLabel } from "./DocumentTypeIcon";
export function DocumentsFilters({ export function DocumentsFilters({
typeCounts: typeCountsRecord, typeCounts: typeCountsRecord,
selectedIds,
onSearch, onSearch,
searchValue, searchValue,
onBulkDelete,
onToggleType, onToggleType,
activeTypes, activeTypes,
}: { }: {
typeCounts: Partial<Record<DocumentTypeEnum, number>>; typeCounts: Partial<Record<DocumentTypeEnum, number>>;
selectedIds: Set<number>;
onSearch: (v: string) => void; onSearch: (v: string) => void;
searchValue: string; searchValue: string;
onBulkDelete: () => Promise<void>;
onToggleType: (type: DocumentTypeEnum, checked: boolean) => void; onToggleType: (type: DocumentTypeEnum, checked: boolean) => void;
activeTypes: DocumentTypeEnum[]; activeTypes: DocumentTypeEnum[];
}) { }) {
@ -55,11 +28,16 @@ export function DocumentsFilters({
const id = React.useId(); const id = React.useId();
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
// Dialog hooks for action buttons
const { openDialog: openUploadDialog } = useDocumentUploadDialog(); const { openDialog: openUploadDialog } = useDocumentUploadDialog();
const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom);
const [typeSearchQuery, setTypeSearchQuery] = useState(""); const [typeSearchQuery, setTypeSearchQuery] = useState("");
const [scrollPos, setScrollPos] = useState<"top" | "middle" | "bottom">("top");
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
const el = e.currentTarget;
const atTop = el.scrollTop <= 2;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
setScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
}, []);
const uniqueTypes = useMemo(() => { const uniqueTypes = useMemo(() => {
return Object.keys(typeCountsRecord).sort() as DocumentTypeEnum[]; return Object.keys(typeCountsRecord).sort() as DocumentTypeEnum[];
@ -80,235 +58,153 @@ export function DocumentsFilters({
}, [typeCountsRecord]); }, [typeCountsRecord]);
return ( return (
<motion.div <div className="flex select-none">
className="flex flex-col gap-4 select-none" <div className="flex items-center gap-2 w-full">
initial={{ opacity: 0, y: 10 }} {/* Type Filter */}
animate={{ opacity: 1, y: 0 }} <Popover>
transition={{ type: "spring", stiffness: 300, damping: 30, delay: 0.1 }} <PopoverTrigger asChild>
> <Button
{/* Main toolbar row */} variant="outline"
<div className="flex flex-wrap items-center gap-3"> size="icon"
{/* Action Buttons - Left Side */} className="h-9 w-9 shrink-0 border-dashed border-sidebar-border text-sidebar-foreground/60 hover:text-sidebar-foreground hover:border-sidebar-border bg-sidebar"
<div className="flex items-center gap-2"> >
<Button <ListFilter size={14} />
onClick={openUploadDialog} {activeTypes.length > 0 && (
variant="outline" <span className="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-primary text-[9px] font-medium text-primary-foreground">
size="sm" {activeTypes.length}
className="h-9 gap-2 bg-white text-gray-700 border-white hover:bg-gray-50 dark:bg-white dark:text-gray-800 dark:hover:bg-gray-100" </span>
> )}
<Upload size={16} /> </Button>
<span>Upload documents</span> </PopoverTrigger>
</Button> <PopoverContent className="w-64 !p-0 overflow-hidden" align="end">
<Button <div>
onClick={() => setConnectorDialogOpen(true)} {/* Search input */}
variant="outline" <div className="p-2 border-b border-border dark:border-neutral-700">
size="sm" <div className="relative">
className="h-9 gap-2 bg-white text-gray-700 border-white hover:bg-gray-50 dark:bg-white dark:text-gray-800 dark:hover:bg-gray-100" <Search className="absolute left-0.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
> <Input
<SlidersHorizontal size={16} /> placeholder="Search types"
<span>Manage connectors</span> value={typeSearchQuery}
</Button> onChange={(e) => setTypeSearchQuery(e.target.value)}
</div> className="h-6 pl-6 text-sm bg-transparent border-0 shadow-none focus-visible:ring-0"
/>
</div>
</div>
{/* Spacer */} <div
<div className="flex-1" /> className="max-h-[300px] overflow-y-auto overflow-x-hidden py-1.5 px-1.5"
onScroll={handleScroll}
style={{
maskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
WebkitMaskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
}}
>
{filteredTypes.length === 0 ? (
<div className="py-6 text-center text-sm text-muted-foreground">
No types found
</div>
) : (
filteredTypes.map((value: DocumentTypeEnum, i) => (
<div
role="option"
aria-selected={activeTypes.includes(value)}
tabIndex={0}
key={value}
className="flex w-full items-center gap-2.5 py-2 px-3 rounded-md hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors cursor-pointer text-left"
onClick={() => onToggleType(value, !activeTypes.includes(value))}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onToggleType(value, !activeTypes.includes(value));
}
}}
>
{/* Icon */}
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-muted/50 text-foreground/80">
{getDocumentTypeIcon(value, "h-4 w-4")}
</div>
{/* Text content */}
<div className="flex flex-col min-w-0 flex-1 gap-0.5">
<span className="text-[13px] font-medium text-foreground truncate leading-tight">
{getDocumentTypeLabel(value)}
</span>
<span className="text-[11px] text-muted-foreground leading-tight">
{typeCounts.get(value)} document
{(typeCounts.get(value) ?? 0) !== 1 ? "s" : ""}
</span>
</div>
{/* Checkbox */}
<Checkbox
id={`${id}-${i}`}
checked={activeTypes.includes(value)}
onCheckedChange={(checked: boolean) => onToggleType(value, !!checked)}
className="h-4 w-4 shrink-0 rounded border-muted-foreground/30 data-[state=checked]:bg-primary data-[state=checked]:border-primary"
/>
</div>
))
)}
</div>
{activeTypes.length > 0 && (
<div className="px-3 pt-1.5 pb-1.5 border-t border-border dark:border-neutral-700">
<Button
variant="ghost"
size="sm"
className="w-full h-7 text-[11px] text-muted-foreground hover:text-foreground hover:bg-neutral-200 dark:hover:bg-neutral-700"
onClick={() => {
activeTypes.forEach((t) => {
onToggleType(t, false);
});
}}
>
Clear filters
</Button>
</div>
)}
</div>
</PopoverContent>
</Popover>
{/* Search Input */} {/* Search Input */}
<motion.div <div className="relative flex-1 min-w-0">
className="relative w-[180px]"
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ type: "spring", stiffness: 300, damping: 30 }}
>
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-muted-foreground"> <div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-muted-foreground">
<ListFilter size={14} aria-hidden="true" /> <Search size={14} aria-hidden="true" />
</div> </div>
<Input <Input
id={`${id}-input`} id={`${id}-input`}
ref={inputRef} ref={inputRef}
className="peer h-9 w-full pl-9 pr-9 text-sm bg-background border-border/60 focus-visible:ring-1 focus-visible:ring-ring/30 select-none focus:select-text" className="peer h-9 w-full pl-9 pr-9 text-sm bg-sidebar border-border/60 focus-visible:ring-1 focus-visible:ring-ring/30 select-none focus:select-text"
value={searchValue} value={searchValue}
onChange={(e) => onSearch(e.target.value)} onChange={(e) => onSearch(e.target.value)}
placeholder="Filter by title" placeholder="Search docs"
type="text" type="text"
aria-label={t("filter_placeholder")} aria-label={t("filter_placeholder")}
/> />
{Boolean(searchValue) && ( {Boolean(searchValue) && (
<motion.button <button
type="button"
className="absolute inset-y-0 right-0 flex h-full w-9 items-center justify-center rounded-r-md text-muted-foreground hover:text-foreground transition-colors" className="absolute inset-y-0 right-0 flex h-full w-9 items-center justify-center rounded-r-md text-muted-foreground hover:text-foreground transition-colors"
aria-label="Clear filter" aria-label="Clear filter"
onClick={() => { onClick={() => {
onSearch(""); onSearch("");
inputRef.current?.focus(); inputRef.current?.focus();
}} }}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
> >
<X size={14} strokeWidth={2} aria-hidden="true" /> <X size={14} strokeWidth={2} aria-hidden="true" />
</motion.button> </button>
)}
</motion.div>
{/* Filter Buttons Group */}
<div className="flex items-center gap-2 flex-wrap">
{/* Type Filter */}
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-9 gap-2 border-dashed border-border/60 text-muted-foreground hover:text-foreground hover:border-border"
>
<FileType size={14} className="text-muted-foreground" />
<span className="hidden sm:inline">Type</span>
{activeTypes.length > 0 && (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-primary text-[10px] font-medium text-primary-foreground">
{activeTypes.length}
</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 !p-0 overflow-hidden" align="end">
<div>
{/* Search input */}
<div className="p-2 border-b border-border/50">
<div className="relative">
<Search className="absolute left-0.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search types"
value={typeSearchQuery}
onChange={(e) => setTypeSearchQuery(e.target.value)}
className="h-6 pl-6 text-sm bg-transparent border-0 focus-visible:ring-0"
/>
</div>
</div>
<div className="max-h-[300px] overflow-y-auto overflow-x-hidden py-1.5 px-1.5">
{filteredTypes.length === 0 ? (
<div className="py-6 text-center text-sm text-muted-foreground">
No types found
</div>
) : (
filteredTypes.map((value: DocumentTypeEnum, i) => (
<div
key={value}
role="button"
tabIndex={0}
className="flex w-full items-center gap-2.5 py-2 px-3 rounded-md hover:bg-muted/50 transition-colors cursor-pointer text-left"
onClick={() => onToggleType(value, !activeTypes.includes(value))}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onToggleType(value, !activeTypes.includes(value));
}
}}
>
{/* Icon */}
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-muted/50 text-foreground/80">
{getDocumentTypeIcon(value, "h-4 w-4")}
</div>
{/* Text content */}
<div className="flex flex-col min-w-0 flex-1 gap-0.5">
<span className="text-[13px] font-medium text-foreground truncate leading-tight">
{getDocumentTypeLabel(value)}
</span>
<span className="text-[11px] text-muted-foreground leading-tight">
{typeCounts.get(value)} document
{(typeCounts.get(value) ?? 0) !== 1 ? "s" : ""}
</span>
</div>
{/* Checkbox */}
<Checkbox
id={`${id}-${i}`}
checked={activeTypes.includes(value)}
onCheckedChange={(checked: boolean) => onToggleType(value, !!checked)}
className="h-4 w-4 shrink-0 rounded border-muted-foreground/30 data-[state=checked]:bg-primary data-[state=checked]:border-primary"
/>
</div>
))
)}
</div>
{activeTypes.length > 0 && (
<div className="px-3 pt-1.5 pb-1.5 border-t border-border/50">
<Button
variant="ghost"
size="sm"
className="w-full h-7 text-[11px] text-muted-foreground hover:text-foreground"
onClick={() => {
activeTypes.forEach((t) => {
onToggleType(t, false);
});
}}
>
Clear filters
</Button>
</div>
)}
</div>
</PopoverContent>
</Popover>
{/* Bulk Delete Button */}
{selectedIds.size > 0 && (
<AlertDialog>
<AlertDialogTrigger asChild>
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
>
{/* Mobile: icon with count */}
<Button variant="destructive" size="sm" className="h-9 gap-1.5 px-2.5 md:hidden">
<Trash size={14} />
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-destructive-foreground/20 text-[10px] font-medium">
{selectedIds.size}
</span>
</Button>
{/* Desktop: full button */}
<Button variant="destructive" size="sm" className="h-9 gap-2 hidden md:flex">
<Trash size={14} />
Delete
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-destructive-foreground/20 text-[10px] font-medium">
{selectedIds.size}
</span>
</Button>
</motion.div>
</AlertDialogTrigger>
<AlertDialogContent className="max-w-md">
<div className="flex flex-col gap-2 sm:flex-row sm:gap-4">
<div
className="flex size-10 shrink-0 items-center justify-center rounded-full bg-destructive/10 text-destructive"
aria-hidden="true"
>
<CircleAlert size={18} strokeWidth={2} />
</div>
<AlertDialogHeader className="flex-1">
<AlertDialogTitle>
Delete {selectedIds.size} document{selectedIds.size !== 1 ? "s" : ""}?
</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the selected{" "}
{selectedIds.size === 1 ? "document" : "documents"} from your search space.
</AlertDialogDescription>
</AlertDialogHeader>
</div>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={onBulkDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)} )}
</div> </div>
{/* Upload Button */}
<Button
onClick={openUploadDialog}
variant="outline"
size="sm"
className="h-9 shrink-0 gap-1.5 bg-white text-gray-700 border-white hover:bg-gray-50 dark:bg-white dark:text-gray-800 dark:hover:bg-gray-100"
>
<Upload size={14} />
<span>Upload</span>
</Button>
</div> </div>
</motion.div> </div>
); );
} }

View file

@ -95,7 +95,6 @@ export function RowActions({
{/* Desktop Actions */} {/* Desktop Actions */}
<div className="hidden md:inline-flex items-center justify-center"> <div className="hidden md:inline-flex items-center justify-center">
{isEditable ? ( {isEditable ? (
// Editable documents: show 3-dot dropdown with edit + delete
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
@ -123,9 +122,7 @@ export function RowActions({
onClick={() => !isDeleteDisabled && setIsDeleteOpen(true)} onClick={() => !isDeleteDisabled && setIsDeleteOpen(true)}
disabled={isDeleteDisabled} disabled={isDeleteDisabled}
className={ className={
isDeleteDisabled isDeleteDisabled ? "text-muted-foreground cursor-not-allowed opacity-50" : ""
? "text-muted-foreground cursor-not-allowed opacity-50"
: "text-destructive focus:text-destructive"
} }
> >
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
@ -135,12 +132,11 @@ export function RowActions({
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
) : ( ) : (
// Non-editable documents: show only delete button directly
shouldShowDelete && ( shouldShowDelete && (
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className={`h-8 w-8 ${isDeleteDisabled ? "text-muted-foreground cursor-not-allowed" : "text-muted-foreground hover:text-destructive hover:bg-destructive/10"}`} className={`h-8 w-8 ${isDeleteDisabled ? "text-muted-foreground cursor-not-allowed" : "text-muted-foreground hover:text-foreground"}`}
onClick={() => !isDeleteDisabled && setIsDeleteOpen(true)} onClick={() => !isDeleteDisabled && setIsDeleteOpen(true)}
disabled={isDeleting || isDeleteDisabled} disabled={isDeleting || isDeleteDisabled}
> >
@ -154,7 +150,6 @@ export function RowActions({
{/* Mobile Actions Dropdown */} {/* Mobile Actions Dropdown */}
<div className="inline-flex md:hidden items-center justify-center"> <div className="inline-flex md:hidden items-center justify-center">
{isEditable ? ( {isEditable ? (
// Editable documents: show 3-dot dropdown
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground"> <Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground">
@ -178,9 +173,7 @@ export function RowActions({
onClick={() => !isDeleteDisabled && setIsDeleteOpen(true)} onClick={() => !isDeleteDisabled && setIsDeleteOpen(true)}
disabled={isDeleteDisabled} disabled={isDeleteDisabled}
className={ className={
isDeleteDisabled isDeleteDisabled ? "text-muted-foreground cursor-not-allowed opacity-50" : ""
? "text-muted-foreground cursor-not-allowed opacity-50"
: "text-destructive focus:text-destructive"
} }
> >
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
@ -190,12 +183,11 @@ export function RowActions({
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
) : ( ) : (
// Non-editable documents: show only delete button directly
shouldShowDelete && ( shouldShowDelete && (
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className={`h-8 w-8 ${isDeleteDisabled ? "text-muted-foreground cursor-not-allowed" : "text-muted-foreground hover:text-destructive hover:bg-destructive/10"}`} className={`h-8 w-8 ${isDeleteDisabled ? "text-muted-foreground cursor-not-allowed" : "text-muted-foreground hover:text-foreground"}`}
onClick={() => !isDeleteDisabled && setIsDeleteOpen(true)} onClick={() => !isDeleteDisabled && setIsDeleteOpen(true)}
disabled={isDeleting || isDeleteDisabled} disabled={isDeleting || isDeleteDisabled}
> >

View file

@ -1,317 +0,0 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { motion } from "motion/react";
import { useParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
import { useDocuments } from "@/hooks/use-documents";
import { documentsApiService } from "@/lib/apis/documents-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { DocumentsFilters } from "./components/DocumentsFilters";
import { DocumentsTableShell, type SortKey } from "./components/DocumentsTableShell";
import { PAGE_SIZE, PaginationControls } from "./components/PaginationControls";
import type { ColumnVisibility } from "./components/types";
function useDebounced<T>(value: T, delay = 250) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const t = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(t);
}, [value, delay]);
return debounced;
}
export default function DocumentsTable() {
const t = useTranslations("documents");
const params = useParams();
const searchSpaceId = Number(params.search_space_id);
const [search, setSearch] = useState("");
const debouncedSearch = useDebounced(search, 250);
const [activeTypes, setActiveTypes] = useState<DocumentTypeEnum[]>([]);
const [columnVisibility, setColumnVisibility] = useState<ColumnVisibility>({
document_type: true,
created_by: true,
created_at: true,
status: true,
});
const [pageIndex, setPageIndex] = useState(0);
const [sortKey, setSortKey] = useState<SortKey>("created_at");
const [sortDesc, setSortDesc] = useState(true);
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const { mutateAsync: deleteDocumentMutation } = useAtomValue(deleteDocumentMutationAtom);
// REAL-TIME: Use Electric SQL hook for live document updates (when not searching)
const {
documents: realtimeDocuments,
typeCounts: realtimeTypeCounts,
loading: realtimeLoading,
error: realtimeError,
} = useDocuments(searchSpaceId, activeTypes);
// Check if we're in search mode
const isSearchMode = !!debouncedSearch.trim();
// Build search query parameters (only used when searching)
const searchQueryParams = useMemo(
() => ({
search_space_id: searchSpaceId,
page: pageIndex,
page_size: PAGE_SIZE,
title: debouncedSearch.trim(),
...(activeTypes.length > 0 && { document_types: activeTypes }),
}),
[searchSpaceId, pageIndex, activeTypes, debouncedSearch]
);
// API search query (only enabled when searching - Electric doesn't do full-text search)
const {
data: searchResponse,
isLoading: isSearchLoading,
refetch: refetchSearch,
error: searchError,
} = useQuery({
queryKey: cacheKeys.documents.globalQueryParams(searchQueryParams),
queryFn: () => documentsApiService.searchDocuments({ queryParams: searchQueryParams }),
staleTime: 30 * 1000, // 30 seconds for search (shorter since it's on-demand)
enabled: !!searchSpaceId && isSearchMode,
});
// Client-side sorting for real-time documents
const sortedRealtimeDocuments = useMemo(() => {
const docs = [...realtimeDocuments];
docs.sort((a, b) => {
const av = a[sortKey] ?? "";
const bv = b[sortKey] ?? "";
let cmp: number;
if (sortKey === "created_at") {
cmp = new Date(av as string).getTime() - new Date(bv as string).getTime();
} else {
cmp = String(av).localeCompare(String(bv));
}
return sortDesc ? -cmp : cmp;
});
return docs;
}, [realtimeDocuments, sortKey, sortDesc]);
// Client-side pagination for real-time documents
const paginatedRealtimeDocuments = useMemo(() => {
const start = pageIndex * PAGE_SIZE;
const end = start + PAGE_SIZE;
return sortedRealtimeDocuments.slice(start, end);
}, [sortedRealtimeDocuments, pageIndex]);
// Determine what to display based on search mode
const displayDocs = isSearchMode
? (searchResponse?.items || []).map((item) => ({
id: item.id,
search_space_id: item.search_space_id,
document_type: item.document_type,
title: item.title,
created_by_id: item.created_by_id ?? null,
created_by_name: item.created_by_name ?? null,
created_by_email: item.created_by_email ?? null,
created_at: item.created_at,
status: (
item as {
status?: { state: "ready" | "pending" | "processing" | "failed"; reason?: string };
}
).status ?? { state: "ready" as const },
}))
: paginatedRealtimeDocuments;
const displayTotal = isSearchMode ? searchResponse?.total || 0 : sortedRealtimeDocuments.length;
const loading = isSearchMode ? isSearchLoading : realtimeLoading;
const error = isSearchMode ? searchError : realtimeError;
const pageEnd = Math.min((pageIndex + 1) * PAGE_SIZE, displayTotal);
const onToggleType = (type: DocumentTypeEnum, checked: boolean) => {
setActiveTypes((prev) => {
if (checked) {
return prev.includes(type) ? prev : [...prev, type];
} else {
return prev.filter((t) => t !== type);
}
});
setPageIndex(0);
// Clear selections when filter changes — selected IDs from the previous
// filter view are no longer visible and would cause misleading bulk actions
setSelectedIds(new Set());
};
const onBulkDelete = async () => {
if (selectedIds.size === 0) {
toast.error(t("no_rows_selected"));
return;
}
// Filter out pending/processing documents - they cannot be deleted
// For real-time mode, use sortedRealtimeDocuments (which has status)
// For search mode, use searchResponse items (need to safely access status)
const allDocs = isSearchMode
? (searchResponse?.items || []).map((item) => ({
id: item.id,
status: (item as { status?: { state: string } }).status,
}))
: sortedRealtimeDocuments.map((doc) => ({ id: doc.id, status: doc.status }));
const selectedDocs = allDocs.filter((doc) => selectedIds.has(doc.id));
const deletableIds = selectedDocs
.filter((doc) => doc.status?.state !== "pending" && doc.status?.state !== "processing")
.map((doc) => doc.id);
const inProgressCount = selectedIds.size - deletableIds.length;
if (inProgressCount > 0) {
toast.warning(
`${inProgressCount} document(s) are pending or processing and cannot be deleted.`
);
}
if (deletableIds.length === 0) {
return;
}
try {
// Delete documents one by one using the mutation
// Track 409 conflicts separately (document started processing after UI loaded)
let conflictCount = 0;
const results = await Promise.all(
deletableIds.map(async (id) => {
try {
await deleteDocumentMutation({ id });
return true;
} catch (error: unknown) {
const status =
(error as { response?: { status?: number } })?.response?.status ??
(error as { status?: number })?.status;
if (status === 409) conflictCount++;
return false;
}
})
);
const okCount = results.filter((r) => r === true).length;
if (okCount === deletableIds.length) {
toast.success(t("delete_success_count", { count: okCount }));
} else if (conflictCount > 0) {
toast.error(`${conflictCount} document(s) started processing. Please try again later.`);
} else {
toast.error(t("delete_partial_failed"));
}
// If in search mode, refetch search results to reflect deletion
if (isSearchMode) {
await refetchSearch();
}
// Real-time mode: Electric will sync the deletion automatically
setSelectedIds(new Set());
} catch (e) {
console.error(e);
toast.error(t("delete_error"));
}
};
// Single document delete handler for RowActions
const handleDeleteDocument = useCallback(
async (id: number): Promise<boolean> => {
try {
await deleteDocumentMutation({ id });
toast.success(t("delete_success") || "Document deleted");
// If in search mode, refetch search results to reflect deletion
if (isSearchMode) {
await refetchSearch();
}
// Real-time mode: Electric will sync the deletion automatically
return true;
} catch (e) {
console.error("Error deleting document:", e);
return false;
}
},
[deleteDocumentMutation, isSearchMode, refetchSearch, t]
);
const handleSortChange = useCallback((key: SortKey) => {
setSortKey((currentKey) => {
if (currentKey === key) {
setSortDesc((v) => !v);
return currentKey;
}
setSortDesc(false);
return key;
});
}, []);
// Reset page when search changes (type filter already resets via onToggleType)
// biome-ignore lint/correctness/useExhaustiveDependencies: Intentionally reset page on search change
useEffect(() => {
setPageIndex(0);
}, [debouncedSearch]);
useEffect(() => {
const mq = window.matchMedia("(max-width: 768px)");
const apply = (isSmall: boolean) => {
setColumnVisibility((prev) => ({ ...prev, created_by: !isSmall, created_at: !isSmall }));
};
apply(mq.matches);
const onChange = (e: MediaQueryListEvent) => apply(e.matches);
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}, []);
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="w-full max-w-7xl mx-auto px-6 pt-17 pb-6 space-y-6 min-h-[calc(100vh-64px)]"
>
{/* Filters - use real-time type counts */}
<DocumentsFilters
typeCounts={realtimeTypeCounts}
selectedIds={selectedIds}
onSearch={setSearch}
searchValue={search}
onBulkDelete={onBulkDelete}
onToggleType={onToggleType}
activeTypes={activeTypes}
/>
{/* Table */}
<DocumentsTableShell
documents={displayDocs}
loading={!!loading}
error={!!error}
selectedIds={selectedIds}
setSelectedIds={setSelectedIds}
columnVisibility={columnVisibility}
sortKey={sortKey}
sortDesc={sortDesc}
onSortChange={handleSortChange}
deleteDocument={handleDeleteDocument}
searchSpaceId={String(searchSpaceId)}
/>
{/* Pagination */}
<PaginationControls
pageIndex={pageIndex}
total={displayTotal}
onFirst={() => setPageIndex(0)}
onPrev={() => setPageIndex((i) => Math.max(0, i - 1))}
onNext={() => setPageIndex((i) => (pageEnd < displayTotal ? i + 1 : i))}
onLast={() => setPageIndex(Math.max(0, Math.ceil(displayTotal / PAGE_SIZE) - 1))}
canPrev={pageIndex > 0}
canNext={pageEnd < displayTotal}
/>
</motion.div>
);
}
export { DocumentsTable };

View file

@ -18,7 +18,7 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button"; import { Button, buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { notesApiService } from "@/lib/apis/notes-api.service"; import { notesApiService } from "@/lib/apis/notes-api.service";
@ -83,6 +83,7 @@ export default function EditorPage() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [showUnsavedDialog, setShowUnsavedDialog] = useState(false); const [showUnsavedDialog, setShowUnsavedDialog] = useState(false);
const [editorTitle, setEditorTitle] = useState<string>("Untitled");
// Store the latest markdown from the editor // Store the latest markdown from the editor
const markdownRef = useRef<string>(""); const markdownRef = useRef<string>("");
@ -117,20 +118,18 @@ export default function EditorPage() {
} }
}, [pendingNavigation, hasUnsavedChanges, router, setPendingNavigation]); }, [pendingNavigation, hasUnsavedChanges, router, setPendingNavigation]);
// Reset state when documentId changes // Reset state and fetch document content when documentId changes
useEffect(() => { useEffect(() => {
setDocument(null); setDocument(null);
setError(null); setError(null);
setHasUnsavedChanges(false); setHasUnsavedChanges(false);
setLoading(true); setLoading(true);
initialLoadDone.current = false; initialLoadDone.current = false;
}, [documentId]);
// Fetch document content
useEffect(() => {
async function fetchDocument() { async function fetchDocument() {
if (isNewNote) { if (isNewNote) {
markdownRef.current = ""; markdownRef.current = "";
setEditorTitle("Untitled");
setDocument({ setDocument({
document_id: 0, document_id: 0,
title: "Untitled", title: "Untitled",
@ -173,6 +172,7 @@ export default function EditorPage() {
} }
markdownRef.current = data.source_markdown; markdownRef.current = data.source_markdown;
setEditorTitle(extractTitleFromMarkdown(data.source_markdown));
setDocument(data); setDocument(data);
setError(null); setError(null);
initialLoadDone.current = true; initialLoadDone.current = true;
@ -193,20 +193,17 @@ export default function EditorPage() {
const isNote = isNewNote || document?.document_type === "NOTE"; const isNote = isNewNote || document?.document_type === "NOTE";
// Extract title dynamically from current markdown for notes
const displayTitle = useMemo(() => { const displayTitle = useMemo(() => {
if (isNote) { if (isNote) return editorTitle;
return extractTitleFromMarkdown(markdownRef.current || document?.source_markdown);
}
return document?.title || "Untitled"; return document?.title || "Untitled";
// eslint-disable-next-line react-hooks/exhaustive-deps }, [isNote, document?.title, editorTitle]);
}, [isNote, document?.title, document?.source_markdown, hasUnsavedChanges]);
// Handle markdown changes from the Plate editor // Handle markdown changes from the Plate editor
const handleMarkdownChange = useCallback((md: string) => { const handleMarkdownChange = useCallback((md: string) => {
markdownRef.current = md; markdownRef.current = md;
if (initialLoadDone.current) { if (initialLoadDone.current) {
setHasUnsavedChanges(true); setHasUnsavedChanges(true);
setEditorTitle(extractTitleFromMarkdown(md));
} }
}, []); }, []);
@ -256,7 +253,7 @@ export default function EditorPage() {
setHasUnsavedChanges(false); setHasUnsavedChanges(false);
toast.success("Note created successfully! Reindexing in background..."); toast.success("Note created successfully! Reindexing in background...");
router.push(`/dashboard/${searchSpaceId}/documents`); router.push(`/dashboard/${searchSpaceId}/new-chat`);
} else { } else {
// Existing document — save // Existing document — save
const response = await authenticatedFetch( const response = await authenticatedFetch(
@ -277,7 +274,7 @@ export default function EditorPage() {
setHasUnsavedChanges(false); setHasUnsavedChanges(false);
toast.success("Document saved! Reindexing in background..."); toast.success("Document saved! Reindexing in background...");
router.push(`/dashboard/${searchSpaceId}/documents`); router.push(`/dashboard/${searchSpaceId}/new-chat`);
} }
} catch (error) { } catch (error) {
console.error("Error saving document:", error); console.error("Error saving document:", error);
@ -298,7 +295,7 @@ export default function EditorPage() {
if (hasUnsavedChanges) { if (hasUnsavedChanges) {
setShowUnsavedDialog(true); setShowUnsavedDialog(true);
} else { } else {
router.push(`/dashboard/${searchSpaceId}/documents`); router.push(`/dashboard/${searchSpaceId}/new-chat`);
} }
}; };
@ -311,7 +308,7 @@ export default function EditorPage() {
router.push(pendingNavigation); router.push(pendingNavigation);
setPendingNavigation(null); setPendingNavigation(null);
} else { } else {
router.push(`/dashboard/${searchSpaceId}/documents`); router.push(`/dashboard/${searchSpaceId}/new-chat`);
} }
}; };
@ -493,13 +490,13 @@ export default function EditorPage() {
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel onClick={handleCancelLeave}>Cancel</AlertDialogCancel> <AlertDialogCancel onClick={handleCancelLeave}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleSaveAndLeave}>Save</AlertDialogAction>
<AlertDialogAction <AlertDialogAction
onClick={handleConfirmLeave} onClick={handleConfirmLeave}
className="border border-input bg-background text-foreground hover:bg-accent hover:text-accent-foreground" className={buttonVariants({ variant: "secondary" })}
> >
Leave without saving Leave without saving
</AlertDialogAction> </AlertDialogAction>
<AlertDialogAction onClick={handleSaveAndLeave}>Save</AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>

View file

@ -10,44 +10,7 @@ export default function DashboardLayout({
params: Promise<{ search_space_id: string }>; params: Promise<{ search_space_id: string }>;
children: React.ReactNode; children: React.ReactNode;
}) { }) {
// Use React.use to unwrap the params Promise
const { search_space_id } = use(params); const { search_space_id } = use(params);
const customNavSecondary = [ return <DashboardClientLayout searchSpaceId={search_space_id}>{children}</DashboardClientLayout>;
{
title: `All Search Spaces`,
url: `#`,
icon: "Info",
},
{
title: `All Search Spaces`,
url: "/dashboard",
icon: "Undo2",
},
];
const customNavMain = [
{
title: "Chat",
url: `/dashboard/${search_space_id}/new-chat`,
icon: "MessageCircle",
items: [],
},
{
title: "Documents",
url: `/dashboard/${search_space_id}/documents`,
icon: "SquareLibrary",
items: [],
},
];
return (
<DashboardClientLayout
searchSpaceId={search_space_id}
navSecondary={customNavSecondary}
navMain={customNavMain}
>
{children}
</DashboardClientLayout>
);
} }

View file

@ -1222,7 +1222,6 @@ function LogRowActions({ row, t }: { row: Row<Log>; t: (key: string) => string }
<AlertDialog open={isOpen} onOpenChange={setIsOpen}> <AlertDialog open={isOpen} onOpenChange={setIsOpen}>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<DropdownMenuItem <DropdownMenuItem
className="text-destructive focus:text-destructive"
onSelect={(e) => { onSelect={(e) => {
e.preventDefault(); e.preventDefault();
setIsOpen(true); setIsOpen(true);

View file

@ -22,6 +22,7 @@ import {
mentionedDocumentIdsAtom, mentionedDocumentIdsAtom,
mentionedDocumentsAtom, mentionedDocumentsAtom,
messageDocumentsMapAtom, messageDocumentsMapAtom,
sidebarSelectedDocumentsAtom,
} from "@/atoms/chat/mentioned-documents.atom"; } from "@/atoms/chat/mentioned-documents.atom";
import { import {
clearPlanOwnerRegistry, clearPlanOwnerRegistry,
@ -31,7 +32,6 @@ import { closeReportPanelAtom } from "@/atoms/chat/report-panel.atom";
import { membersAtom } from "@/atoms/members/members-query.atoms"; import { membersAtom } from "@/atoms/members/members-query.atoms";
import { currentUserAtom } from "@/atoms/user/user-query.atoms"; import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { Thread } from "@/components/assistant-ui/thread"; import { Thread } from "@/components/assistant-ui/thread";
import { ChatHeader } from "@/components/new-chat/chat-header";
import { ReportPanel } from "@/components/report-panel/report-panel"; import { ReportPanel } from "@/components/report-panel/report-panel";
import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking"; import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
import { DisplayImageToolUI } from "@/components/tool-ui/display-image"; import { DisplayImageToolUI } from "@/components/tool-ui/display-image";
@ -180,11 +180,12 @@ export default function NewChatPage() {
interruptData: Record<string, unknown>; interruptData: Record<string, unknown>;
} | null>(null); } | null>(null);
// Get mentioned document IDs from the composer // Get mentioned document IDs from the composer (derived from @ mentions + sidebar selections)
const mentionedDocumentIds = useAtomValue(mentionedDocumentIdsAtom); const mentionedDocumentIds = useAtomValue(mentionedDocumentIdsAtom);
const mentionedDocuments = useAtomValue(mentionedDocumentsAtom); const mentionedDocuments = useAtomValue(mentionedDocumentsAtom);
const setMentionedDocumentIds = useSetAtom(mentionedDocumentIdsAtom); const sidebarDocuments = useAtomValue(sidebarSelectedDocumentsAtom);
const setMentionedDocuments = useSetAtom(mentionedDocumentsAtom); const setMentionedDocuments = useSetAtom(mentionedDocumentsAtom);
const setSidebarDocuments = useSetAtom(sidebarSelectedDocumentsAtom);
const setMessageDocumentsMap = useSetAtom(messageDocumentsMapAtom); const setMessageDocumentsMap = useSetAtom(messageDocumentsMapAtom);
const setCurrentThreadState = useSetAtom(currentThreadAtom); const setCurrentThreadState = useSetAtom(currentThreadAtom);
const setTargetCommentId = useSetAtom(setTargetCommentIdAtom); const setTargetCommentId = useSetAtom(setTargetCommentIdAtom);
@ -276,11 +277,8 @@ export default function NewChatPage() {
setThreadId(null); setThreadId(null);
setCurrentThread(null); setCurrentThread(null);
setMessageThinkingSteps(new Map()); setMessageThinkingSteps(new Map());
setMentionedDocumentIds({
surfsense_doc_ids: [],
document_ids: [],
});
setMentionedDocuments([]); setMentionedDocuments([]);
setSidebarDocuments([]);
setMessageDocumentsMap({}); setMessageDocumentsMap({});
clearPlanOwnerRegistry(); // Reset plan ownership for new chat clearPlanOwnerRegistry(); // Reset plan ownership for new chat
closeReportPanel(); // Close report panel when switching chats closeReportPanel(); // Close report panel when switching chats
@ -345,8 +343,8 @@ export default function NewChatPage() {
}, [ }, [
urlChatId, urlChatId,
setMessageDocumentsMap, setMessageDocumentsMap,
setMentionedDocumentIds,
setMentionedDocuments, setMentionedDocuments,
setSidebarDocuments,
closeReportPanel, closeReportPanel,
]); ]);
@ -467,13 +465,10 @@ export default function NewChatPage() {
let isNewThread = false; let isNewThread = false;
if (!currentThreadId) { if (!currentThreadId) {
try { try {
// Create thread with truncated prompt as initial title const newThread = await createThread(searchSpaceId, "New Chat");
const initialTitle =
userQuery.trim().slice(0, 100) + (userQuery.trim().length > 100 ? "..." : "");
const newThread = await createThread(searchSpaceId, initialTitle);
currentThreadId = newThread.id; currentThreadId = newThread.id;
setThreadId(currentThreadId); setThreadId(currentThreadId);
// Set currentThread so ChatHeader can show share button immediately // Set currentThread so share button in header appears immediately
setCurrentThread(newThread); setCurrentThread(newThread);
// Track chat creation // Track chat creation
@ -528,31 +523,30 @@ export default function NewChatPage() {
messageLength: userQuery.length, messageLength: userQuery.length,
}); });
// Store mentioned documents with this message for display // Combine @-mention chips + sidebar selections for display & persistence
if (mentionedDocuments.length > 0) { const allMentionedDocs: MentionedDocumentInfo[] = [];
const docsInfo: MentionedDocumentInfo[] = mentionedDocuments.map((doc) => ({ const seenDocKeys = new Set<string>();
id: doc.id, for (const doc of [...mentionedDocuments, ...sidebarDocuments]) {
title: doc.title, const key = `${doc.document_type}:${doc.id}`;
document_type: doc.document_type, if (!seenDocKeys.has(key)) {
})); seenDocKeys.add(key);
allMentionedDocs.push({ id: doc.id, title: doc.title, document_type: doc.document_type });
}
}
if (allMentionedDocs.length > 0) {
setMessageDocumentsMap((prev) => ({ setMessageDocumentsMap((prev) => ({
...prev, ...prev,
[userMsgId]: docsInfo, [userMsgId]: allMentionedDocs,
})); }));
} }
// Persist user message with mentioned documents (don't await, fire and forget)
const persistContent: unknown[] = [...message.content]; const persistContent: unknown[] = [...message.content];
// Add mentioned documents for persistence if (allMentionedDocs.length > 0) {
if (mentionedDocuments.length > 0) {
persistContent.push({ persistContent.push({
type: "mentioned-documents", type: "mentioned-documents",
documents: mentionedDocuments.map((doc) => ({ documents: allMentionedDocs,
id: doc.id,
title: doc.title,
document_type: doc.document_type,
})),
}); });
} }
@ -560,7 +554,17 @@ export default function NewChatPage() {
role: "user", role: "user",
content: persistContent, content: persistContent,
}) })
.then(() => { .then((savedMessage) => {
const newUserMsgId = `msg-${savedMessage.id}`;
setMessages((prev) =>
prev.map((m) => (m.id === userMsgId ? { ...m, id: newUserMsgId } : m))
);
setMessageDocumentsMap((prev) => {
const docs = prev[userMsgId];
if (!docs) return prev;
const { [userMsgId]: _, ...rest } = prev;
return { ...rest, [newUserMsgId]: docs };
});
if (isNewThread) { if (isNewThread) {
queryClient.invalidateQueries({ queryKey: ["threads", String(searchSpaceId)] }); queryClient.invalidateQueries({ queryKey: ["threads", String(searchSpaceId)] });
} }
@ -618,11 +622,8 @@ export default function NewChatPage() {
// Clear mentioned documents after capturing them // Clear mentioned documents after capturing them
if (hasDocumentIds || hasSurfsenseDocIds) { if (hasDocumentIds || hasSurfsenseDocIds) {
setMentionedDocumentIds({
surfsense_doc_ids: [],
document_ids: [],
});
setMentionedDocuments([]); setMentionedDocuments([]);
setSidebarDocuments([]);
} }
const response = await fetch(`${backendUrl}/api/v1/new_chat`, { const response = await fetch(`${backendUrl}/api/v1/new_chat`, {
@ -747,15 +748,6 @@ export default function NewChatPage() {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ["threads", String(searchSpaceId)], queryKey: ["threads", String(searchSpaceId)],
}); });
// Invalidate thread detail for breadcrumb update
queryClient.invalidateQueries({
queryKey: [
"threads",
String(searchSpaceId),
"detail",
String(titleData.threadId),
],
});
} }
break; break;
} }
@ -920,8 +912,9 @@ export default function NewChatPage() {
messages, messages,
mentionedDocumentIds, mentionedDocumentIds,
mentionedDocuments, mentionedDocuments,
setMentionedDocumentIds, sidebarDocuments,
setMentionedDocuments, setMentionedDocuments,
setSidebarDocuments,
setMessageDocumentsMap, setMessageDocumentsMap,
queryClient, queryClient,
currentThread, currentThread,
@ -1674,10 +1667,7 @@ export default function NewChatPage() {
{/* <WriteTodosToolUI /> Disabled for now */} {/* <WriteTodosToolUI /> Disabled for now */}
<div className="flex h-[calc(100dvh-64px)] overflow-hidden"> <div className="flex h-[calc(100dvh-64px)] overflow-hidden">
<div className="flex-1 flex flex-col min-w-0 overflow-hidden"> <div className="flex-1 flex flex-col min-w-0 overflow-hidden">
<Thread <Thread messageThinkingSteps={messageThinkingSteps} />
messageThinkingSteps={messageThinkingSteps}
header={<ChatHeader searchSpaceId={searchSpaceId} />}
/>
</div> </div>
<ReportPanel /> <ReportPanel />
</div> </div>

View file

@ -259,7 +259,7 @@ export default function OnboardPage() {
You can add more configurations and customize settings anytime in{" "} You can add more configurations and customize settings anytime in{" "}
<button <button
type="button" type="button"
onClick={() => router.push(`/dashboard/${searchSpaceId}/settings?section=general`)} onClick={() => router.push(`/dashboard/${searchSpaceId}/settings?tab=general`)}
className="text-violet-500 hover:underline" className="text-violet-500 hover:underline"
> >
Settings Settings

View file

@ -1,9 +1,5 @@
import type React from "react"; import type React from "react";
/**
* Settings layout - renders children directly without the parent sidebar
* This creates a full-screen settings experience
*/
export default function SettingsLayout({ children }: { children: React.ReactNode }) { export default function SettingsLayout({ children }: { children: React.ReactNode }) {
return <div className="fixed inset-0 z-50 bg-background">{children}</div>; return <>{children}</>;
} }

View file

@ -1,24 +1,9 @@
"use client"; "use client";
import { import { Bot, Brain, FileText, Globe, ImageIcon, MessageSquare, Shield } from "lucide-react";
ArrowLeft,
Bot,
Brain,
ChevronRight,
FileText,
Globe,
ImageIcon,
type LucideIcon,
Menu,
MessageSquare,
Settings,
Shield,
X,
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useParams, useRouter, useSearchParams } from "next/navigation"; import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect } from "react";
import { PublicChatSnapshotsManager } from "@/components/public-chat-snapshots/public-chat-snapshots-manager"; import { PublicChatSnapshotsManager } from "@/components/public-chat-snapshots/public-chat-snapshots-manager";
import { GeneralSettingsManager } from "@/components/settings/general-settings-manager"; import { GeneralSettingsManager } from "@/components/settings/general-settings-manager";
import { ImageModelManager } from "@/components/settings/image-model-manager"; import { ImageModelManager } from "@/components/settings/image-model-manager";
@ -26,347 +11,103 @@ import { LLMRoleManager } from "@/components/settings/llm-role-manager";
import { ModelConfigManager } from "@/components/settings/model-config-manager"; import { ModelConfigManager } from "@/components/settings/model-config-manager";
import { PromptConfigManager } from "@/components/settings/prompt-config-manager"; import { PromptConfigManager } from "@/components/settings/prompt-config-manager";
import { RolesManager } from "@/components/settings/roles-manager"; import { RolesManager } from "@/components/settings/roles-manager";
import { Button } from "@/components/ui/button"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/animated-tabs";
import { trackSettingsViewed } from "@/lib/posthog/events"; import { trackSettingsViewed } from "@/lib/posthog/events";
import { cn } from "@/lib/utils";
interface SettingsNavItem { const VALID_TABS = [
id: string; "general",
labelKey: string; "models",
descriptionKey: string; "roles",
icon: LucideIcon; "image-models",
} "prompts",
"public-links",
"team-roles",
] as const;
const settingsNavItems: SettingsNavItem[] = [ const DEFAULT_TAB = "general";
{
id: "general",
labelKey: "nav_general",
descriptionKey: "nav_general_desc",
icon: FileText,
},
{
id: "models",
labelKey: "nav_agent_configs",
descriptionKey: "nav_agent_configs_desc",
icon: Bot,
},
{
id: "roles",
labelKey: "nav_role_assignments",
descriptionKey: "nav_role_assignments_desc",
icon: Brain,
},
{
id: "image-models",
labelKey: "nav_image_models",
descriptionKey: "nav_image_models_desc",
icon: ImageIcon,
},
{
id: "prompts",
labelKey: "nav_system_instructions",
descriptionKey: "nav_system_instructions_desc",
icon: MessageSquare,
},
{
id: "public-links",
labelKey: "nav_public_links",
descriptionKey: "nav_public_links_desc",
icon: Globe,
},
{
id: "team-roles",
labelKey: "nav_team_roles",
descriptionKey: "nav_team_roles_desc",
icon: Shield,
},
];
function SettingsSidebar({
activeSection,
onSectionChange,
onBackToApp,
isOpen,
onClose,
}: {
activeSection: string;
onSectionChange: (section: string) => void;
onBackToApp: () => void;
isOpen: boolean;
onClose: () => void;
}) {
const t = useTranslations("searchSpaceSettings");
const handleNavClick = (sectionId: string) => {
onSectionChange(sectionId);
onClose(); // Close sidebar on mobile after selection
};
return (
<>
{/* Mobile overlay */}
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 bg-background/80 backdrop-blur-sm z-40 md:hidden"
onClick={onClose}
/>
)}
</AnimatePresence>
{/* Sidebar */}
<aside
className={cn(
"fixed md:relative left-0 top-0 z-50 md:z-auto",
"w-72 shrink-0 bg-background md:bg-muted/30 h-full flex flex-col",
"md:border-r",
"transition-transform duration-300 ease-out",
"md:translate-x-0",
isOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"
)}
>
{/* Header with title */}
<div className="p-4 space-y-3">
<div className="flex items-center justify-between">
<Button
variant="ghost"
onClick={onBackToApp}
className="justify-start gap-3 h-11 px-3 hover:bg-muted group"
>
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10 group-hover:bg-primary/20 transition-colors">
<ArrowLeft className="h-4 w-4 text-primary" />
</div>
<span className="font-medium">{t("back_to_app")}</span>
</Button>
{/* Mobile close button */}
<Button variant="ghost" size="icon" onClick={onClose} className="md:hidden h-9 w-9">
<X className="h-5 w-5" />
</Button>
</div>
{/* Settings Title */}
<div className="px-3">
<h2 className="text-lg font-semibold text-foreground">{t("title")}</h2>
</div>
</div>
{/* Navigation Items */}
<nav className="flex-1 px-3 py-2 space-y-1 overflow-y-auto">
{settingsNavItems.map((item, index) => {
const isActive = activeSection === item.id;
const Icon = item.icon;
return (
<motion.button
key={item.id}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.1 + index * 0.05, duration: 0.3 }}
onClick={() => handleNavClick(item.id)}
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.99 }}
className={cn(
"relative w-full flex items-center gap-3 px-3 py-3 rounded-xl text-left transition-all duration-200",
isActive ? "bg-muted shadow-sm border border-border" : "hover:bg-muted/60"
)}
>
{isActive && (
<motion.div
layoutId="settingsActiveIndicator"
className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-8 bg-primary rounded-r-full"
initial={false}
transition={{
type: "spring",
stiffness: 500,
damping: 35,
}}
/>
)}
<div
className={cn(
"flex items-center justify-center w-9 h-9 rounded-lg transition-colors",
isActive ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground"
)}
>
<Icon className="h-4 w-4" />
</div>
<div className="flex-1 min-w-0">
<p
className={cn(
"text-sm font-medium truncate transition-colors",
isActive ? "text-foreground" : "text-muted-foreground"
)}
>
{t(item.labelKey)}
</p>
<p className="text-xs text-muted-foreground/70 truncate">
{t(item.descriptionKey)}
</p>
</div>
<ChevronRight
className={cn(
"h-4 w-4 shrink-0 transition-all",
isActive
? "text-primary opacity-100 translate-x-0"
: "text-muted-foreground/40 opacity-0 -translate-x-1"
)}
/>
</motion.button>
);
})}
</nav>
</aside>
</>
);
}
function SettingsContent({
activeSection,
searchSpaceId,
onMenuClick,
}: {
activeSection: string;
searchSpaceId: number;
onMenuClick: () => void;
}) {
const t = useTranslations("searchSpaceSettings");
const activeItem = settingsNavItems.find((item) => item.id === activeSection);
const Icon = activeItem?.icon || Settings;
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2, duration: 0.4 }}
className="flex-1 min-w-0 h-full overflow-hidden bg-background"
>
<div className="h-full overflow-y-auto">
<div className="max-w-4xl mx-auto p-4 md:p-6 lg:p-10">
{/* Section Header */}
<AnimatePresence mode="wait">
<motion.div
key={`${activeSection}-header`}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.3 }}
className="mb-6 md:mb-8"
>
<div className="flex items-center gap-3 md:gap-4">
{/* Mobile menu button */}
<Button
variant="outline"
size="icon"
onClick={onMenuClick}
className="md:hidden h-10 w-10 shrink-0"
>
<Menu className="h-5 w-5" />
</Button>
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.1, duration: 0.3 }}
className="flex items-center justify-center w-10 h-10 md:w-14 md:h-14 rounded-lg md:rounded-2xl bg-gradient-to-br from-primary/20 to-primary/5 border border-primary/10 shadow-sm shrink-0"
>
<Icon className="h-5 w-5 md:h-7 md:w-7 text-primary" />
</motion.div>
<div className="min-w-0">
<h1 className="text-lg md:text-2xl font-bold tracking-tight truncate">
{activeItem ? t(activeItem.labelKey) : ""}
</h1>
</div>
</div>
</motion.div>
</AnimatePresence>
{/* Section Content */}
<AnimatePresence mode="wait">
<motion.div
key={activeSection}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{
duration: 0.35,
ease: [0.4, 0, 0.2, 1],
}}
>
{activeSection === "general" && (
<GeneralSettingsManager searchSpaceId={searchSpaceId} />
)}
{activeSection === "models" && <ModelConfigManager searchSpaceId={searchSpaceId} />}
{activeSection === "roles" && <LLMRoleManager searchSpaceId={searchSpaceId} />}
{activeSection === "image-models" && (
<ImageModelManager searchSpaceId={searchSpaceId} />
)}
{activeSection === "prompts" && <PromptConfigManager searchSpaceId={searchSpaceId} />}
{activeSection === "public-links" && (
<PublicChatSnapshotsManager searchSpaceId={searchSpaceId} />
)}
{activeSection === "team-roles" && <RolesManager searchSpaceId={searchSpaceId} />}
</motion.div>
</AnimatePresence>
</div>
</div>
</motion.div>
);
}
const VALID_SECTIONS = new Set(settingsNavItems.map((item) => item.id));
const DEFAULT_SECTION = "general";
export default function SettingsPage() { export default function SettingsPage() {
const t = useTranslations("searchSpaceSettings");
const router = useRouter(); const router = useRouter();
const params = useParams(); const params = useParams();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const searchSpaceId = Number(params.search_space_id); const searchSpaceId = Number(params.search_space_id);
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const sectionParam = searchParams.get("section"); const tabParam = searchParams.get("tab") ?? "";
const activeSection = const activeTab = VALID_TABS.includes(tabParam as (typeof VALID_TABS)[number])
sectionParam && VALID_SECTIONS.has(sectionParam) ? sectionParam : DEFAULT_SECTION; ? tabParam
: DEFAULT_TAB;
const handleSectionChange = useCallback( const handleTabChange = useCallback(
(section: string) => { (value: string) => {
router.replace(`/dashboard/${searchSpaceId}/settings?section=${section}`, { scroll: false }); const p = new URLSearchParams(searchParams.toString());
p.set("tab", value);
router.replace(`?${p.toString()}`, { scroll: false });
}, },
[router, searchSpaceId] [router, searchParams]
); );
useEffect(() => { useEffect(() => {
trackSettingsViewed(searchSpaceId, activeSection); trackSettingsViewed(searchSpaceId, activeTab);
}, [searchSpaceId, activeSection]); }, [searchSpaceId, activeTab]);
const handleBackToApp = useCallback(() => {
router.push(`/dashboard/${searchSpaceId}/new-chat`);
}, [router, searchSpaceId]);
return ( return (
<motion.div <div className="h-full overflow-y-auto">
initial={{ opacity: 0 }} <div className="mx-auto w-full max-w-4xl px-4 py-10">
animate={{ opacity: 1 }} <Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
transition={{ duration: 0.3 }} <TabsList showBottomBorder>
className="fixed inset-0 z-50 flex bg-muted/40" <TabsTrigger value="general">
> <FileText className="mr-2 h-4 w-4" />
<div className="flex h-full w-full p-0 md:p-2"> {t("nav_general")}
<div className="flex h-full w-full overflow-hidden bg-background md:rounded-xl md:border md:shadow-sm"> </TabsTrigger>
<SettingsSidebar <TabsTrigger value="models">
activeSection={activeSection} <Bot className="mr-2 h-4 w-4" />
onSectionChange={handleSectionChange} {t("nav_agent_configs")}
onBackToApp={handleBackToApp} </TabsTrigger>
isOpen={isSidebarOpen} <TabsTrigger value="roles">
onClose={() => setIsSidebarOpen(false)} <Brain className="mr-2 h-4 w-4" />
/> {t("nav_role_assignments")}
<SettingsContent </TabsTrigger>
activeSection={activeSection} <TabsTrigger value="image-models">
searchSpaceId={searchSpaceId} <ImageIcon className="mr-2 h-4 w-4" />
onMenuClick={() => setIsSidebarOpen(true)} {t("nav_image_models")}
/> </TabsTrigger>
</div> <TabsTrigger value="team-roles">
<Shield className="mr-2 h-4 w-4" />
{t("nav_team_roles")}
</TabsTrigger>
<TabsTrigger value="prompts">
<MessageSquare className="mr-2 h-4 w-4" />
{t("nav_system_instructions")}
</TabsTrigger>
<TabsTrigger value="public-links">
<Globe className="mr-2 h-4 w-4" />
{t("nav_public_links")}
</TabsTrigger>
</TabsList>
<TabsContent value="general" className="mt-6">
<GeneralSettingsManager searchSpaceId={searchSpaceId} />
</TabsContent>
<TabsContent value="models" className="mt-6">
<ModelConfigManager searchSpaceId={searchSpaceId} />
</TabsContent>
<TabsContent value="roles" className="mt-6">
<LLMRoleManager searchSpaceId={searchSpaceId} />
</TabsContent>
<TabsContent value="image-models" className="mt-6">
<ImageModelManager searchSpaceId={searchSpaceId} />
</TabsContent>
<TabsContent value="prompts" className="mt-6">
<PromptConfigManager searchSpaceId={searchSpaceId} />
</TabsContent>
<TabsContent value="public-links" className="mt-6">
<PublicChatSnapshotsManager searchSpaceId={searchSpaceId} />
</TabsContent>
<TabsContent value="team-roles" className="mt-6">
<RolesManager searchSpaceId={searchSpaceId} />
</TabsContent>
</Tabs>
</div> </div>
</motion.div> </div>
); );
} }

View file

@ -138,6 +138,7 @@ function getAvatarInitials(member: Membership): string {
} }
const PAGE_SIZE = 5; const PAGE_SIZE = 5;
const SKELETON_KEYS = Array.from({ length: PAGE_SIZE }, (_, i) => `skeleton-${i}`);
export default function TeamManagementPage() { export default function TeamManagementPage() {
const params = useParams(); const params = useParams();
@ -290,11 +291,8 @@ export default function TeamManagementPage() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{Array.from({ length: PAGE_SIZE }).map((_, i) => ( {SKELETON_KEYS.map((id) => (
<TableRow <TableRow key={id} className="border-b border-border/40 hover:bg-transparent">
key={`skeleton-${i}`}
className="border-b border-border/40 hover:bg-transparent"
>
<TableCell className="w-[45%] py-2.5 px-4 md:px-6 border-r border-border/40"> <TableCell className="w-[45%] py-2.5 px-4 md:px-6 border-r border-border/40">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Skeleton className="h-10 w-10 rounded-full shrink-0" /> <Skeleton className="h-10 w-10 rounded-full shrink-0" />
@ -546,7 +544,7 @@ function MemberRow({
</TableCell> </TableCell>
<TableCell className="hidden md:table-cell w-[25%] py-2.5 text-sm text-foreground border-r border-border/40"> <TableCell className="hidden md:table-cell w-[25%] py-2.5 text-sm text-foreground border-r border-border/40">
{formatRelativeDate(member.joined_at)} {member.user_last_login ? formatRelativeDate(member.user_last_login) : "Never"}
</TableCell> </TableCell>
<TableCell className="w-[30%] text-right py-2.5 px-4 md:px-6"> <TableCell className="w-[30%] text-right py-2.5 px-4 md:px-6">
@ -564,7 +562,7 @@ function MemberRow({
<DropdownMenuContent <DropdownMenuContent
align="end" align="end"
onCloseAutoFocus={(e) => e.preventDefault()} onCloseAutoFocus={(e) => e.preventDefault()}
className="min-w-[120px] bg-muted dark:border dark:border-neutral-700" className="min-w-[120px] dark:bg-neutral-900 dark:border dark:border-white/5"
> >
{canManageRoles && {canManageRoles &&
roles roles
@ -581,8 +579,8 @@ function MemberRow({
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<DropdownMenuItem <DropdownMenuItem
className="text-destructive focus:text-destructive"
onSelect={(e) => e.preventDefault()} onSelect={(e) => e.preventDefault()}
className="text-destructive focus:text-destructive"
> >
Remove Remove
</DropdownMenuItem> </DropdownMenuItem>
@ -607,11 +605,9 @@ function MemberRow({
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
)} )}
<DropdownMenuSeparator className="dark:bg-neutral-700" /> <DropdownMenuSeparator className="dark:bg-white/5" />
<DropdownMenuItem <DropdownMenuItem
onClick={() => onClick={() => router.push(`/dashboard/${searchSpaceId}/settings?tab=team-roles`)}
router.push(`/dashboard/${searchSpaceId}/settings?section=team-roles`)
}
> >
Manage Roles Manage Roles
</DropdownMenuItem> </DropdownMenuItem>
@ -811,7 +807,7 @@ function CreateInviteDialog({
<Button <Button
variant="outline" variant="outline"
className={cn( className={cn(
"w-full justify-start text-left font-normal", "w-full justify-start text-left font-normal bg-transparent",
!expiresAt && "text-muted-foreground" !expiresAt && "text-muted-foreground"
)} )}
> >
@ -832,8 +828,8 @@ function CreateInviteDialog({
</div> </div>
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter className="gap-3 sm:gap-2">
<Button variant="outline" onClick={handleClose}> <Button variant="secondary" onClick={handleClose}>
Cancel Cancel
</Button> </Button>
<Button onClick={handleCreate} disabled={creating}> <Button onClick={handleCreate} disabled={creating}>
@ -876,10 +872,10 @@ function AllInvitesDialog({
return ( return (
<Dialog> <Dialog>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="outline" className="gap-2"> <Button variant="secondary" className="gap-2">
<Link2 className="h-4 w-4 rotate-315" /> <Link2 className="h-4 w-4 rotate-315" />
Active invites Active invites
<span className="inline-flex items-center justify-center h-5 min-w-5 px-1 rounded-full bg-muted text-xs font-medium"> <span className="inline-flex items-center justify-center h-5 min-w-5 px-1 rounded-full bg-neutral-700 text-neutral-200 text-xs font-medium">
{invites.length} {invites.length}
</span> </span>
</Button> </Button>

View file

@ -0,0 +1,116 @@
"use client";
import { Check, Copy, Info } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useTranslations } from "next-intl";
import { useCallback, useRef, useState } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { useApiKey } from "@/hooks/use-api-key";
import { copyToClipboard as copyToClipboardUtil } from "@/lib/utils";
export function ApiKeyContent() {
const t = useTranslations("userSettings");
const { apiKey, isLoading, copied, copyToClipboard } = useApiKey();
const [copiedUsage, setCopiedUsage] = useState(false);
const usageCopyTimeoutRef = useRef<ReturnType<typeof setTimeout>>(null);
const copyUsageToClipboard = useCallback(async () => {
const text = `Authorization: Bearer ${apiKey || "YOUR_API_KEY"}`;
const success = await copyToClipboardUtil(text);
if (success) {
setCopiedUsage(true);
if (usageCopyTimeoutRef.current) clearTimeout(usageCopyTimeoutRef.current);
usageCopyTimeoutRef.current = setTimeout(() => setCopiedUsage(false), 2000);
}
}, [apiKey]);
return (
<AnimatePresence mode="wait">
<motion.div
key="api-key-content"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.35, ease: [0.4, 0, 0.2, 1] }}
className="space-y-6"
>
<Alert className="border-border/60 bg-muted/30 text-muted-foreground">
<Info className="h-4 w-4 text-muted-foreground" />
<AlertTitle className="text-muted-foreground">{t("api_key_warning_title")}</AlertTitle>
<AlertDescription className="text-muted-foreground/60">
{t("api_key_warning_description")}
</AlertDescription>
</Alert>
<div className="rounded-lg border border-border/60 bg-card p-6">
<h3 className="mb-4 text-sm font-semibold tracking-tight">{t("your_api_key")}</h3>
{isLoading ? (
<div className="h-12 w-full animate-pulse rounded-md border border-border/60 bg-muted/30" />
) : apiKey ? (
<div className="flex items-center gap-2 rounded-md border border-border/60 bg-muted/30 px-2.5 py-1.5">
<div className="min-w-0 flex-1 overflow-x-auto scrollbar-hide">
<p className="font-mono text-[10px] text-muted-foreground whitespace-nowrap select-all cursor-text">
{apiKey}
</p>
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={copyToClipboard}
className="h-6 w-6 shrink-0 text-muted-foreground hover:text-foreground"
>
{copied ? (
<Check className="h-3 w-3 text-green-500" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>{copied ? t("copied") : t("copy")}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
) : (
<p className="text-center text-muted-foreground/60">{t("no_api_key")}</p>
)}
</div>
<div className="rounded-lg border border-border/60 bg-card p-6">
<h3 className="mb-2 text-sm font-semibold tracking-tight">{t("usage_title")}</h3>
<p className="mb-4 text-[11px] text-muted-foreground/60">{t("usage_description")}</p>
<div className="flex items-center gap-2 rounded-md border border-border/60 bg-muted/30 px-2.5 py-1.5">
<div className="min-w-0 flex-1 overflow-x-auto scrollbar-hide">
<pre className="font-mono text-[10px] text-muted-foreground whitespace-nowrap select-all cursor-text">
<code>Authorization: Bearer {apiKey || "YOUR_API_KEY"}</code>
</pre>
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={copyUsageToClipboard}
className="h-6 w-6 shrink-0 text-muted-foreground hover:text-foreground"
>
{copiedUsage ? (
<Check className="h-3 w-3 text-green-500" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>{copiedUsage ? t("copied") : t("copy")}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
</motion.div>
</AnimatePresence>
);
}

View file

@ -0,0 +1,129 @@
"use client";
import { useAtomValue } from "jotai";
import { AnimatePresence, motion } from "motion/react";
import Image from "next/image";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { updateUserMutationAtom } from "@/atoms/user/user-mutation.atoms";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/spinner";
function AvatarDisplay({ url, fallback }: { url?: string; fallback: string }) {
const [errorUrl, setErrorUrl] = useState<string>();
const hasError = errorUrl === url;
if (url && !hasError) {
return (
<Image
src={url}
alt="Avatar"
width={64}
height={64}
className="h-16 w-16 rounded-xl object-cover"
onError={() => setErrorUrl(url)}
unoptimized
/>
);
}
return (
<div className="flex h-16 w-16 items-center justify-center rounded-xl bg-muted text-xl font-semibold text-muted-foreground">
{fallback}
</div>
);
}
export function ProfileContent() {
const t = useTranslations("userSettings");
const { data: user, isLoading: isUserLoading } = useAtomValue(currentUserAtom);
const { mutateAsync: updateUser, isPending } = useAtomValue(updateUserMutationAtom);
const [displayName, setDisplayName] = useState("");
useEffect(() => {
if (user) {
setDisplayName(user.display_name || "");
}
}, [user]);
const getInitials = (email: string) => {
const name = email.split("@")[0];
return name.slice(0, 2).toUpperCase();
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
await updateUser({
display_name: displayName || null,
});
toast.success(t("profile_saved"));
} catch {
toast.error(t("profile_save_error"));
}
};
const hasChanges = displayName !== (user?.display_name || "");
return (
<AnimatePresence mode="wait">
<motion.div
key="profile-content"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.35, ease: [0.4, 0, 0.2, 1] }}
>
{isUserLoading ? (
<div className="flex items-center justify-center py-12">
<Spinner size="md" className="text-muted-foreground" />
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-6">
<div className="rounded-lg border bg-card p-6">
<div className="flex flex-col gap-6">
<div className="space-y-2">
<Label>{t("profile_avatar")}</Label>
<AvatarDisplay
url={user?.avatar_url || undefined}
fallback={getInitials(user?.email || "")}
/>
</div>
<div className="space-y-2">
<Label htmlFor="display-name">{t("profile_display_name")}</Label>
<Input
id="display-name"
type="text"
placeholder={user?.email?.split("@")[0]}
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
/>
<p className="text-xs text-muted-foreground">{t("profile_display_name_hint")}</p>
</div>
<div className="space-y-2">
<Label>{t("profile_email")}</Label>
<Input type="email" value={user?.email || ""} disabled />
</div>
</div>
</div>
<div className="flex justify-end">
<Button type="submit" disabled={isPending || !hasChanges}>
{isPending && <Spinner size="sm" className="mr-2" />}
{t("profile_save")}
</Button>
</div>
</form>
)}
</motion.div>
</AnimatePresence>
);
}

View file

@ -0,0 +1,57 @@
"use client";
import { User, UserKey } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/animated-tabs";
import { ApiKeyContent } from "./components/ApiKeyContent";
import { ProfileContent } from "./components/ProfileContent";
const VALID_TABS = ["profile", "api-key"] as const;
const DEFAULT_TAB = "profile";
export default function UserSettingsPage() {
const t = useTranslations("userSettings");
const router = useRouter();
const searchParams = useSearchParams();
const tabParam = searchParams.get("tab") ?? "";
const activeTab = VALID_TABS.includes(tabParam as (typeof VALID_TABS)[number])
? tabParam
: DEFAULT_TAB;
const handleTabChange = useCallback(
(value: string) => {
const params = new URLSearchParams(searchParams.toString());
params.set("tab", value);
router.replace(`?${params.toString()}`, { scroll: false });
},
[router, searchParams]
);
return (
<div className="h-full overflow-y-auto">
<div className="mx-auto w-full max-w-4xl px-4 py-10">
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
<TabsList showBottomBorder>
<TabsTrigger value="profile">
<User className="mr-2 h-4 w-4" />
{t("profile_nav_label")}
</TabsTrigger>
<TabsTrigger value="api-key">
<UserKey className="mr-2 h-4 w-4" />
{t("api_key_nav_label")}
</TabsTrigger>
</TabsList>
<TabsContent value="profile" className="mt-6">
<ProfileContent />
</TabsContent>
<TabsContent value="api-key" className="mt-6">
<ApiKeyContent />
</TabsContent>
</Tabs>
</div>
</div>
);
}

View file

@ -1,122 +0,0 @@
"use client";
import { Check, Copy, Key, Menu, Shield } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useTranslations } from "next-intl";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { useApiKey } from "@/hooks/use-api-key";
interface ApiKeyContentProps {
onMenuClick: () => void;
}
export function ApiKeyContent({ onMenuClick }: ApiKeyContentProps) {
const t = useTranslations("userSettings");
const { apiKey, isLoading, copied, copyToClipboard } = useApiKey();
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2, duration: 0.4 }}
className="h-full min-w-0 flex-1 overflow-hidden bg-background"
>
<div className="h-full overflow-y-auto">
<div className="mx-auto max-w-4xl p-4 md:p-6 lg:p-10">
<AnimatePresence mode="wait">
<motion.div
key="api-key-header"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.3 }}
className="mb-6 md:mb-8"
>
<div className="flex items-center gap-3 md:gap-4">
<Button
variant="outline"
size="icon"
onClick={onMenuClick}
className="h-10 w-10 shrink-0 md:hidden"
>
<Menu className="h-5 w-5" />
</Button>
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.1, duration: 0.3 }}
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-primary/10 bg-gradient-to-br from-primary/20 to-primary/5 shadow-sm md:h-14 md:w-14 md:rounded-2xl"
>
<Key className="h-5 w-5 text-primary md:h-7 md:w-7" />
</motion.div>
<div className="min-w-0">
<h1 className="truncate text-lg font-bold tracking-tight md:text-2xl">
{t("api_key_title")}
</h1>
<p className="text-sm text-muted-foreground">{t("api_key_description")}</p>
</div>
</div>
</motion.div>
</AnimatePresence>
<AnimatePresence mode="wait">
<motion.div
key="api-key-content"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.35, ease: [0.4, 0, 0.2, 1] }}
className="space-y-6"
>
<Alert>
<Shield className="h-4 w-4" />
<AlertTitle>{t("api_key_warning_title")}</AlertTitle>
<AlertDescription>{t("api_key_warning_description")}</AlertDescription>
</Alert>
<div className="rounded-lg border bg-card p-6">
<h3 className="mb-4 font-medium">{t("your_api_key")}</h3>
{isLoading ? (
<div className="h-12 w-full animate-pulse rounded-md bg-muted" />
) : apiKey ? (
<div className="flex items-center gap-2">
<div className="flex-1 overflow-x-auto rounded-md bg-muted p-3 font-mono text-sm">
{apiKey}
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={copyToClipboard}
className="shrink-0"
>
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</TooltipTrigger>
<TooltipContent>{copied ? t("copied") : t("copy")}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
) : (
<p className="text-center text-muted-foreground">{t("no_api_key")}</p>
)}
</div>
<div className="rounded-lg border bg-card p-6">
<h3 className="mb-2 font-medium">{t("usage_title")}</h3>
<p className="mb-4 text-sm text-muted-foreground">{t("usage_description")}</p>
<pre className="overflow-x-auto rounded-md bg-muted p-3 text-sm">
<code>Authorization: Bearer {apiKey || "YOUR_API_KEY"}</code>
</pre>
</div>
</motion.div>
</AnimatePresence>
</div>
</div>
</motion.div>
);
}

View file

@ -1,182 +0,0 @@
"use client";
import { useAtomValue } from "jotai";
import { Menu, User } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { updateUserMutationAtom } from "@/atoms/user/user-mutation.atoms";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/spinner";
interface ProfileContentProps {
onMenuClick: () => void;
}
function AvatarDisplay({ url, fallback }: { url?: string; fallback: string }) {
const [hasError, setHasError] = useState(false);
useEffect(() => {
setHasError(false);
}, [url]);
if (url && !hasError) {
return (
<img
src={url}
alt="Avatar"
className="h-16 w-16 rounded-xl object-cover"
onError={() => setHasError(true)}
/>
);
}
return (
<div className="flex h-16 w-16 items-center justify-center rounded-xl bg-muted text-xl font-semibold text-muted-foreground">
{fallback}
</div>
);
}
export function ProfileContent({ onMenuClick }: ProfileContentProps) {
const t = useTranslations("userSettings");
const { data: user, isLoading: isUserLoading } = useAtomValue(currentUserAtom);
const { mutateAsync: updateUser, isPending } = useAtomValue(updateUserMutationAtom);
const [displayName, setDisplayName] = useState("");
useEffect(() => {
if (user) {
setDisplayName(user.display_name || "");
}
}, [user]);
const getInitials = (email: string) => {
const name = email.split("@")[0];
return name.slice(0, 2).toUpperCase();
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
await updateUser({
display_name: displayName || null,
});
toast.success(t("profile_saved"));
} catch {
toast.error(t("profile_save_error"));
}
};
const hasChanges = displayName !== (user?.display_name || "");
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2, duration: 0.4 }}
className="h-full min-w-0 flex-1 overflow-hidden bg-background"
>
<div className="h-full overflow-y-auto">
<div className="mx-auto max-w-4xl p-4 md:p-6 lg:p-10">
<AnimatePresence mode="wait">
<motion.div
key="profile-header"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.3 }}
className="mb-6 md:mb-8"
>
<div className="flex items-center gap-3 md:gap-4">
<Button
variant="outline"
size="icon"
onClick={onMenuClick}
className="h-10 w-10 shrink-0 md:hidden"
>
<Menu className="h-5 w-5" />
</Button>
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.1, duration: 0.3 }}
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-primary/10 bg-gradient-to-br from-primary/20 to-primary/5 shadow-sm md:h-14 md:w-14 md:rounded-2xl"
>
<User className="h-5 w-5 text-primary md:h-7 md:w-7" />
</motion.div>
<div className="min-w-0">
<h1 className="truncate text-lg font-bold tracking-tight md:text-2xl">
{t("profile_title")}
</h1>
<p className="text-sm text-muted-foreground">{t("profile_description")}</p>
</div>
</div>
</motion.div>
</AnimatePresence>
<AnimatePresence mode="wait">
<motion.div
key="profile-content"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.35, ease: [0.4, 0, 0.2, 1] }}
>
{isUserLoading ? (
<div className="flex items-center justify-center py-12">
<Spinner size="md" className="text-muted-foreground" />
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-6">
<div className="rounded-lg border bg-card p-6">
<div className="flex flex-col gap-6">
<div className="space-y-2">
<Label>{t("profile_avatar")}</Label>
<AvatarDisplay
url={user?.avatar_url || undefined}
fallback={getInitials(user?.email || "")}
/>
</div>
<div className="space-y-2">
<Label htmlFor="display-name">{t("profile_display_name")}</Label>
<Input
id="display-name"
type="text"
placeholder={user?.email?.split("@")[0]}
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
{t("profile_display_name_hint")}
</p>
</div>
<div className="space-y-2">
<Label>{t("profile_email")}</Label>
<Input type="email" value={user?.email || ""} disabled />
</div>
</div>
</div>
<div className="flex justify-end">
<Button type="submit" disabled={isPending || !hasChanges}>
{isPending && <Spinner size="sm" className="mr-2" />}
{t("profile_save")}
</Button>
</div>
</form>
)}
</motion.div>
</AnimatePresence>
</div>
</div>
</motion.div>
);
}

View file

@ -1,160 +0,0 @@
"use client";
import type { LucideIcon } from "lucide-react";
import { ArrowLeft, ChevronRight, X } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { APP_VERSION } from "@/lib/env-config";
import { cn } from "@/lib/utils";
export interface SettingsNavItem {
id: string;
label: string;
description: string;
icon: LucideIcon;
}
interface UserSettingsSidebarProps {
activeSection: string;
onSectionChange: (section: string) => void;
onBackToApp: () => void;
isOpen: boolean;
onClose: () => void;
navItems: SettingsNavItem[];
}
export function UserSettingsSidebar({
activeSection,
onSectionChange,
onBackToApp,
isOpen,
onClose,
navItems,
}: UserSettingsSidebarProps) {
const t = useTranslations("userSettings");
const handleNavClick = (sectionId: string) => {
onSectionChange(sectionId);
onClose();
};
return (
<>
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 z-40 bg-background/80 backdrop-blur-sm md:hidden"
onClick={onClose}
/>
)}
</AnimatePresence>
<aside
className={cn(
"fixed left-0 top-0 z-50 md:relative md:z-auto",
"flex h-full w-72 shrink-0 flex-col bg-background md:bg-muted/30",
"md:border-r",
"transition-transform duration-300 ease-out",
"md:translate-x-0",
isOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"
)}
>
{/* Header with title */}
<div className="space-y-3 p-4">
<div className="flex items-center justify-between">
<Button
variant="ghost"
onClick={onBackToApp}
className="group h-11 justify-start gap-3 px-3 hover:bg-muted"
>
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 transition-colors group-hover:bg-primary/20">
<ArrowLeft className="h-4 w-4 text-primary" />
</div>
<span className="font-medium">{t("back_to_app")}</span>
</Button>
<Button variant="ghost" size="icon" onClick={onClose} className="h-9 w-9 md:hidden">
<X className="h-5 w-5" />
</Button>
</div>
{/* Settings Title */}
<div className="px-3">
<h2 className="text-lg font-semibold text-foreground">{t("title")}</h2>
</div>
</div>
<nav className="flex-1 space-y-1 overflow-y-auto px-3 py-2">
{navItems.map((item, index) => {
const isActive = activeSection === item.id;
const Icon = item.icon;
return (
<motion.button
key={item.id}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.1 + index * 0.05, duration: 0.3 }}
onClick={() => handleNavClick(item.id)}
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.99 }}
className={cn(
"relative flex w-full items-center gap-3 rounded-xl px-3 py-3 text-left transition-all duration-200",
isActive ? "border border-border bg-muted shadow-sm" : "hover:bg-muted/60"
)}
>
{isActive && (
<motion.div
layoutId="userSettingsActiveIndicator"
className="absolute left-0 top-1/2 h-8 w-1 -translate-y-1/2 rounded-r-full bg-primary"
initial={false}
transition={{
type: "spring",
stiffness: 500,
damping: 35,
}}
/>
)}
<div
className={cn(
"flex h-9 w-9 items-center justify-center rounded-lg transition-colors",
isActive ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground"
)}
>
<Icon className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<p
className={cn(
"truncate text-sm font-medium transition-colors",
isActive ? "text-foreground" : "text-muted-foreground"
)}
>
{item.label}
</p>
<p className="truncate text-xs text-muted-foreground/70">{item.description}</p>
</div>
<ChevronRight
className={cn(
"h-4 w-4 shrink-0 transition-all",
isActive
? "translate-x-0 text-primary opacity-100"
: "-translate-x-1 text-muted-foreground/40 opacity-0"
)}
/>
</motion.button>
);
})}
</nav>
{/* Version display */}
<div className="mt-auto border-t px-6 py-3">
<p className="text-xs text-muted-foreground/50">v{APP_VERSION}</p>
</div>
</aside>
</>
);
}

View file

@ -1,64 +0,0 @@
"use client";
import { Key, User } from "lucide-react";
import { motion } from "motion/react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useState } from "react";
import { ApiKeyContent } from "./components/ApiKeyContent";
import { ProfileContent } from "./components/ProfileContent";
import { type SettingsNavItem, UserSettingsSidebar } from "./components/UserSettingsSidebar";
export default function UserSettingsPage() {
const t = useTranslations("userSettings");
const router = useRouter();
const [activeSection, setActiveSection] = useState("profile");
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const navItems: SettingsNavItem[] = [
{
id: "profile",
label: t("profile_nav_label"),
description: t("profile_nav_description"),
icon: User,
},
{
id: "api-key",
label: t("api_key_nav_label"),
description: t("api_key_nav_description"),
icon: Key,
},
];
const handleBackToApp = useCallback(() => {
router.back();
}, [router]);
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
className="fixed inset-0 z-50 flex bg-muted/40"
>
<div className="flex h-full w-full p-0 md:p-2">
<div className="flex h-full w-full overflow-hidden bg-background md:rounded-xl md:border md:shadow-sm">
<UserSettingsSidebar
activeSection={activeSection}
onSectionChange={setActiveSection}
onBackToApp={handleBackToApp}
isOpen={isSidebarOpen}
onClose={() => setIsSidebarOpen(false)}
navItems={navItems}
/>
{activeSection === "profile" && (
<ProfileContent onMenuClick={() => setIsSidebarOpen(true)} />
)}
{activeSection === "api-key" && (
<ApiKeyContent onMenuClick={() => setIsSidebarOpen(true)} />
)}
</div>
</div>
</motion.div>
);
}

View file

@ -195,6 +195,18 @@ button {
background-color: hsl(var(--muted-foreground) / 0.4); background-color: hsl(var(--muted-foreground) / 0.4);
} }
/* Hide scrollbar on mobile, only visible while scrolling */
@media (max-width: 767px) {
.scrollbar-thin {
scrollbar-width: none;
}
.scrollbar-thin::-webkit-scrollbar {
width: 0;
display: none;
}
}
/* Human-in-the-loop approval card animations */ /* Human-in-the-loop approval card animations */
@keyframes pulse-subtle { @keyframes pulse-subtle {
0%, 0%,
@ -231,7 +243,7 @@ button {
} }
} }
@source '../node_modules/@llamaindex/chat-ui/**/*.{ts,tsx}'; @source "../node_modules/@llamaindex/chat-ui/**/*.{ts,tsx}";
@source '../node_modules/streamdown/dist/*.js'; @source "../node_modules/streamdown/dist/*.js";
@source '../node_modules/@streamdown/code/dist/*.js'; @source "../node_modules/@streamdown/code/dist/*.js";
@source '../node_modules/@streamdown/math/dist/*.js'; @source "../node_modules/@streamdown/math/dist/*.js";

View file

@ -108,6 +108,9 @@ export default function RootLayout({
// Locale state is managed by LocaleContext and persisted in localStorage // Locale state is managed by LocaleContext and persisted in localStorage
return ( return (
<html lang="en" suppressHydrationWarning> <html lang="en" suppressHydrationWarning>
<head>
<link rel="preconnect" href="https://api.github.com" />
</head>
<body className={cn(roboto.className, "bg-white dark:bg-black antialiased h-full w-full ")}> <body className={cn(roboto.className, "bg-white dark:bg-black antialiased h-full w-full ")}>
<PostHogProvider> <PostHogProvider>
<LocaleProvider> <LocaleProvider>

View file

@ -1,4 +1,4 @@
import { NextRequest, NextResponse } from "next/server"; import { type NextRequest, NextResponse } from "next/server";
const backendBaseUrl = (process.env.INTERNAL_FASTAPI_BACKEND_URL || "http://backend:8000").replace( const backendBaseUrl = (process.env.INTERNAL_FASTAPI_BACKEND_URL || "http://backend:8000").replace(
/\/+$/, /\/+$/,

View file

@ -1,26 +1,47 @@
"use client"; "use client";
import { atom } from "jotai"; import { atom } from "jotai";
import type { Document, SurfsenseDocsDocument } from "@/contracts/types/document.types"; import type { Document } from "@/contracts/types/document.types";
/** /**
* Atom to store the IDs of documents mentioned in the current chat composer. * Atom to store the full document objects mentioned via @-mention chips
* This is used to pass document context to the backend when sending a message. * in the current chat composer. This persists across component remounts.
*/
export const mentionedDocumentIdsAtom = atom<{
surfsense_doc_ids: number[];
document_ids: number[];
}>({
surfsense_doc_ids: [],
document_ids: [],
});
/**
* Atom to store the full document objects mentioned in the current chat composer.
* This persists across component remounts.
*/ */
export const mentionedDocumentsAtom = atom<Pick<Document, "id" | "title" | "document_type">[]>([]); export const mentionedDocumentsAtom = atom<Pick<Document, "id" | "title" | "document_type">[]>([]);
/**
* Atom to store documents selected via the sidebar checkboxes / row clicks.
* These are NOT inserted as chips the composer shows a count badge instead.
*/
export const sidebarSelectedDocumentsAtom = atom<
Pick<Document, "id" | "title" | "document_type">[]
>([]);
/**
* Derived read-only atom that merges @-mention chips and sidebar selections
* into a single deduplicated set of document IDs for the backend.
*/
export const mentionedDocumentIdsAtom = atom((get) => {
const chipDocs = get(mentionedDocumentsAtom);
const sidebarDocs = get(sidebarSelectedDocumentsAtom);
const allDocs = [...chipDocs, ...sidebarDocs];
const seen = new Set<string>();
const deduped = allDocs.filter((d) => {
const key = `${d.document_type}:${d.id}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
return {
surfsense_doc_ids: deduped
.filter((doc) => doc.document_type === "SURFSENSE_DOCS")
.map((doc) => doc.id),
document_ids: deduped
.filter((doc) => doc.document_type !== "SURFSENSE_DOCS")
.map((doc) => doc.id),
};
});
/** /**
* Simplified document info for display purposes * Simplified document info for display purposes
*/ */

View file

@ -49,7 +49,6 @@ export const uploadDocumentMutationAtom = atomWithMutation((get) => {
onSuccess: () => { onSuccess: () => {
// Note: Toast notification is handled by the caller (DocumentUploadTab) to use i18n // Note: Toast notification is handled by the caller (DocumentUploadTab) to use i18n
// Invalidate logs summary to show new processing tasks immediately on documents page
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: cacheKeys.logs.summary(searchSpaceId ?? undefined), queryKey: cacheKeys.logs.summary(searchSpaceId ?? undefined),
}); });

View file

@ -5,3 +5,5 @@ export const globalDocumentsQueryParamsAtom = atom<GetDocumentsRequest["queryPar
page_size: 10, page_size: 10,
page: 0, page: 0,
}); });
export const documentsSidebarOpenAtom = atom(false);

View file

@ -10,6 +10,7 @@ export const membersAtom = atomWithQuery((get) => {
queryKey: cacheKeys.members.all(searchSpaceId?.toString() ?? ""), queryKey: cacheKeys.members.all(searchSpaceId?.toString() ?? ""),
enabled: !!searchSpaceId, enabled: !!searchSpaceId,
staleTime: 3 * 1000, // 3 seconds - short staleness for live collaboration staleTime: 3 * 1000, // 3 seconds - short staleness for live collaboration
refetchInterval: 2 * 60 * 1000, // 2 minutes
queryFn: async () => { queryFn: async () => {
if (!searchSpaceId) { if (!searchSpaceId) {
return []; return [];

View file

@ -1,5 +1,5 @@
{ {
"$schema": "https://biomejs.dev/schemas/2.1.2/schema.json", "$schema": "https://biomejs.dev/schemas/2.4.6/schema.json",
"vcs": { "vcs": {
"enabled": true, "enabled": true,
"clientKind": "git", "clientKind": "git",
@ -7,7 +7,7 @@
}, },
"files": { "files": {
"ignoreUnknown": true, "ignoreUnknown": true,
"experimentalScannerIgnores": ["node_modules", ".git", ".next", "dist", "build", "coverage"], "includes": ["**", "!!node_modules", "!!.git", "!!.next", "!!dist", "!!build", "!!coverage"],
"maxSize": 1048576 "maxSize": 1048576
}, },
"formatter": { "formatter": {
@ -65,6 +65,9 @@
} }
}, },
"css": { "css": {
"parser": {
"tailwindDirectives": true
},
"formatter": { "formatter": {
"enabled": true, "enabled": true,
"indentStyle": "tab", "indentStyle": "tab",

View file

@ -1,14 +1,6 @@
"use client"; "use client";
import { import { Bell, ExternalLink, Info, type LucideIcon, Rocket, Wrench, Zap } from "lucide-react";
Bell,
ExternalLink,
Info,
type LucideIcon,
Rocket,
Wrench,
Zap,
} from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -114,4 +106,3 @@ export function AnnouncementCard({ announcement }: { announcement: AnnouncementW
</Card> </Card>
); );
} }

View file

@ -15,4 +15,3 @@ export function AnnouncementsEmptyState() {
</div> </div>
); );
} }

View file

@ -4,7 +4,7 @@ import { useAtomValue } from "jotai";
import { AlertTriangle, Cable, Settings } from "lucide-react"; import { AlertTriangle, Cable, Settings } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import type { FC } from "react"; import { type FC, useMemo } from "react";
import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms"; import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms";
import { import {
globalNewLLMConfigsAtom, globalNewLLMConfigsAtom,
@ -37,7 +37,7 @@ import { AllConnectorsTab } from "./connector-popup/tabs/all-connectors-tab";
import { ConnectorAccountsListView } from "./connector-popup/views/connector-accounts-list-view"; import { ConnectorAccountsListView } from "./connector-popup/views/connector-accounts-list-view";
import { YouTubeCrawlerView } from "./connector-popup/views/youtube-crawler-view"; import { YouTubeCrawlerView } from "./connector-popup/views/youtube-crawler-view";
export const ConnectorIndicator: FC<{ hideTrigger?: boolean }> = ({ hideTrigger = false }) => { export const ConnectorIndicator: FC = () => {
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom); const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { data: currentUser } = useAtomValue(currentUserAtom); const { data: currentUser } = useAtomValue(currentUserAtom);
@ -66,11 +66,15 @@ export const ConnectorIndicator: FC<{ hideTrigger?: boolean }> = ({ hideTrigger
const { data: documentTypeCounts, isFetching: documentTypesLoading } = const { data: documentTypeCounts, isFetching: documentTypesLoading } =
useAtomValue(documentTypeCountsAtom); useAtomValue(documentTypeCountsAtom);
// Fetch notifications to detect indexing failures // Fetch status notifications to detect indexing failures
const { inboxItems = [] } = useInbox( const { inboxItems: statusInboxItems = [] } = useInbox(
currentUser?.id ?? null, currentUser?.id ?? null,
searchSpaceId ? Number(searchSpaceId) : null, searchSpaceId ? Number(searchSpaceId) : null,
"connector_indexing" "status"
);
const inboxItems = useMemo(
() => statusInboxItems.filter((item) => item.type === "connector_indexing"),
[statusInboxItems]
); );
// Check if YouTube view is active // Check if YouTube view is active
@ -189,40 +193,36 @@ export const ConnectorIndicator: FC<{ hideTrigger?: boolean }> = ({ hideTrigger
return ( return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}> <Dialog open={isOpen} onOpenChange={handleOpenChange}>
{!hideTrigger && ( <TooltipIconButton
<TooltipIconButton data-joyride="connector-icon"
data-joyride="connector-icon" tooltip={hasConnectors ? `Manage ${activeConnectorsCount} connectors` : "Connect your data"}
tooltip={ side="bottom"
hasConnectors ? `Manage ${activeConnectorsCount} connectors` : "Connect your data" className={cn(
} "size-[34px] rounded-full p-1 flex items-center justify-center transition-colors relative",
side="bottom" "hover:bg-muted-foreground/15 dark:hover:bg-muted-foreground/30",
className={cn( "outline-none focus:outline-none focus-visible:outline-none font-semibold text-xs",
"size-[34px] rounded-full p-1 flex items-center justify-center transition-colors relative", "border-0 ring-0 focus:ring-0 shadow-none focus:shadow-none"
"hover:bg-muted-foreground/15 dark:hover:bg-muted-foreground/30", )}
"outline-none focus:outline-none focus-visible:outline-none font-semibold text-xs", aria-label={
"border-0 ring-0 focus:ring-0 shadow-none focus:shadow-none" hasConnectors ? `View ${activeConnectorsCount} connectors` : "Add your first connector"
)} }
aria-label={ onClick={() => handleOpenChange(true)}
hasConnectors ? `View ${activeConnectorsCount} connectors` : "Add your first connector" >
} {isLoading ? (
onClick={() => handleOpenChange(true)} <Spinner size="sm" />
> ) : (
{isLoading ? ( <>
<Spinner size="sm" /> <Cable className="size-4 stroke-[1.5px]" />
) : ( {activeConnectorsCount > 0 && (
<> <span className="absolute -top-0.5 right-0 flex items-center justify-center min-w-[16px] h-4 px-1 text-[10px] font-medium rounded-full bg-primary text-primary-foreground shadow-sm select-none">
<Cable className="size-4 stroke-[1.5px]" /> {activeConnectorsCount > 99 ? "99+" : activeConnectorsCount}
{activeConnectorsCount > 0 && ( </span>
<span className="absolute -top-0.5 right-0 flex items-center justify-center min-w-[16px] h-4 px-1 text-[10px] font-medium rounded-full bg-primary text-primary-foreground shadow-sm"> )}
{activeConnectorsCount > 99 ? "99+" : activeConnectorsCount} </>
</span> )}
)} </TooltipIconButton>
</>
)}
</TooltipIconButton>
)}
<DialogContent className="max-w-3xl w-[95vw] sm:w-full h-[75vh] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border bg-muted text-foreground focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 [&>button]:right-4 sm:[&>button]:right-12 [&>button]:top-6 sm:[&>button]:top-10 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button_svg]:size-5 select-none"> <DialogContent className="max-w-3xl w-[95vw] sm:w-full h-[75vh] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border ring-0 dark:ring-0 bg-muted dark:bg-muted text-foreground focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 [&>button]:right-4 sm:[&>button]:right-12 [&>button]:top-6 sm:[&>button]:top-10 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button_svg]:size-5 select-none">
<DialogTitle className="sr-only">Manage Connectors</DialogTitle> <DialogTitle className="sr-only">Manage Connectors</DialogTitle>
{/* YouTube Crawler View - shown when adding YouTube videos */} {/* YouTube Crawler View - shown when adding YouTube videos */}
{isYouTubeView && searchSpaceId ? ( {isYouTubeView && searchSpaceId ? (
@ -379,7 +379,7 @@ export const ConnectorIndicator: FC<{ hideTrigger?: boolean }> = ({ hideTrigger
: "You need to configure a Document Summary LLM before adding connectors. This LLM is used to process and summarize documents from your connected sources."} : "You need to configure a Document Summary LLM before adding connectors. This LLM is used to process and summarize documents from your connected sources."}
</p> </p>
<Button asChild size="sm" variant="outline"> <Button asChild size="sm" variant="outline">
<Link href={`/dashboard/${searchSpaceId}/settings?section=models`}> <Link href={`/dashboard/${searchSpaceId}/settings?tab=models`}>
<Settings className="mr-2 h-4 w-4" /> <Settings className="mr-2 h-4 w-4" />
Go to Settings Go to Settings
</Link> </Link>
@ -415,7 +415,6 @@ export const ConnectorIndicator: FC<{ hideTrigger?: boolean }> = ({ hideTrigger
activeDocumentTypes={activeDocumentTypes} activeDocumentTypes={activeDocumentTypes}
connectors={connectors as SearchSourceConnector[]} connectors={connectors as SearchSourceConnector[]}
indexingConnectorIds={indexingConnectorIds} indexingConnectorIds={indexingConnectorIds}
searchSpaceId={searchSpaceId}
onTabChange={handleTabChange} onTabChange={handleTabChange}
onManage={handleStartEdit} onManage={handleStartEdit}
onViewAccountsList={handleViewAccountsList} onViewAccountsList={handleViewAccountsList}

View file

@ -1,18 +1,13 @@
"use client"; "use client";
import { ArrowRight, Cable } from "lucide-react"; import { Cable } from "lucide-react";
import { useRouter } from "next/navigation";
import type { FC } from "react"; import type { FC } from "react";
import { useState } from "react";
import { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon"; import { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { TabsContent } from "@/components/ui/tabs"; import { TabsContent } from "@/components/ui/tabs";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { SearchSourceConnector } from "@/contracts/types/connector.types"; import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import type { LogActiveTask, LogSummary } from "@/contracts/types/log.types";
import { connectorsApiService } from "@/lib/apis/connectors-api.service";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { COMPOSIO_CONNECTORS, OAUTH_CONNECTORS } from "../constants/connector-constants"; import { COMPOSIO_CONNECTORS, OAUTH_CONNECTORS } from "../constants/connector-constants";
import { getDocumentCountForConnector } from "../utils/connector-document-mapping"; import { getDocumentCountForConnector } from "../utils/connector-document-mapping";
@ -25,37 +20,21 @@ interface ActiveConnectorsTabProps {
activeDocumentTypes: Array<[string, number]>; activeDocumentTypes: Array<[string, number]>;
connectors: SearchSourceConnector[]; connectors: SearchSourceConnector[];
indexingConnectorIds: Set<number>; indexingConnectorIds: Set<number>;
searchSpaceId: string;
onTabChange: (value: string) => void; onTabChange: (value: string) => void;
onManage?: (connector: SearchSourceConnector) => void; onManage?: (connector: SearchSourceConnector) => void;
onViewAccountsList?: (connectorType: string, connectorTitle: string) => void; onViewAccountsList?: (connectorType: string, connectorTitle: string) => void;
} }
/**
* Check if a connector type is indexable
*/
function isIndexableConnector(connectorType: string): boolean {
const nonIndexableTypes = ["MCP_CONNECTOR"];
return !nonIndexableTypes.includes(connectorType);
}
export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
searchQuery, searchQuery,
hasSources, hasSources,
activeDocumentTypes, activeDocumentTypes,
connectors, connectors,
indexingConnectorIds, indexingConnectorIds,
searchSpaceId, onTabChange: _onTabChange,
onTabChange,
onManage, onManage,
onViewAccountsList, onViewAccountsList,
}) => { }) => {
const router = useRouter();
const handleViewAllDocuments = () => {
router.push(`/dashboard/${searchSpaceId}/documents`);
};
// Convert activeDocumentTypes array to Record for utility function // Convert activeDocumentTypes array to Record for utility function
const documentTypeCounts = activeDocumentTypes.reduce( const documentTypeCounts = activeDocumentTypes.reduce(
(acc, [docType, count]) => { (acc, [docType, count]) => {
@ -300,15 +279,6 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-muted-foreground">Documents</h3> <h3 className="text-sm font-semibold text-muted-foreground">Documents</h3>
<Button
variant="ghost"
size="sm"
onClick={handleViewAllDocuments}
className="h-7 text-xs text-muted-foreground hover:text-foreground gap-1.5"
>
View all documents
<ArrowRight className="size-3" />
</Button>
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{standaloneDocuments.map((doc) => ( {standaloneDocuments.map((doc) => (

View file

@ -3,7 +3,6 @@
import { TagInput, type Tag as TagType } from "emblor"; import { TagInput, type Tag as TagType } from "emblor";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { type FC, useState } from "react"; import { type FC, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
@ -24,7 +23,6 @@ interface YouTubeCrawlerViewProps {
export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({ searchSpaceId, onBack }) => { export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({ searchSpaceId, onBack }) => {
const t = useTranslations("add_youtube"); const t = useTranslations("add_youtube");
const router = useRouter();
const [videoTags, setVideoTags] = useState<TagType[]>([]); const [videoTags, setVideoTags] = useState<TagType[]>([]);
const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null); const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -74,9 +72,7 @@ export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({ searchSpaceId,
toast(t("success_toast"), { toast(t("success_toast"), {
description: t("success_toast_desc"), description: t("success_toast_desc"),
}); });
// Close the popup and navigate to documents
onBack(); onBack();
router.push(`/dashboard/${searchSpaceId}/documents`);
}, },
onError: (error: unknown) => { onError: (error: unknown) => {
const errorMessage = error instanceof Error ? error.message : t("error_generic"); const errorMessage = error instanceof Error ? error.message : t("error_generic");

View file

@ -120,7 +120,12 @@ const DocumentUploadPopupContent: FC<{
return ( return (
<Dialog open={isOpen} onOpenChange={onOpenChange}> <Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="select-none max-w-4xl w-[95vw] sm:w-full h-[calc(100dvh-2rem)] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border bg-muted text-foreground [&>button]:right-3 sm:[&>button]:right-12 [&>button]:top-3 sm:[&>button]:top-10 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button]:z-[100] [&>button_svg]:size-4 sm:[&>button_svg]:size-5"> <DialogContent
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
className="select-none max-w-4xl w-[95vw] sm:w-full h-[calc(100dvh-2rem)] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border ring-0 bg-muted dark:bg-muted text-foreground [&>button]:right-3 sm:[&>button]:right-12 [&>button]:top-3 sm:[&>button]:top-10 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button]:z-[100] [&>button_svg]:size-4 sm:[&>button_svg]:size-5"
>
<DialogTitle className="sr-only">Upload Document</DialogTitle> <DialogTitle className="sr-only">Upload Document</DialogTitle>
{/* Scrollable container for mobile */} {/* Scrollable container for mobile */}
@ -153,7 +158,7 @@ const DocumentUploadPopupContent: FC<{
: "You need to configure a Document Summary LLM before uploading files. This LLM is used to process and summarize your uploaded documents."} : "You need to configure a Document Summary LLM before uploading files. This LLM is used to process and summarize your uploaded documents."}
</p> </p>
<Button asChild size="sm" variant="outline"> <Button asChild size="sm" variant="outline">
<Link href={`/dashboard/${searchSpaceId}/settings?section=models`}> <Link href={`/dashboard/${searchSpaceId}/settings?tab=models`}>
<Settings className="mr-2 h-4 w-4" /> <Settings className="mr-2 h-4 w-4" />
Go to Settings Go to Settings
</Link> </Link>

View file

@ -27,6 +27,7 @@ export interface InlineMentionEditorRef {
getText: () => string; getText: () => string;
getMentionedDocuments: () => MentionedDocument[]; getMentionedDocuments: () => MentionedDocument[];
insertDocumentChip: (doc: Pick<Document, "id" | "title" | "document_type">) => void; insertDocumentChip: (doc: Pick<Document, "id" | "title" | "document_type">) => void;
removeDocumentChip: (docId: number, docType?: string) => void;
setDocumentChipStatus: ( setDocumentChipStatus: (
docId: number, docId: number,
docType: string | undefined, docType: string | undefined,
@ -175,33 +176,27 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
chip.setAttribute(CHIP_DOCTYPE_ATTR, doc.document_type ?? "UNKNOWN"); chip.setAttribute(CHIP_DOCTYPE_ATTR, doc.document_type ?? "UNKNOWN");
chip.contentEditable = "false"; chip.contentEditable = "false";
chip.className = chip.className =
"inline-flex items-center gap-1 mx-0.5 pl-1 pr-0.5 py-0.5 rounded bg-primary/10 text-xs font-bold text-primary/60 select-none"; "inline-flex items-center gap-1 mx-0.5 px-1 py-0.5 rounded bg-primary/10 text-xs font-bold text-primary/60 select-none cursor-default";
chip.style.userSelect = "none"; chip.style.userSelect = "none";
chip.style.verticalAlign = "baseline"; chip.style.verticalAlign = "baseline";
// Add document type icon // Container that swaps between icon and remove button on hover
const iconContainer = document.createElement("span");
iconContainer.className = "shrink-0 flex items-center size-3 relative";
const iconSpan = document.createElement("span"); const iconSpan = document.createElement("span");
iconSpan.className = "shrink-0 flex items-center text-muted-foreground"; iconSpan.className = "flex items-center text-muted-foreground";
iconSpan.innerHTML = ReactDOMServer.renderToString( iconSpan.innerHTML = ReactDOMServer.renderToString(
getConnectorIcon(doc.document_type ?? "UNKNOWN", "h-3 w-3") getConnectorIcon(doc.document_type ?? "UNKNOWN", "h-3 w-3")
); );
const titleSpan = document.createElement("span");
titleSpan.className = "max-w-[120px] truncate";
titleSpan.textContent = doc.title;
titleSpan.title = doc.title;
titleSpan.setAttribute("data-mention-title", "true");
const statusSpan = document.createElement("span");
statusSpan.setAttribute(CHIP_STATUS_ATTR, "true");
statusSpan.className = "text-[10px] font-semibold opacity-80 hidden";
const removeBtn = document.createElement("button"); const removeBtn = document.createElement("button");
removeBtn.type = "button"; removeBtn.type = "button";
removeBtn.className = removeBtn.className =
"size-3 flex items-center justify-center rounded-full hover:bg-primary/20 transition-colors ml-0.5"; "size-3 items-center justify-center rounded-full text-muted-foreground transition-colors";
removeBtn.style.display = "none";
removeBtn.innerHTML = ReactDOMServer.renderToString( removeBtn.innerHTML = ReactDOMServer.renderToString(
createElement(X, { className: "h-2.5 w-2.5", strokeWidth: 2.5 }) createElement(X, { className: "h-3 w-3", strokeWidth: 2.5 })
); );
removeBtn.onclick = (e) => { removeBtn.onclick = (e) => {
e.preventDefault(); e.preventDefault();
@ -213,15 +208,45 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
next.delete(docKey); next.delete(docKey);
return next; return next;
}); });
// Notify parent that a document was removed
onDocumentRemove?.(doc.id, doc.document_type); onDocumentRemove?.(doc.id, doc.document_type);
focusAtEnd(); focusAtEnd();
}; };
chip.appendChild(iconSpan); const titleSpan = document.createElement("span");
chip.appendChild(titleSpan); titleSpan.className = "max-w-[120px] truncate";
chip.appendChild(statusSpan); titleSpan.textContent = doc.title;
chip.appendChild(removeBtn); titleSpan.title = doc.title;
titleSpan.setAttribute("data-mention-title", "true");
const statusSpan = document.createElement("span");
statusSpan.setAttribute(CHIP_STATUS_ATTR, "true");
statusSpan.className = "text-[10px] font-semibold opacity-80 hidden";
const isTouchDevice = window.matchMedia("(hover: none)").matches;
if (isTouchDevice) {
// Mobile: icon on left, title, X on right
chip.appendChild(iconSpan);
chip.appendChild(titleSpan);
chip.appendChild(statusSpan);
removeBtn.style.display = "flex";
removeBtn.className += " ml-0.5";
chip.appendChild(removeBtn);
} else {
// Desktop: icon/X swap on hover in the same slot
iconContainer.appendChild(iconSpan);
iconContainer.appendChild(removeBtn);
chip.addEventListener("mouseenter", () => {
iconSpan.style.display = "none";
removeBtn.style.display = "flex";
});
chip.addEventListener("mouseleave", () => {
iconSpan.style.display = "";
removeBtn.style.display = "none";
});
chip.appendChild(iconContainer);
chip.appendChild(titleSpan);
chip.appendChild(statusSpan);
}
return chip; return chip;
}, },
@ -388,6 +413,32 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
[] []
); );
const removeDocumentChip = useCallback(
(docId: number, docType?: string) => {
if (!editorRef.current) return;
const chipKey = `${docType ?? "UNKNOWN"}:${docId}`;
const chips = editorRef.current.querySelectorAll<HTMLSpanElement>(
`span[${CHIP_DATA_ATTR}="true"]`
);
for (const chip of chips) {
if (getChipId(chip) === docId && getChipDocType(chip) === (docType ?? "UNKNOWN")) {
chip.remove();
break;
}
}
setMentionedDocs((prev) => {
const next = new Map(prev);
next.delete(chipKey);
return next;
});
const text = getText();
const empty = text.length === 0 && mentionedDocs.size <= 1;
setIsEmpty(empty);
},
[getText, mentionedDocs.size]
);
// Expose methods via ref // Expose methods via ref
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
focus: () => editorRef.current?.focus(), focus: () => editorRef.current?.focus(),
@ -395,6 +446,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
getText, getText,
getMentionedDocuments, getMentionedDocuments,
insertDocumentChip, insertDocumentChip,
removeDocumentChip,
setDocumentChipStatus, setDocumentChipStatus,
})); }));

View file

@ -268,7 +268,7 @@ function ThreadListItemComponent({
</DropdownMenuItem> </DropdownMenuItem>
)} )}
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem onClick={onDelete} className="text-destructive focus:text-destructive"> <DropdownMenuItem onClick={onDelete}>
<TrashIcon className="mr-2 size-4" /> <TrashIcon className="mr-2 size-4" />
Delete Delete
</DropdownMenuItem> </DropdownMenuItem>

View file

@ -18,23 +18,21 @@ import {
ChevronLeftIcon, ChevronLeftIcon,
ChevronRightIcon, ChevronRightIcon,
CopyIcon, CopyIcon,
Dot,
DownloadIcon, DownloadIcon,
FileWarning, PlusIcon,
Paperclip,
RefreshCwIcon, RefreshCwIcon,
SquareIcon, SquareIcon,
} from "lucide-react"; } from "lucide-react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { type FC, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import { type FC, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { toast } from "sonner";
import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom"; import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom";
import { showCommentsGutterAtom } from "@/atoms/chat/current-thread.atom"; import { showCommentsGutterAtom } from "@/atoms/chat/current-thread.atom";
import { import {
mentionedDocumentIdsAtom,
mentionedDocumentsAtom, mentionedDocumentsAtom,
sidebarSelectedDocumentsAtom,
} from "@/atoms/chat/mentioned-documents.atom"; } from "@/atoms/chat/mentioned-documents.atom";
import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms";
import { membersAtom } from "@/atoms/members/members-query.atoms"; import { membersAtom } from "@/atoms/members/members-query.atoms";
import { import {
globalNewLLMConfigsAtom, globalNewLLMConfigsAtom,
@ -45,6 +43,7 @@ import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { AssistantMessage } from "@/components/assistant-ui/assistant-message"; import { AssistantMessage } from "@/components/assistant-ui/assistant-message";
import { ChatSessionStatus } from "@/components/assistant-ui/chat-session-status"; import { ChatSessionStatus } from "@/components/assistant-ui/chat-session-status";
import { ConnectorIndicator } from "@/components/assistant-ui/connector-popup"; import { ConnectorIndicator } from "@/components/assistant-ui/connector-popup";
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
import { import {
InlineMentionEditor, InlineMentionEditor,
type InlineMentionEditorRef, type InlineMentionEditorRef,
@ -63,11 +62,9 @@ import {
} from "@/components/new-chat/document-mention-picker"; } from "@/components/new-chat/document-mention-picker";
import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking"; import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import type { Document } from "@/contracts/types/document.types"; import type { Document } from "@/contracts/types/document.types";
import { useBatchCommentsPreload } from "@/hooks/use-comments"; import { useBatchCommentsPreload } from "@/hooks/use-comments";
import { useCommentsElectric } from "@/hooks/use-comments-electric"; import { useCommentsElectric } from "@/hooks/use-comments-electric";
import { documentsApiService } from "@/lib/apis/documents-api.service";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
/** Placeholder texts that cycle in new chats when input is empty */ /** Placeholder texts that cycle in new chats when input is empty */
@ -80,37 +77,19 @@ const CYCLING_PLACEHOLDERS = [
"Check if this week's Slack messages reference any GitHub issues.", "Check if this week's Slack messages reference any GitHub issues.",
]; ];
const CHAT_UPLOAD_ACCEPT =
".pdf,.doc,.docx,.txt,.md,.markdown,.ppt,.pptx,.xls,.xlsx,.xlsm,.xlsb,.csv,.html,.htm,.xml,.rtf,.epub,.jpg,.jpeg,.png,.bmp,.webp,.tiff,.tif,.mp3,.mp4,.mpeg,.mpga,.m4a,.wav,.webm";
const CHAT_MAX_FILES = 10;
const CHAT_MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024; // 50 MB per file
const CHAT_MAX_TOTAL_SIZE_BYTES = 200 * 1024 * 1024; // 200 MB total
type UploadState = "pending" | "processing" | "ready" | "failed";
interface UploadedMentionDoc {
id: number;
title: string;
document_type: Document["document_type"];
state: UploadState;
reason?: string | null;
}
interface ThreadProps { interface ThreadProps {
messageThinkingSteps?: Map<string, ThinkingStep[]>; messageThinkingSteps?: Map<string, ThinkingStep[]>;
header?: React.ReactNode;
} }
export const Thread: FC<ThreadProps> = ({ messageThinkingSteps = new Map(), header }) => { export const Thread: FC<ThreadProps> = ({ messageThinkingSteps = new Map() }) => {
return ( return (
<ThinkingStepsContext.Provider value={messageThinkingSteps}> <ThinkingStepsContext.Provider value={messageThinkingSteps}>
<ThreadContent header={header} /> <ThreadContent />
</ThinkingStepsContext.Provider> </ThinkingStepsContext.Provider>
); );
}; };
const ThreadContent: FC<{ header?: React.ReactNode }> = ({ header }) => { const ThreadContent: FC = () => {
const showGutter = useAtomValue(showCommentsGutterAtom); const showGutter = useAtomValue(showCommentsGutterAtom);
return ( return (
@ -122,14 +101,11 @@ const ThreadContent: FC<{ header?: React.ReactNode }> = ({ header }) => {
> >
<ThreadPrimitive.Viewport <ThreadPrimitive.Viewport
turnAnchor="top" turnAnchor="top"
autoScroll
className={cn( className={cn(
"aui-thread-viewport relative flex flex-1 min-h-0 flex-col overflow-y-auto px-4 pt-4 transition-[padding] duration-300 ease-out", "aui-thread-viewport relative flex flex-1 min-h-0 flex-col overflow-y-auto px-4 pt-4 transition-[padding] duration-300 ease-out",
showGutter && "lg:pr-30" showGutter && "lg:pr-30"
)} )}
> >
{header && <div className="sticky top-0 z-10 mb-4">{header}</div>}
<AssistantIf condition={({ thread }) => thread.isEmpty}> <AssistantIf condition={({ thread }) => thread.isEmpty}>
<ThreadWelcome /> <ThreadWelcome />
</AssistantIf> </AssistantIf>
@ -250,19 +226,13 @@ const ThreadWelcome: FC = () => {
const Composer: FC = () => { const Composer: FC = () => {
// Document mention state (atoms persist across component remounts) // Document mention state (atoms persist across component remounts)
const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom); const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom);
const setSidebarDocs = useSetAtom(sidebarSelectedDocumentsAtom);
const [showDocumentPopover, setShowDocumentPopover] = useState(false); const [showDocumentPopover, setShowDocumentPopover] = useState(false);
const [mentionQuery, setMentionQuery] = useState(""); const [mentionQuery, setMentionQuery] = useState("");
const [uploadedMentionDocs, setUploadedMentionDocs] = useState<
Record<number, UploadedMentionDoc>
>({});
const [isUploadingDocs, setIsUploadingDocs] = useState(false);
const editorRef = useRef<InlineMentionEditorRef>(null); const editorRef = useRef<InlineMentionEditorRef>(null);
const editorContainerRef = useRef<HTMLDivElement>(null); const editorContainerRef = useRef<HTMLDivElement>(null);
const uploadInputRef = useRef<HTMLInputElement>(null);
const isFileDialogOpenRef = useRef(false);
const documentPickerRef = useRef<DocumentMentionPickerRef>(null); const documentPickerRef = useRef<DocumentMentionPickerRef>(null);
const { search_space_id, chat_id } = useParams(); const { search_space_id, chat_id } = useParams();
const setMentionedDocumentIds = useSetAtom(mentionedDocumentIdsAtom);
const composerRuntime = useComposerRuntime(); const composerRuntime = useComposerRuntime();
const hasAutoFocusedRef = useRef(false); const hasAutoFocusedRef = useRef(false);
@ -317,7 +287,7 @@ const Composer: FC = () => {
const assistantIdsKey = useAssistantState(({ thread }) => const assistantIdsKey = useAssistantState(({ thread }) =>
thread.messages thread.messages
.filter((m) => m.role === "assistant" && m.id?.startsWith("msg-")) .filter((m) => m.role === "assistant" && m.id?.startsWith("msg-"))
.map((m) => m.id!.replace("msg-", "")) .map((m) => m.id?.replace("msg-", ""))
.join(",") .join(",")
); );
const assistantDbMessageIds = useMemo( const assistantDbMessageIds = useMemo(
@ -337,18 +307,6 @@ const Composer: FC = () => {
} }
}, [isThreadEmpty]); }, [isThreadEmpty]);
// Sync mentioned document IDs to atom for inclusion in chat request payload
useEffect(() => {
setMentionedDocumentIds({
surfsense_doc_ids: mentionedDocuments
.filter((doc) => doc.document_type === "SURFSENSE_DOCS")
.map((doc) => doc.id),
document_ids: mentionedDocuments
.filter((doc) => doc.document_type !== "SURFSENSE_DOCS")
.map((doc) => doc.id),
});
}, [mentionedDocuments, setMentionedDocumentIds]);
// Sync editor text with assistant-ui composer runtime // Sync editor text with assistant-ui composer runtime
const handleEditorChange = useCallback( const handleEditorChange = useCallback(
(text: string) => { (text: string) => {
@ -401,75 +359,35 @@ const Composer: FC = () => {
[showDocumentPopover] [showDocumentPopover]
); );
const uploadedMentionedDocs = useMemo(
() => mentionedDocuments.filter((doc) => uploadedMentionDocs[doc.id]),
[mentionedDocuments, uploadedMentionDocs]
);
const blockingUploadedMentions = useMemo(
() =>
uploadedMentionedDocs.filter((doc) => {
const state = uploadedMentionDocs[doc.id]?.state;
return state === "pending" || state === "processing" || state === "failed";
}),
[uploadedMentionedDocs, uploadedMentionDocs]
);
// Submit message (blocked during streaming, document picker open, or AI responding to another user) // Submit message (blocked during streaming, document picker open, or AI responding to another user)
const handleSubmit = useCallback(() => { const handleSubmit = useCallback(() => {
if ( if (isThreadRunning || isBlockedByOtherUser) {
isThreadRunning ||
isBlockedByOtherUser ||
isUploadingDocs ||
blockingUploadedMentions.length > 0
) {
return; return;
} }
if (!showDocumentPopover) { if (!showDocumentPopover) {
composerRuntime.send(); composerRuntime.send();
editorRef.current?.clear(); editorRef.current?.clear();
setMentionedDocuments([]); setMentionedDocuments([]);
setMentionedDocumentIds({ setSidebarDocs([]);
surfsense_doc_ids: [],
document_ids: [],
});
} }
}, [ }, [
showDocumentPopover, showDocumentPopover,
isThreadRunning, isThreadRunning,
isBlockedByOtherUser, isBlockedByOtherUser,
isUploadingDocs,
blockingUploadedMentions.length,
composerRuntime, composerRuntime,
setMentionedDocuments, setMentionedDocuments,
setMentionedDocumentIds, setSidebarDocs,
]); ]);
// Remove document from mentions and sync IDs to atom
const handleDocumentRemove = useCallback( const handleDocumentRemove = useCallback(
(docId: number, docType?: string) => { (docId: number, docType?: string) => {
setMentionedDocuments((prev) => { setMentionedDocuments((prev) =>
const updated = prev.filter((doc) => !(doc.id === docId && doc.document_type === docType)); prev.filter((doc) => !(doc.id === docId && doc.document_type === docType))
setMentionedDocumentIds({ );
surfsense_doc_ids: updated
.filter((doc) => doc.document_type === "SURFSENSE_DOCS")
.map((doc) => doc.id),
document_ids: updated
.filter((doc) => doc.document_type !== "SURFSENSE_DOCS")
.map((doc) => doc.id),
});
return updated;
});
setUploadedMentionDocs((prev) => {
if (!(docId in prev)) return prev;
const { [docId]: _removed, ...rest } = prev;
return rest;
});
}, },
[setMentionedDocuments, setMentionedDocumentIds] [setMentionedDocuments]
); );
// Add selected documents from picker, insert chips, and sync IDs to atom
const handleDocumentsMention = useCallback( const handleDocumentsMention = useCallback(
(documents: Pick<Document, "id" | "title" | "document_type">[]) => { (documents: Pick<Document, "id" | "title" | "document_type">[]) => {
const existingKeys = new Set(mentionedDocuments.map((d) => `${d.document_type}:${d.id}`)); const existingKeys = new Set(mentionedDocuments.map((d) => `${d.document_type}:${d.id}`));
@ -486,185 +404,14 @@ const Composer: FC = () => {
const uniqueNewDocs = documents.filter( const uniqueNewDocs = documents.filter(
(doc) => !existingKeySet.has(`${doc.document_type}:${doc.id}`) (doc) => !existingKeySet.has(`${doc.document_type}:${doc.id}`)
); );
const updated = [...prev, ...uniqueNewDocs]; return [...prev, ...uniqueNewDocs];
setMentionedDocumentIds({
surfsense_doc_ids: updated
.filter((doc) => doc.document_type === "SURFSENSE_DOCS")
.map((doc) => doc.id),
document_ids: updated
.filter((doc) => doc.document_type !== "SURFSENSE_DOCS")
.map((doc) => doc.id),
});
return updated;
}); });
setMentionQuery(""); setMentionQuery("");
}, },
[mentionedDocuments, setMentionedDocuments, setMentionedDocumentIds] [mentionedDocuments, setMentionedDocuments]
); );
const refreshUploadedDocStatuses = useCallback(
async (documentIds: number[]) => {
if (!search_space_id || documentIds.length === 0) return;
const statusResponse = await documentsApiService.getDocumentsStatus({
queryParams: {
search_space_id: Number(search_space_id),
document_ids: documentIds,
},
});
setUploadedMentionDocs((prev) => {
const next = { ...prev };
for (const item of statusResponse.items) {
next[item.id] = {
id: item.id,
title: item.title,
document_type: item.document_type,
state: item.status.state,
reason: item.status.reason,
};
}
return next;
});
handleDocumentsMention(
statusResponse.items.map((item) => ({
id: item.id,
title: item.title,
document_type: item.document_type,
}))
);
},
[search_space_id, handleDocumentsMention]
);
const handleUploadClick = useCallback(() => {
if (isFileDialogOpenRef.current) return;
isFileDialogOpenRef.current = true;
uploadInputRef.current?.click();
// Reset after a delay to handle cancellation (which doesn't fire the change event).
setTimeout(() => {
isFileDialogOpenRef.current = false;
}, 1000);
}, []);
const handleUploadInputChange = useCallback(
async (event: React.ChangeEvent<HTMLInputElement>) => {
isFileDialogOpenRef.current = false;
const files = Array.from(event.target.files ?? []);
event.target.value = "";
if (files.length === 0 || !search_space_id) return;
if (files.length > CHAT_MAX_FILES) {
toast.error(`Too many files. Maximum ${CHAT_MAX_FILES} files per upload.`);
return;
}
let totalSize = 0;
for (const file of files) {
if (file.size > CHAT_MAX_FILE_SIZE_BYTES) {
toast.error(
`File "${file.name}" (${(file.size / (1024 * 1024)).toFixed(1)} MB) exceeds the ${CHAT_MAX_FILE_SIZE_BYTES / (1024 * 1024)} MB per-file limit.`
);
return;
}
totalSize += file.size;
}
if (totalSize > CHAT_MAX_TOTAL_SIZE_BYTES) {
toast.error(
`Total upload size (${(totalSize / (1024 * 1024)).toFixed(1)} MB) exceeds the ${CHAT_MAX_TOTAL_SIZE_BYTES / (1024 * 1024)} MB limit.`
);
return;
}
setIsUploadingDocs(true);
try {
const uploadResponse = await documentsApiService.uploadDocument({
files,
search_space_id: Number(search_space_id),
});
const uploadedIds = uploadResponse.document_ids ?? [];
const duplicateIds = uploadResponse.duplicate_document_ids ?? [];
const idsToMention = Array.from(new Set([...uploadedIds, ...duplicateIds]));
if (idsToMention.length === 0) {
toast.warning("No documents were created or matched from selected files.");
return;
}
await refreshUploadedDocStatuses(idsToMention);
if (uploadedIds.length > 0 && duplicateIds.length > 0) {
toast.success(
`Uploaded ${uploadedIds.length} file${uploadedIds.length > 1 ? "s" : ""} and matched ${duplicateIds.length} existing file${duplicateIds.length > 1 ? "s" : ""}.`
);
} else if (uploadedIds.length > 0) {
toast.success(`Uploaded ${uploadedIds.length} file${uploadedIds.length > 1 ? "s" : ""}`);
} else {
toast.success(
`Matched ${duplicateIds.length} existing file${duplicateIds.length > 1 ? "s" : ""} and added mention${duplicateIds.length > 1 ? "s" : ""}.`
);
}
} catch (error) {
const message = error instanceof Error ? error.message : "Upload failed";
toast.error(`Upload failed: ${message}`);
} finally {
setIsUploadingDocs(false);
}
},
[search_space_id, refreshUploadedDocStatuses]
);
// Poll status for uploaded mentioned documents until all are ready or removed.
useEffect(() => {
const trackedIds = uploadedMentionedDocs.map((doc) => doc.id);
const needsPolling = trackedIds.some((id) => {
const state = uploadedMentionDocs[id]?.state;
return state === "pending" || state === "processing";
});
if (!needsPolling) return;
const interval = setInterval(() => {
refreshUploadedDocStatuses(trackedIds).catch((error) => {
console.error("[Composer] Failed to refresh uploaded mention statuses:", error);
});
}, 2500);
return () => clearInterval(interval);
}, [uploadedMentionedDocs, uploadedMentionDocs, refreshUploadedDocStatuses]);
// Push upload status directly onto mention chips (instead of separate status rows).
useEffect(() => {
for (const doc of uploadedMentionedDocs) {
const state = uploadedMentionDocs[doc.id]?.state ?? "pending";
const statusLabel =
state === "ready"
? null
: state === "failed"
? "failed"
: state === "processing"
? "indexing"
: "queued";
editorRef.current?.setDocumentChipStatus(doc.id, doc.document_type, statusLabel, state);
}
}, [uploadedMentionedDocs, uploadedMentionDocs]);
// Prune upload status entries that are no longer mentioned in the composer.
useEffect(() => {
const activeIds = new Set(mentionedDocuments.map((doc) => doc.id));
setUploadedMentionDocs((prev) => {
let changed = false;
const next: Record<number, UploadedMentionDoc> = {};
for (const [key, value] of Object.entries(prev)) {
const id = Number(key);
if (activeIds.has(id)) {
next[id] = value;
} else {
changed = true;
}
}
return changed ? next : prev;
});
}, [mentionedDocuments]);
return ( return (
<ComposerPrimitive.Root className="aui-composer-root relative flex w-full flex-col gap-2"> <ComposerPrimitive.Root className="aui-composer-root relative flex w-full flex-col gap-2">
<ChatSessionStatus <ChatSessionStatus
@ -688,15 +435,6 @@ const Composer: FC = () => {
className="min-h-[24px]" className="min-h-[24px]"
/> />
</div> </div>
<input
ref={uploadInputRef}
type="file"
multiple
accept={CHAT_UPLOAD_ACCEPT}
onChange={handleUploadInputChange}
className="hidden"
/>
{/* Document picker popover (portal to body for proper z-index stacking) */} {/* Document picker popover (portal to body for proper z-index stacking) */}
{showDocumentPopover && {showDocumentPopover &&
typeof document !== "undefined" && typeof document !== "undefined" &&
@ -722,15 +460,7 @@ const Composer: FC = () => {
/>, />,
document.body document.body
)} )}
<ComposerAction <ComposerAction isBlockedByOtherUser={isBlockedByOtherUser} />
isBlockedByOtherUser={isBlockedByOtherUser}
onUploadClick={handleUploadClick}
isUploadingDocs={isUploadingDocs}
blockingUploadedMentionsCount={blockingUploadedMentions.length}
hasFailedUploadedMentions={blockingUploadedMentions.some(
(doc) => uploadedMentionDocs[doc.id]?.state === "failed"
)}
/>
</div> </div>
</ComposerPrimitive.Root> </ComposerPrimitive.Root>
); );
@ -738,29 +468,20 @@ const Composer: FC = () => {
interface ComposerActionProps { interface ComposerActionProps {
isBlockedByOtherUser?: boolean; isBlockedByOtherUser?: boolean;
onUploadClick: () => void;
isUploadingDocs: boolean;
blockingUploadedMentionsCount: number;
hasFailedUploadedMentions: boolean;
} }
const ComposerAction: FC<ComposerActionProps> = ({ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false }) => {
isBlockedByOtherUser = false,
onUploadClick,
isUploadingDocs,
blockingUploadedMentionsCount,
hasFailedUploadedMentions,
}) => {
const mentionedDocuments = useAtomValue(mentionedDocumentsAtom); const mentionedDocuments = useAtomValue(mentionedDocumentsAtom);
const sidebarDocs = useAtomValue(sidebarSelectedDocumentsAtom);
const setDocumentsSidebarOpen = useSetAtom(documentsSidebarOpenAtom);
const { openDialog: openUploadDialog } = useDocumentUploadDialog();
// Check if composer text is empty (chips are represented in mentionedDocuments atom)
const isComposerTextEmpty = useAssistantState(({ composer }) => { const isComposerTextEmpty = useAssistantState(({ composer }) => {
const text = composer.text?.trim() || ""; const text = composer.text?.trim() || "";
return text.length === 0; return text.length === 0;
}); });
const isComposerEmpty = isComposerTextEmpty && mentionedDocuments.length === 0; const isComposerEmpty = isComposerTextEmpty && mentionedDocuments.length === 0;
// Check if a model is configured
const { data: userConfigs } = useAtomValue(newLLMConfigsAtom); const { data: userConfigs } = useAtomValue(newLLMConfigsAtom);
const { data: globalConfigs } = useAtomValue(globalNewLLMConfigsAtom); const { data: globalConfigs } = useAtomValue(globalNewLLMConfigsAtom);
const { data: preferences } = useAtomValue(llmPreferencesAtom); const { data: preferences } = useAtomValue(llmPreferencesAtom);
@ -770,121 +491,91 @@ const ComposerAction: FC<ComposerActionProps> = ({
const agentLlmId = preferences.agent_llm_id; const agentLlmId = preferences.agent_llm_id;
if (agentLlmId === null || agentLlmId === undefined) return false; if (agentLlmId === null || agentLlmId === undefined) return false;
// Check if the configured model actually exists
// Auto mode (ID 0) and global configs (negative IDs) are in globalConfigs
if (agentLlmId <= 0) { if (agentLlmId <= 0) {
return globalConfigs?.some((c) => c.id === agentLlmId) ?? false; return globalConfigs?.some((c) => c.id === agentLlmId) ?? false;
} }
return userConfigs?.some((c) => c.id === agentLlmId) ?? false; return userConfigs?.some((c) => c.id === agentLlmId) ?? false;
}, [preferences, globalConfigs, userConfigs]); }, [preferences, globalConfigs, userConfigs]);
const isSendDisabled = const isSendDisabled = isComposerEmpty || !hasModelConfigured || isBlockedByOtherUser;
isComposerEmpty ||
!hasModelConfigured ||
isBlockedByOtherUser ||
isUploadingDocs ||
blockingUploadedMentionsCount > 0;
return ( return (
<div className="aui-composer-action-wrapper relative mx-2 mb-2 flex items-center justify-between"> <div className="aui-composer-action-wrapper relative mx-2 mb-2 flex items-center justify-between">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<TooltipIconButton <TooltipIconButton
tooltip={ tooltip="Upload"
isUploadingDocs ? (
"Uploading documents..."
) : (
<div className="flex flex-col gap-0.5">
<span className="font-medium">Upload and mention files</span>
<span className="text-xs text-muted-foreground flex items-center">
Max 10 files <Dot className="size-3" /> 50 MB each
</span>
<span className="text-xs text-muted-foreground">Total upload limit: 200 MB</span>
</div>
)
}
side="bottom" side="bottom"
variant="ghost" variant="ghost"
size="icon" size="icon"
className="size-[34px] rounded-full p-1 font-semibold text-xs hover:bg-muted-foreground/15 dark:border-muted-foreground/15 dark:hover:bg-muted-foreground/30" className="size-[34px] rounded-full p-1 font-semibold text-xs hover:bg-muted-foreground/15 dark:border-muted-foreground/15 dark:hover:bg-muted-foreground/30"
aria-label="Upload files" aria-label="Upload documents"
onClick={onUploadClick} onClick={openUploadDialog}
disabled={isUploadingDocs}
> >
{isUploadingDocs ? ( <PlusIcon className="size-4" />
<Spinner size="sm" className="text-muted-foreground" />
) : (
<Paperclip className="size-4" />
)}
</TooltipIconButton> </TooltipIconButton>
<ConnectorIndicator /> <ConnectorIndicator />
</div> </div>
{blockingUploadedMentionsCount > 0 && ( {!hasModelConfigured && (
<div className="flex items-center gap-1.5 text-muted-foreground text-xs">
{hasFailedUploadedMentions ? <FileWarning className="size-3" /> : <Spinner size="xs" />}
<span>
{hasFailedUploadedMentions
? "Remove or retry failed uploads"
: "Waiting for uploaded files to finish indexing"}
</span>
</div>
)}
{/* Show warning when no model is configured */}
{!hasModelConfigured && blockingUploadedMentionsCount === 0 && (
<div className="flex items-center gap-1.5 text-amber-600 dark:text-amber-400 text-xs"> <div className="flex items-center gap-1.5 text-amber-600 dark:text-amber-400 text-xs">
<AlertCircle className="size-3" /> <AlertCircle className="size-3" />
<span>Select a model</span> <span>Select a model</span>
</div> </div>
)} )}
<AssistantIf condition={({ thread }) => !thread.isRunning}> <div className="flex items-center gap-2">
<ComposerPrimitive.Send asChild disabled={isSendDisabled}> {sidebarDocs.length > 0 && (
<TooltipIconButton <button
tooltip={
isBlockedByOtherUser
? "Wait for AI to finish responding"
: hasFailedUploadedMentions
? "Remove or retry failed uploads before sending"
: blockingUploadedMentionsCount > 0
? "Waiting for uploaded files to finish indexing"
: isUploadingDocs
? "Uploading documents..."
: !hasModelConfigured
? "Please select a model from the header to start chatting"
: isComposerEmpty
? "Enter a message to send"
: "Send message"
}
side="bottom"
type="submit"
variant="default"
size="icon"
className={cn(
"aui-composer-send size-8 rounded-full",
isSendDisabled && "cursor-not-allowed opacity-50"
)}
aria-label="Send message"
disabled={isSendDisabled}
>
<ArrowUpIcon className="aui-composer-send-icon size-4" />
</TooltipIconButton>
</ComposerPrimitive.Send>
</AssistantIf>
<AssistantIf condition={({ thread }) => thread.isRunning}>
<ComposerPrimitive.Cancel asChild>
<Button
type="button" type="button"
variant="default" onClick={() => setDocumentsSidebarOpen(true)}
size="icon" className="rounded-full border border-border/60 bg-accent/50 px-2.5 py-1 text-xs font-medium text-foreground/80 transition-colors hover:bg-accent"
className="aui-composer-cancel size-8 rounded-full"
aria-label="Stop generating"
> >
<SquareIcon className="aui-composer-cancel-icon size-3 fill-current" /> {sidebarDocs.length} {sidebarDocs.length === 1 ? "source" : "sources"} selected
</Button> </button>
</ComposerPrimitive.Cancel> )}
</AssistantIf>
<AssistantIf condition={({ thread }) => !thread.isRunning}>
<ComposerPrimitive.Send asChild disabled={isSendDisabled}>
<TooltipIconButton
tooltip={
isBlockedByOtherUser
? "Wait for AI to finish responding"
: !hasModelConfigured
? "Please select a model from the header to start chatting"
: isComposerEmpty
? "Enter a message to send"
: "Send message"
}
side="bottom"
type="submit"
variant="default"
size="icon"
className={cn(
"aui-composer-send size-8 rounded-full",
isSendDisabled && "cursor-not-allowed opacity-50"
)}
aria-label="Send message"
disabled={isSendDisabled}
>
<ArrowUpIcon className="aui-composer-send-icon size-4" />
</TooltipIconButton>
</ComposerPrimitive.Send>
</AssistantIf>
<AssistantIf condition={({ thread }) => thread.isRunning}>
<ComposerPrimitive.Cancel asChild>
<Button
type="button"
variant="default"
size="icon"
className="aui-composer-cancel size-8 rounded-full"
aria-label="Stop generating"
>
<SquareIcon className="aui-composer-cancel-icon size-3 fill-current" />
</Button>
</ComposerPrimitive.Cancel>
</AssistantIf>
</div>
</div> </div>
); );
}; };

View file

@ -1,6 +1,6 @@
import { ActionBarPrimitive, MessagePrimitive, useAssistantState } from "@assistant-ui/react"; import { ActionBarPrimitive, MessagePrimitive, useAssistantState } from "@assistant-ui/react";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { FileText, PencilIcon } from "lucide-react"; import { FileText, Pen } from "lucide-react";
import { type FC, useState } from "react"; import { type FC, useState } from "react";
import { messageDocumentsMapAtom } from "@/atoms/chat/mentioned-documents.atom"; import { messageDocumentsMapAtom } from "@/atoms/chat/mentioned-documents.atom";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
@ -125,7 +125,7 @@ const UserActionBar: FC = () => {
{canEdit && ( {canEdit && (
<ActionBarPrimitive.Edit asChild> <ActionBarPrimitive.Edit asChild>
<TooltipIconButton tooltip="Edit" className="aui-user-action-edit p-4"> <TooltipIconButton tooltip="Edit" className="aui-user-action-edit p-4">
<PencilIcon /> <Pen />
</TooltipIconButton> </TooltipIconButton>
</ActionBarPrimitive.Edit> </ActionBarPrimitive.Edit>
)} )}

View file

@ -36,7 +36,7 @@ export function CommentActions({ canEdit, canDelete, onEdit, onDelete }: Comment
)} )}
{canEdit && canDelete && <DropdownMenuSeparator />} {canEdit && canDelete && <DropdownMenuSeparator />}
{canDelete && ( {canDelete && (
<DropdownMenuItem onClick={onDelete} className="text-destructive focus:text-destructive"> <DropdownMenuItem onClick={onDelete}>
<Trash2 className="mr-2 size-4" /> <Trash2 className="mr-2 size-4" />
Delete Delete
</DropdownMenuItem> </DropdownMenuItem>

View file

@ -1,216 +0,0 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { usePathname } from "next/navigation";
import { useTranslations } from "next-intl";
import React, { useEffect, useState } from "react";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { authenticatedFetch, getBearerToken } from "@/lib/auth-utils";
import { getThreadFull } from "@/lib/chat/thread-persistence";
import { cacheKeys } from "@/lib/query-client/cache-keys";
interface BreadcrumbItemInterface {
label: string;
href?: string;
}
export function DashboardBreadcrumb() {
const t = useTranslations("breadcrumb");
const pathname = usePathname();
// Extract search space ID and chat ID from pathname
const segments = pathname.split("/").filter(Boolean);
const searchSpaceId = segments[0] === "dashboard" && segments[1] ? segments[1] : null;
const { data: searchSpace } = useQuery({
queryKey: cacheKeys.searchSpaces.detail(searchSpaceId || ""),
queryFn: () => searchSpacesApiService.getSearchSpace({ id: Number(searchSpaceId) }),
enabled: !!searchSpaceId,
});
// Extract chat thread ID from pathname for chat pages
const chatThreadId = segments[2] === "new-chat" && segments[3] ? segments[3] : null;
// Fetch thread details when on a chat page with a thread ID
const { data: threadData } = useQuery({
queryKey: ["threads", searchSpaceId, "detail", chatThreadId],
queryFn: () => getThreadFull(Number(chatThreadId)),
enabled: !!chatThreadId && !!searchSpaceId,
});
// State to store document title for editor breadcrumb
const [documentTitle, setDocumentTitle] = useState<string | null>(null);
// Fetch document title when on editor page
useEffect(() => {
if (segments[2] === "editor" && segments[3] && searchSpaceId) {
const documentId = segments[3];
// Skip fetch for "new" notes
if (documentId === "new") {
setDocumentTitle(null);
return;
}
const token = getBearerToken();
if (token) {
authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/editor-content`,
{ method: "GET" }
)
.then((res) => res.json())
.then((data) => {
if (data.title) {
setDocumentTitle(data.title);
}
})
.catch(() => {
// If fetch fails, just use the document ID
setDocumentTitle(null);
});
}
} else {
setDocumentTitle(null);
}
}, [segments, searchSpaceId]);
// Parse the pathname to create breadcrumb items
const generateBreadcrumbs = (path: string): BreadcrumbItemInterface[] => {
const segments = path.split("/").filter(Boolean);
const breadcrumbs: BreadcrumbItemInterface[] = [];
// Handle search space (start directly with search space, skip "Dashboard")
if (segments[0] === "dashboard" && segments[1]) {
// Use the actual search space name if available, otherwise fall back to the ID
const searchSpaceLabel = searchSpace?.name || `${t("search_space")} ${segments[1]}`;
breadcrumbs.push({
label: searchSpaceLabel,
href: `/dashboard/${segments[1]}`,
});
// Handle specific sections
if (segments[2]) {
const section = segments[2];
let sectionLabel = section.charAt(0).toUpperCase() + section.slice(1);
// Map section names to more readable labels
const sectionLabels: Record<string, string> = {
"new-chat": t("chat") || "Chat",
documents: t("documents"),
logs: t("logs"),
settings: t("settings"),
editor: t("editor"),
};
sectionLabel = sectionLabels[section] || sectionLabel;
// Handle sub-sections
if (segments[3]) {
const subSection = segments[3];
// Handle editor sub-sections (document ID)
if (section === "editor") {
// Handle special cases for editor
let documentLabel: string;
if (subSection === "new") {
documentLabel = "New Note";
} else {
documentLabel = documentTitle || subSection;
}
breadcrumbs.push({
label: t("documents"),
href: `/dashboard/${segments[1]}/documents`,
});
breadcrumbs.push({
label: sectionLabel,
href: `/dashboard/${segments[1]}/documents`,
});
breadcrumbs.push({ label: documentLabel });
return breadcrumbs;
}
// Handle documents sub-sections
if (section === "documents") {
const documentLabels: Record<string, string> = {
upload: t("upload_documents"),
webpage: t("add_webpages"),
};
const documentLabel = documentLabels[subSection] || subSection;
breadcrumbs.push({
label: t("documents"),
href: `/dashboard/${segments[1]}/documents`,
});
breadcrumbs.push({ label: documentLabel });
return breadcrumbs;
}
// Handle new-chat sub-sections (thread IDs)
// Show the chat title if available, otherwise fall back to "Chat"
if (section === "new-chat") {
const chatLabel = threadData?.title || t("chat") || "Chat";
breadcrumbs.push({
label: chatLabel,
});
return breadcrumbs;
}
// Handle other sub-sections
let subSectionLabel = subSection.charAt(0).toUpperCase() + subSection.slice(1);
const subSectionLabels: Record<string, string> = {
upload: t("upload_documents"),
youtube: t("add_youtube"),
webpage: t("add_webpages"),
manage: t("manage"),
};
subSectionLabel = subSectionLabels[subSection] || subSectionLabel;
breadcrumbs.push({
label: sectionLabel,
href: `/dashboard/${segments[1]}/${section}`,
});
breadcrumbs.push({ label: subSectionLabel });
} else {
breadcrumbs.push({ label: sectionLabel });
}
}
}
return breadcrumbs;
};
const breadcrumbs = generateBreadcrumbs(pathname);
if (breadcrumbs.length === 0) {
return null; // Don't show breadcrumbs for root dashboard
}
return (
<Breadcrumb className="select-none">
<BreadcrumbList>
{breadcrumbs.map((item, index) => (
<React.Fragment key={`${index}-${item.href || item.label}`}>
<BreadcrumbItem>
{index === breadcrumbs.length - 1 ? (
<BreadcrumbPage>{item.label}</BreadcrumbPage>
) : (
<BreadcrumbLink href={item.href}>{item.label}</BreadcrumbLink>
)}
</BreadcrumbItem>
{index < breadcrumbs.length - 1 && <BreadcrumbSeparator />}
</React.Fragment>
))}
</BreadcrumbList>
</Breadcrumb>
);
}

View file

@ -0,0 +1,363 @@
"use client";
import { IconBrandGithub } from "@tabler/icons-react";
import type { HTMLMotionProps, UseInViewOptions } from "motion/react";
import { motion, useInView, useMotionValue, useSpring } from "motion/react";
import * as React from "react";
import { cn } from "@/lib/utils";
// ---------------------------------------------------------------------------
// Utilities
// ---------------------------------------------------------------------------
function getStrictContext<T>(name?: string) {
const Context = React.createContext<T | undefined>(undefined);
const Provider = ({ value, children }: { value: T; children?: React.ReactNode }) => (
<Context.Provider value={value}>{children}</Context.Provider>
);
const useSafeContext = () => {
const ctx = React.useContext(Context);
if (ctx === undefined) {
throw new Error(`useContext must be used within ${name ?? "a Provider"}`);
}
return ctx;
};
return [Provider, useSafeContext] as const;
}
interface UseIsInViewOptions {
inView?: boolean;
inViewOnce?: boolean;
inViewMargin?: UseInViewOptions["margin"];
}
function useIsInView<T extends HTMLElement = HTMLElement>(
ref: React.Ref<T>,
options: UseIsInViewOptions = {}
) {
const { inView, inViewOnce = false, inViewMargin = "0px" } = options;
const localRef = React.useRef<T>(null);
React.useImperativeHandle(ref, () => localRef.current as T);
const inViewResult = useInView(localRef, {
once: inViewOnce,
margin: inViewMargin,
});
const isInView = !inView || inViewResult;
return { ref: localRef, isInView };
}
// ---------------------------------------------------------------------------
// Per-digit scrolling wheel
// ---------------------------------------------------------------------------
const ROLLING_ITEM_COUNT = 200;
function DigitWheel({
digit,
itemSize = 22,
delay = 0,
cycles = 5,
isRolling = false,
reverse = false,
className,
onSettled,
}: {
digit: number;
itemSize?: number;
delay?: number;
cycles?: number;
isRolling?: boolean;
reverse?: boolean;
className?: string;
onSettled?: () => void;
}) {
const sequence = React.useMemo(() => {
if (isRolling) {
return Array.from({ length: ROLLING_ITEM_COUNT }, (_, i) => ({
id: `r${i}`,
value: i % 10,
}));
}
const seq = Array.from({ length: cycles * 10 }, (_, i) => ({
id: `s${i}`,
value: Math.floor(Math.random() * 10),
}));
const target = { id: "target", value: digit };
if (reverse) {
seq.unshift(target);
} else {
seq.push(target);
}
return seq;
}, [digit, cycles, isRolling, reverse]);
const maxOffset = (sequence.length - 1) * itemSize;
const endY = reverse ? 0 : -maxOffset;
const rollingStartItem = React.useRef(Math.floor(Math.random() * 10));
const startOffset = rollingStartItem.current * itemSize;
const y = useMotionValue(
isRolling ? (reverse ? -(maxOffset - startOffset) : -startOffset) : reverse ? -maxOffset : 0
);
const ySpring = useSpring(
y,
isRolling ? { stiffness: 10000, damping: 500 } : { stiffness: 70, damping: 20 }
);
const settledRef = React.useRef(false);
const wasRollingRef = React.useRef(isRolling);
// Jump y to settling start position when transitioning from rolling → settled
React.useLayoutEffect(() => {
if (wasRollingRef.current && !isRolling) {
y.jump(reverse ? -maxOffset : 0);
}
wasRollingRef.current = isRolling;
}, [isRolling, reverse, maxOffset, y]);
// Rolling: drive y continuously via RAF (stiff spring tracks it transparently)
React.useEffect(() => {
if (!isRolling) return;
const cycleHeight = 10 * itemSize;
const msPerCycle = 1000;
let startTime: number | null = null;
let rafId: number;
const tick = (time: number) => {
if (startTime === null) startTime = time;
const elapsed = time - startTime;
const speed = cycleHeight / msPerCycle;
const travel = elapsed * speed + startOffset;
if (reverse) {
y.set(Math.min(-maxOffset + travel, 0));
} else {
y.set(Math.max(-travel, -maxOffset));
}
rafId = requestAnimationFrame(tick);
};
rafId = requestAnimationFrame(tick);
return () => cancelAnimationFrame(rafId);
}, [isRolling, itemSize, reverse, y, maxOffset, startOffset]);
// Settling: spring to endY after delay
React.useEffect(() => {
if (isRolling) return;
settledRef.current = false;
const timer = setTimeout(() => y.set(endY), delay);
return () => clearTimeout(timer);
}, [endY, y, delay, isRolling]);
// Detect settled
React.useEffect(() => {
if (isRolling) return;
const unsub = ySpring.on("change", (latest) => {
if (!settledRef.current && Math.abs(latest - endY) < 0.5) {
settledRef.current = true;
onSettled?.();
}
});
return unsub;
}, [ySpring, endY, onSettled, isRolling]);
return (
<div style={{ height: itemSize, overflow: "hidden" }}>
<motion.div style={{ y: ySpring }}>
{sequence.map((item) => (
<div
key={item.id}
className={className}
style={{
height: itemSize,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{item.value}
</div>
))}
</motion.div>
</div>
);
}
// ---------------------------------------------------------------------------
// Animated star count with per-digit alternating wheels
// ---------------------------------------------------------------------------
const numberFormatter = new Intl.NumberFormat("en-US");
function AnimatedStarCount({
value,
itemSize = 22,
isRolling = false,
animated = true,
className,
onComplete,
}: {
value: number;
itemSize?: number;
isRolling?: boolean;
animated?: boolean;
className?: string;
onComplete?: () => void;
}) {
const formatted = numberFormatter.format(value);
const chars = formatted.split("");
if (!animated) {
return (
<div className="flex items-center">
{chars.map((char, idx) => (
<div
key={`static-${idx}-${char}`}
className={className}
style={{
height: itemSize,
display: "flex",
alignItems: "center",
justifyContent: "center",
width: char >= "0" && char <= "9" ? undefined : "0.3em",
}}
>
{char}
</div>
))}
</div>
);
}
let totalDigits = 0;
for (const c of chars) {
if (c >= "0" && c <= "9") totalDigits++;
}
const settledCount = React.useRef(0);
const completedRef = React.useRef(false);
const handleDigitSettled = React.useCallback(() => {
settledCount.current++;
if (!completedRef.current && settledCount.current >= totalDigits) {
completedRef.current = true;
onComplete?.();
}
}, [totalDigits, onComplete]);
let digitIndex = 0;
let separatorIndex = 0;
return (
<div className="flex items-center">
{chars.map((char) => {
if (char < "0" || char > "9") {
const sepKey = `sep-${separatorIndex++}`;
return (
<div
key={sepKey}
className={className}
style={{
height: itemSize,
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "0.3em",
}}
>
{char}
</div>
);
}
const digit = parseInt(char, 10);
const idx = digitIndex++;
return (
<DigitWheel
key={`digit-${idx}`}
digit={digit}
itemSize={itemSize}
delay={idx * 150}
cycles={5}
isRolling={isRolling}
reverse={idx % 2 === 1}
className={className}
onSettled={handleDigitSettled}
/>
);
})}
</div>
);
}
// ---------------------------------------------------------------------------
// NavbarGitHubStars — the exported component
// ---------------------------------------------------------------------------
const ITEM_SIZE = 22;
type NavbarGitHubStarsProps = {
username?: string;
repo?: string;
href?: string;
className?: string;
};
function NavbarGitHubStars({
username = "MODSetter",
repo = "SurfSense",
href = "https://github.com/MODSetter/SurfSense",
className,
}: NavbarGitHubStarsProps) {
const [hasMounted, setHasMounted] = React.useState(false);
const [stars, setStars] = React.useState(0);
const [isLoading, setIsLoading] = React.useState(true);
React.useEffect(() => {
setHasMounted(true);
}, []);
React.useEffect(() => {
const abortController = new AbortController();
fetch(`https://api.github.com/repos/${username}/${repo}`, {
signal: abortController.signal,
})
.then((res) => res.json())
.then((data) => {
if (data && typeof data.stargazers_count === "number") {
setStars(data.stargazers_count);
}
})
.catch((err) => {
if (err instanceof Error && err.name !== "AbortError") {
console.error("Error fetching stars:", err);
}
})
.finally(() => setIsLoading(false));
return () => abortController.abort();
}, [username, repo]);
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className={cn(
"group inline-flex items-center rounded-full border border-neutral-200 bg-white/80 px-3 py-1.5 text-sm backdrop-blur-sm transition-colors dark:border-neutral-800 dark:bg-neutral-950/80",
"hover:bg-neutral-100 dark:hover:bg-neutral-900",
className
)}
>
<IconBrandGithub className="h-5 w-5 shrink-0 text-neutral-600 transition-colors dark:text-neutral-300 group-hover:text-neutral-800 dark:group-hover:text-neutral-100" />
<div className="ml-2 flex items-center text-neutral-500 transition-colors dark:text-neutral-400 group-hover:text-neutral-800 dark:group-hover:text-neutral-200">
<AnimatedStarCount
value={isLoading ? 10000 : stars}
itemSize={ITEM_SIZE}
isRolling={hasMounted && isLoading}
animated={hasMounted}
className="text-sm font-semibold tabular-nums text-neutral-500 dark:text-neutral-400 group-hover:text-neutral-800 dark:group-hover:text-neutral-200 transition-colors"
/>
</div>
</a>
);
}
export { NavbarGitHubStars, type NavbarGitHubStarsProps };

View file

@ -32,11 +32,24 @@ const GoogleLogo = ({ className }: { className?: string }) => (
</svg> </svg>
); );
function useIsDesktop(breakpoint = 1024) {
const [isDesktop, setIsDesktop] = useState(false);
useEffect(() => {
const mql = window.matchMedia(`(min-width: ${breakpoint}px)`);
setIsDesktop(mql.matches);
const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
mql.addEventListener("change", handler);
return () => mql.removeEventListener("change", handler);
}, [breakpoint]);
return isDesktop;
}
export function HeroSection() { export function HeroSection() {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const parentRef = useRef<HTMLDivElement>(null); const parentRef = useRef<HTMLDivElement>(null);
const heroVariant = useFeatureFlagVariantKey("notebooklm_superpowers_flag"); const heroVariant = useFeatureFlagVariantKey("notebooklm_superpowers_flag");
const isNotebookLMVariant = heroVariant === "superpowers"; const isNotebookLMVariant = heroVariant === "superpowers";
const isDesktop = useIsDesktop();
return ( return (
<div <div
@ -44,42 +57,46 @@ export function HeroSection() {
className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-4 py-24 md:px-8 md:py-48" className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-4 py-24 md:px-8 md:py-48"
> >
<BackgroundGrids /> <BackgroundGrids />
<CollisionMechanism {isDesktop && (
parentRef={parentRef} <>
beamOptions={{ <CollisionMechanism
initialX: -400, parentRef={parentRef}
translateX: 600, beamOptions={{
duration: 7, initialX: -400,
repeatDelay: 3, translateX: 600,
}} duration: 7,
/> repeatDelay: 3,
<CollisionMechanism }}
parentRef={parentRef} />
beamOptions={{ <CollisionMechanism
initialX: -200, parentRef={parentRef}
translateX: 800, beamOptions={{
duration: 4, initialX: -200,
repeatDelay: 3, translateX: 800,
}} duration: 4,
/> repeatDelay: 3,
<CollisionMechanism }}
parentRef={parentRef} />
beamOptions={{ <CollisionMechanism
initialX: 200, parentRef={parentRef}
translateX: 1200, beamOptions={{
duration: 5, initialX: 200,
repeatDelay: 3, translateX: 1200,
}} duration: 5,
/> repeatDelay: 3,
<CollisionMechanism }}
parentRef={parentRef} />
beamOptions={{ <CollisionMechanism
initialX: 400, parentRef={parentRef}
translateX: 1400, beamOptions={{
duration: 6, initialX: 400,
repeatDelay: 3, translateX: 1400,
}} duration: 6,
/> repeatDelay: 3,
}}
/>
</>
)}
<h2 className="relative z-50 mx-auto mb-4 mt-8 max-w-4xl text-balance text-center text-3xl font-semibold tracking-tight text-gray-700 md:text-7xl dark:text-neutral-300"> <h2 className="relative z-50 mx-auto mb-4 mt-8 max-w-4xl text-balance text-center text-3xl font-semibold tracking-tight text-gray-700 md:text-7xl dark:text-neutral-300">
{isNotebookLMVariant ? ( {isNotebookLMVariant ? (

View file

@ -1,18 +1,12 @@
"use client"; "use client";
import { import { IconBrandDiscord, IconBrandReddit, IconMenu2, IconX } from "@tabler/icons-react";
IconBrandDiscord,
IconBrandGithub,
IconBrandReddit,
IconMenu2,
IconX,
} from "@tabler/icons-react";
import { AnimatePresence, motion } from "motion/react"; import { AnimatePresence, motion } from "motion/react";
import Link from "next/link"; import Link from "next/link";
import { useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { SignInButton } from "@/components/auth/sign-in-button"; import { SignInButton } from "@/components/auth/sign-in-button";
import { NavbarGitHubStars } from "@/components/homepage/github-stars-badge";
import { Logo } from "@/components/Logo"; import { Logo } from "@/components/Logo";
import { ThemeTogglerComponent } from "@/components/theme/theme-toggle"; import { ThemeTogglerComponent } from "@/components/theme/theme-toggle";
import { useGithubStars } from "@/hooks/use-github-stars";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export const Navbar = () => { export const Navbar = () => {
@ -38,7 +32,7 @@ export const Navbar = () => {
}, []); }, []);
return ( return (
<div className="fixed top-1 left-0 right-0 z-60 w-full"> <div className="fixed top-1 left-0 right-0 z-60 w-full select-none">
<DesktopNav navItems={navItems} isScrolled={isScrolled} /> <DesktopNav navItems={navItems} isScrolled={isScrolled} />
<MobileNav navItems={navItems} isScrolled={isScrolled} /> <MobileNav navItems={navItems} isScrolled={isScrolled} />
</div> </div>
@ -47,7 +41,6 @@ export const Navbar = () => {
const DesktopNav = ({ navItems, isScrolled }: any) => { const DesktopNav = ({ navItems, isScrolled }: any) => {
const [hovered, setHovered] = useState<number | null>(null); const [hovered, setHovered] = useState<number | null>(null);
const { compactFormat: githubStars, loading: loadingGithubStars } = useGithubStars();
return ( return (
<motion.div <motion.div
onMouseLeave={() => { onMouseLeave={() => {
@ -103,21 +96,7 @@ const DesktopNav = ({ navItems, isScrolled }: any) => {
> >
<IconBrandReddit className="h-5 w-5 text-neutral-600 dark:text-neutral-300" /> <IconBrandReddit className="h-5 w-5 text-neutral-600 dark:text-neutral-300" />
</Link> </Link>
<Link <NavbarGitHubStars className="hidden md:flex" />
href="https://github.com/MODSetter/SurfSense"
target="_blank"
rel="noopener noreferrer"
className="hidden rounded-full px-3 py-2 hover:bg-gray-100 dark:hover:bg-neutral-800 transition-colors md:flex items-center gap-1.5"
>
<IconBrandGithub className="h-5 w-5 text-neutral-600 dark:text-neutral-300" />
{loadingGithubStars ? (
<div className="w-6 h-5 dark:bg-neutral-800 animate-pulse"></div>
) : (
<span className="text-sm font-medium text-neutral-600 dark:text-neutral-300">
{githubStars}
</span>
)}
</Link>
<ThemeTogglerComponent /> <ThemeTogglerComponent />
<SignInButton variant="desktop" /> <SignInButton variant="desktop" />
</div> </div>
@ -127,10 +106,28 @@ const DesktopNav = ({ navItems, isScrolled }: any) => {
const MobileNav = ({ navItems, isScrolled }: any) => { const MobileNav = ({ navItems, isScrolled }: any) => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const { compactFormat: githubStars, loading: loadingGithubStars } = useGithubStars(); const navRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const handleClickOutside = (e: MouseEvent | TouchEvent) => {
if (navRef.current && !navRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("touchstart", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("touchstart", handleClickOutside);
};
}, [open]);
return ( return (
<motion.div <motion.div
ref={navRef}
animate={{ borderRadius: open ? "4px" : "2rem" }} animate={{ borderRadius: open ? "4px" : "2rem" }}
key={String(open)} key={String(open)}
className={cn( className={cn(
@ -197,21 +194,7 @@ const MobileNav = ({ navItems, isScrolled }: any) => {
> >
<IconBrandReddit className="h-5 w-5 text-neutral-600 dark:text-neutral-300" /> <IconBrandReddit className="h-5 w-5 text-neutral-600 dark:text-neutral-300" />
</Link> </Link>
<Link <NavbarGitHubStars className="rounded-lg" />
href="https://github.com/MODSetter/SurfSense"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 rounded-lg px-3 py-2 hover:bg-gray-100 dark:hover:bg-neutral-800 transition-colors touch-manipulation"
>
<IconBrandGithub className="h-5 w-5 text-neutral-600 dark:text-neutral-300" />
{loadingGithubStars ? (
<div className="w-6 h-5 dark:bg-neutral-800 animate-pulse"></div>
) : (
<span className="text-sm font-medium text-neutral-600 dark:text-neutral-300">
{githubStars}
</span>
)}
</Link>
<ThemeTogglerComponent /> <ThemeTogglerComponent />
</div> </div>
<SignInButton variant="mobile" /> <SignInButton variant="mobile" />

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { Plus, Trash2 } from "lucide-react"; import { Trash2 } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@ -76,7 +76,7 @@ export default function InferenceParamsEditor({ params, setParams }: InferencePa
<SelectTrigger id="param-key" className="w-full"> <SelectTrigger id="param-key" className="w-full">
<SelectValue placeholder="Select parameter" /> <SelectValue placeholder="Select parameter" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent className="bg-muted dark:border-neutral-700">
{PARAM_KEYS.map((key) => ( {PARAM_KEYS.map((key) => (
<SelectItem key={key} value={key}> <SelectItem key={key} value={key}>
{key} {key}
@ -104,7 +104,7 @@ export default function InferenceParamsEditor({ params, setParams }: InferencePa
onClick={handleAdd} onClick={handleAdd}
disabled={!selectedKey || value === ""} disabled={!selectedKey || value === ""}
> >
<Plus className="w-3.5 h-3.5 sm:w-4 sm:h-4 mr-2" /> Add Parameter Add Parameter
</Button> </Button>
</div> </div>

View file

@ -1,25 +1,28 @@
"use client"; "use client";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtomValue, useSetAtom } from "jotai"; import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { import { AlertTriangle, Inbox, Megaphone, SquareLibrary } from "lucide-react";
AlertTriangle,
Inbox,
LogOut,
Megaphone,
PencilIcon,
SquareLibrary,
Trash2,
} from "lucide-react";
import { useParams, usePathname, useRouter } from "next/navigation"; import { useParams, usePathname, useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { currentThreadAtom, resetCurrentThreadAtom } from "@/atoms/chat/current-thread.atom"; import { currentThreadAtom, resetCurrentThreadAtom } from "@/atoms/chat/current-thread.atom";
import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms";
import { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms"; import { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms"; import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { currentUserAtom } from "@/atoms/user/user-query.atoms"; import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
@ -32,6 +35,7 @@ import {
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { isPageLimitExceededMetadata } from "@/contracts/types/inbox.types"; import { isPageLimitExceededMetadata } from "@/contracts/types/inbox.types";
import { useAnnouncements } from "@/hooks/use-announcements"; import { useAnnouncements } from "@/hooks/use-announcements";
import { useDocumentsProcessing } from "@/hooks/use-documents-processing";
import { useInbox } from "@/hooks/use-inbox"; import { useInbox } from "@/hooks/use-inbox";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { logout } from "@/lib/auth-utils"; import { logout } from "@/lib/auth-utils";
@ -46,7 +50,6 @@ import { LayoutShell } from "../ui/shell";
interface LayoutDataProviderProps { interface LayoutDataProviderProps {
searchSpaceId: string; searchSpaceId: string;
children: React.ReactNode; children: React.ReactNode;
breadcrumb?: React.ReactNode;
} }
/** /**
@ -60,11 +63,7 @@ function formatInboxCount(count: number): string {
return `${thousands}k+`; return `${thousands}k+`;
} }
export function LayoutDataProvider({ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProviderProps) {
searchSpaceId,
children,
breadcrumb,
}: LayoutDataProviderProps) {
const t = useTranslations("dashboard"); const t = useTranslations("dashboard");
const tCommon = useTranslations("common"); const tCommon = useTranslations("common");
const tSidebar = useTranslations("sidebar"); const tSidebar = useTranslations("sidebar");
@ -87,6 +86,10 @@ export function LayoutDataProvider({
// State for handling new chat navigation when router is out of sync // State for handling new chat navigation when router is out of sync
const [pendingNewChat, setPendingNewChat] = useState(false); const [pendingNewChat, setPendingNewChat] = useState(false);
// Key used to force-remount the page component (e.g. after deleting the active chat
// when the router is out of sync due to replaceState)
const [chatResetKey, setChatResetKey] = useState(0);
// Current IDs from URL, with fallback to atom for replaceState updates // Current IDs from URL, with fallback to atom for replaceState updates
const currentChatId = params?.chat_id const currentChatId = params?.chat_id
? Number(Array.isArray(params.chat_id) ? params.chat_id[0] : params.chat_id) ? Number(Array.isArray(params.chat_id) ? params.chat_id[0] : params.chat_id)
@ -114,40 +117,27 @@ export function LayoutDataProvider({
const [isInboxSidebarOpen, setIsInboxSidebarOpen] = useState(false); const [isInboxSidebarOpen, setIsInboxSidebarOpen] = useState(false);
const [isInboxDocked, setIsInboxDocked] = useState(false); const [isInboxDocked, setIsInboxDocked] = useState(false);
// Documents sidebar state (shared atom so Composer can toggle it)
const [isDocumentsSidebarOpen, setIsDocumentsSidebarOpen] = useAtom(documentsSidebarOpenAtom);
// Announcements sidebar state // Announcements sidebar state
const [isAnnouncementsSidebarOpen, setIsAnnouncementsSidebarOpen] = useState(false); const [isAnnouncementsSidebarOpen, setIsAnnouncementsSidebarOpen] = useState(false);
// Search space dialog state // Search space dialog state
const [isCreateSearchSpaceDialogOpen, setIsCreateSearchSpaceDialogOpen] = useState(false); const [isCreateSearchSpaceDialogOpen, setIsCreateSearchSpaceDialogOpen] = useState(false);
// Inbox hooks - separate data sources for mentions and status tabs // Per-tab inbox hooks — each has independent API loading, pagination,
// This ensures each tab has independent pagination and data loading // and Electric live queries. The Electric sync shape is shared (client-level cache).
const userId = user?.id ? String(user.id) : null; const userId = user?.id ? String(user.id) : null;
const numericSpaceId = Number(searchSpaceId) || null;
const { const commentsInbox = useInbox(userId, numericSpaceId, "comments");
inboxItems: mentionItems, const statusInbox = useInbox(userId, numericSpaceId, "status");
unreadCount: mentionUnreadCount,
loading: mentionLoading,
loadingMore: mentionLoadingMore,
hasMore: mentionHasMore,
loadMore: mentionLoadMore,
markAsRead: markMentionAsRead,
markAllAsRead: markAllMentionsAsRead,
} = useInbox(userId, Number(searchSpaceId) || null, "new_mention");
const { const totalUnreadCount = commentsInbox.unreadCount + statusInbox.unreadCount;
inboxItems: statusItems,
unreadCount: allUnreadCount,
loading: statusLoading,
loadingMore: statusLoadingMore,
hasMore: statusHasMore,
loadMore: statusLoadMore,
markAsRead: markStatusAsRead,
markAllAsRead: markAllStatusAsRead,
} = useInbox(userId, Number(searchSpaceId) || null, null);
const totalUnreadCount = allUnreadCount; // Document processing status — drives sidebar status indicator (spinner / check / error)
const statusOnlyUnreadCount = Math.max(0, allUnreadCount - mentionUnreadCount); const documentsProcessingStatus = useDocumentsProcessing(numericSpaceId);
// Track seen notification IDs to detect new page_limit_exceeded notifications // Track seen notification IDs to detect new page_limit_exceeded notifications
const seenPageLimitNotifications = useRef<Set<number>>(new Set()); const seenPageLimitNotifications = useRef<Set<number>>(new Set());
@ -155,14 +145,12 @@ export function LayoutDataProvider({
// Effect to show toast for new page_limit_exceeded notifications // Effect to show toast for new page_limit_exceeded notifications
useEffect(() => { useEffect(() => {
if (statusLoading) return; if (statusInbox.loading) return;
// Get page_limit_exceeded notifications const pageLimitNotifications = statusInbox.inboxItems.filter(
const pageLimitNotifications = statusItems.filter(
(item) => item.type === "page_limit_exceeded" (item) => item.type === "page_limit_exceeded"
); );
// On initial load, just mark all as seen without showing toasts
if (isInitialLoad.current) { if (isInitialLoad.current) {
for (const notification of pageLimitNotifications) { for (const notification of pageLimitNotifications) {
seenPageLimitNotifications.current.add(notification.id); seenPageLimitNotifications.current.add(notification.id);
@ -171,16 +159,13 @@ export function LayoutDataProvider({
return; return;
} }
// Find new notifications (not yet seen)
const newNotifications = pageLimitNotifications.filter( const newNotifications = pageLimitNotifications.filter(
(notification) => !seenPageLimitNotifications.current.has(notification.id) (notification) => !seenPageLimitNotifications.current.has(notification.id)
); );
// Show toast for each new page_limit_exceeded notification
for (const notification of newNotifications) { for (const notification of newNotifications) {
seenPageLimitNotifications.current.add(notification.id); seenPageLimitNotifications.current.add(notification.id);
// Extract metadata for navigation
const actionUrl = isPageLimitExceededMetadata(notification.metadata) const actionUrl = isPageLimitExceededMetadata(notification.metadata)
? notification.metadata.action_url ? notification.metadata.action_url
: `/dashboard/${searchSpaceId}/more-pages`; : `/dashboard/${searchSpaceId}/more-pages`;
@ -195,24 +180,7 @@ export function LayoutDataProvider({
}, },
}); });
} }
}, [statusItems, statusLoading, searchSpaceId, router]); }, [statusInbox.inboxItems, statusInbox.loading, searchSpaceId, router]);
// 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);
@ -295,34 +263,35 @@ export function LayoutDataProvider({
// Navigation items // Navigation items
const navItems: NavItem[] = useMemo( const navItems: NavItem[] = useMemo(
() => [ () => [
{
title: "Documents",
url: `/dashboard/${searchSpaceId}/documents`,
icon: SquareLibrary,
isActive: pathname?.includes("/documents"),
},
{ {
title: "Inbox", title: "Inbox",
url: "#inbox", // Special URL to indicate this is handled differently url: "#inbox",
icon: Inbox, icon: Inbox,
isActive: isInboxSidebarOpen, isActive: isInboxSidebarOpen,
badge: totalUnreadCount > 0 ? formatInboxCount(totalUnreadCount) : undefined, badge: totalUnreadCount > 0 ? formatInboxCount(totalUnreadCount) : undefined,
}, },
{
title: "Documents",
url: "#documents",
icon: SquareLibrary,
isActive: isDocumentsSidebarOpen,
statusIndicator: documentsProcessingStatus,
},
{ {
title: "Announcements", title: "Announcements",
url: "#announcements", // Special URL to indicate this is handled differently url: "#announcements",
icon: Megaphone, icon: Megaphone,
isActive: isAnnouncementsSidebarOpen, isActive: isAnnouncementsSidebarOpen,
badge: announcementUnreadCount > 0 ? formatInboxCount(announcementUnreadCount) : undefined, badge: announcementUnreadCount > 0 ? formatInboxCount(announcementUnreadCount) : undefined,
}, },
], ],
[ [
searchSpaceId,
pathname,
isInboxSidebarOpen, isInboxSidebarOpen,
isDocumentsSidebarOpen,
totalUnreadCount, totalUnreadCount,
isAnnouncementsSidebarOpen, isAnnouncementsSidebarOpen,
announcementUnreadCount, announcementUnreadCount,
documentsProcessingStatus,
] ]
); );
@ -339,12 +308,12 @@ export function LayoutDataProvider({
}, []); }, []);
const handleUserSettings = useCallback(() => { const handleUserSettings = useCallback(() => {
router.push("/dashboard/user/settings"); router.push(`/dashboard/${searchSpaceId}/user-settings?tab=profile`);
}, [router]); }, [router, searchSpaceId]);
const handleSearchSpaceSettings = useCallback( const handleSearchSpaceSettings = useCallback(
(space: SearchSpace) => { (space: SearchSpace) => {
router.push(`/dashboard/${space.id}/settings?section=general`); router.push(`/dashboard/${space.id}/settings?tab=general`);
}, },
[router] [router]
); );
@ -415,10 +384,22 @@ export function LayoutDataProvider({
const handleNavItemClick = useCallback( const handleNavItemClick = useCallback(
(item: NavItem) => { (item: NavItem) => {
// Handle inbox specially - toggle sidebar instead of navigating
if (item.url === "#inbox") { if (item.url === "#inbox") {
setIsInboxSidebarOpen((prev) => { setIsInboxSidebarOpen((prev) => {
if (!prev) { if (!prev) {
setIsAllSharedChatsSidebarOpen(false);
setIsAllPrivateChatsSidebarOpen(false);
setIsDocumentsSidebarOpen(false);
setIsAnnouncementsSidebarOpen(false);
}
return !prev;
});
return;
}
if (item.url === "#documents") {
setIsDocumentsSidebarOpen((prev) => {
if (!prev) {
setIsInboxSidebarOpen(false);
setIsAllSharedChatsSidebarOpen(false); setIsAllSharedChatsSidebarOpen(false);
setIsAllPrivateChatsSidebarOpen(false); setIsAllPrivateChatsSidebarOpen(false);
setIsAnnouncementsSidebarOpen(false); setIsAnnouncementsSidebarOpen(false);
@ -427,13 +408,13 @@ export function LayoutDataProvider({
}); });
return; return;
} }
// Handle announcements specially - toggle sidebar instead of navigating
if (item.url === "#announcements") { if (item.url === "#announcements") {
setIsAnnouncementsSidebarOpen((prev) => { setIsAnnouncementsSidebarOpen((prev) => {
if (!prev) { if (!prev) {
setIsInboxSidebarOpen(false); setIsInboxSidebarOpen(false);
setIsAllSharedChatsSidebarOpen(false); setIsAllSharedChatsSidebarOpen(false);
setIsAllPrivateChatsSidebarOpen(false); setIsAllPrivateChatsSidebarOpen(false);
setIsDocumentsSidebarOpen(false);
} }
return !prev; return !prev;
}); });
@ -441,13 +422,7 @@ export function LayoutDataProvider({
} }
router.push(item.url); router.push(item.url);
}, },
[ [router, setIsDocumentsSidebarOpen]
router,
setIsAllPrivateChatsSidebarOpen,
setIsAllSharedChatsSidebarOpen,
setIsAnnouncementsSidebarOpen,
setIsInboxSidebarOpen,
]
); );
const handleNewChat = useCallback(() => { const handleNewChat = useCallback(() => {
@ -507,7 +482,7 @@ export function LayoutDataProvider({
); );
const handleSettings = useCallback(() => { const handleSettings = useCallback(() => {
router.push(`/dashboard/${searchSpaceId}/settings?section=general`); router.push(`/dashboard/${searchSpaceId}/settings?tab=general`);
}, [router, searchSpaceId]); }, [router, searchSpaceId]);
const handleManageMembers = useCallback(() => { const handleManageMembers = useCallback(() => {
@ -544,15 +519,17 @@ export function LayoutDataProvider({
setIsAllSharedChatsSidebarOpen(true); setIsAllSharedChatsSidebarOpen(true);
setIsAllPrivateChatsSidebarOpen(false); setIsAllPrivateChatsSidebarOpen(false);
setIsInboxSidebarOpen(false); setIsInboxSidebarOpen(false);
setIsDocumentsSidebarOpen(false);
setIsAnnouncementsSidebarOpen(false); setIsAnnouncementsSidebarOpen(false);
}, []); }, [setIsDocumentsSidebarOpen]);
const handleViewAllPrivateChats = useCallback(() => { const handleViewAllPrivateChats = useCallback(() => {
setIsAllPrivateChatsSidebarOpen(true); setIsAllPrivateChatsSidebarOpen(true);
setIsAllSharedChatsSidebarOpen(false); setIsAllSharedChatsSidebarOpen(false);
setIsInboxSidebarOpen(false); setIsInboxSidebarOpen(false);
setIsDocumentsSidebarOpen(false);
setIsAnnouncementsSidebarOpen(false); setIsAnnouncementsSidebarOpen(false);
}, []); }, [setIsDocumentsSidebarOpen]);
// Delete handlers // Delete handlers
const confirmDeleteChat = useCallback(async () => { const confirmDeleteChat = useCallback(async () => {
@ -562,7 +539,14 @@ export function LayoutDataProvider({
await deleteThread(chatToDelete.id); await deleteThread(chatToDelete.id);
queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] }); queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] });
if (currentChatId === chatToDelete.id) { if (currentChatId === chatToDelete.id) {
router.push(`/dashboard/${searchSpaceId}/new-chat`); resetCurrentThread();
const isOutOfSync = currentThreadState.id !== null && !params?.chat_id;
if (isOutOfSync) {
window.history.replaceState(null, "", `/dashboard/${searchSpaceId}/new-chat`);
setChatResetKey((k) => k + 1);
} else {
router.push(`/dashboard/${searchSpaceId}/new-chat`);
}
} }
} catch (error) { } catch (error) {
console.error("Error deleting thread:", error); console.error("Error deleting thread:", error);
@ -571,7 +555,16 @@ export function LayoutDataProvider({
setShowDeleteChatDialog(false); setShowDeleteChatDialog(false);
setChatToDelete(null); setChatToDelete(null);
} }
}, [chatToDelete, queryClient, searchSpaceId, router, currentChatId]); }, [
chatToDelete,
queryClient,
searchSpaceId,
resetCurrentThread,
currentChatId,
currentThreadState.id,
params?.chat_id,
router,
]);
// Rename handler // Rename handler
const confirmRenameChat = useCallback(async () => { const confirmRenameChat = useCallback(async () => {
@ -583,10 +576,6 @@ export function LayoutDataProvider({
queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] }); queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] });
queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] }); queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] });
queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] }); queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] });
// Invalidate thread detail for breadcrumb update
queryClient.invalidateQueries({
queryKey: ["threads", searchSpaceId, "detail", String(chatToRename.id)],
});
} catch (error) { } catch (error) {
console.error("Error renaming thread:", error); console.error("Error renaming thread:", error);
toast.error(tSidebar("error_renaming_chat") || "Failed to rename chat"); toast.error(tSidebar("error_renaming_chat") || "Failed to rename chat");
@ -641,7 +630,6 @@ export function LayoutDataProvider({
onUserSettings={handleUserSettings} onUserSettings={handleUserSettings}
onLogout={handleLogout} onLogout={handleLogout}
pageUsage={pageUsage} pageUsage={pageUsage}
breadcrumb={breadcrumb}
theme={theme} theme={theme}
setTheme={setTheme} setTheme={setTheme}
isChatPage={isChatPage} isChatPage={isChatPage}
@ -649,26 +637,27 @@ export function LayoutDataProvider({
inbox={{ inbox={{
isOpen: isInboxSidebarOpen, isOpen: isInboxSidebarOpen,
onOpenChange: setIsInboxSidebarOpen, onOpenChange: setIsInboxSidebarOpen,
// Separate data sources for each tab totalUnreadCount,
mentions: { comments: {
items: mentionItems, items: commentsInbox.inboxItems,
unreadCount: mentionUnreadCount, unreadCount: commentsInbox.unreadCount,
loading: mentionLoading, loading: commentsInbox.loading,
loadingMore: mentionLoadingMore, loadingMore: commentsInbox.loadingMore,
hasMore: mentionHasMore, hasMore: commentsInbox.hasMore,
loadMore: mentionLoadMore, loadMore: commentsInbox.loadMore,
markAsRead: commentsInbox.markAsRead,
markAllAsRead: commentsInbox.markAllAsRead,
}, },
status: { status: {
items: statusItems, items: statusInbox.inboxItems,
unreadCount: statusOnlyUnreadCount, unreadCount: statusInbox.unreadCount,
loading: statusLoading, loading: statusInbox.loading,
loadingMore: statusLoadingMore, loadingMore: statusInbox.loadingMore,
hasMore: statusHasMore, hasMore: statusInbox.hasMore,
loadMore: statusLoadMore, loadMore: statusInbox.loadMore,
markAsRead: statusInbox.markAsRead,
markAllAsRead: statusInbox.markAllAsRead,
}, },
totalUnreadCount,
markAsRead,
markAllAsRead,
isDocked: isInboxDocked, isDocked: isInboxDocked,
onDockedChange: setIsInboxDocked, onDockedChange: setIsInboxDocked,
}} }}
@ -686,36 +675,33 @@ export function LayoutDataProvider({
onOpenChange: setIsAllPrivateChatsSidebarOpen, onOpenChange: setIsAllPrivateChatsSidebarOpen,
searchSpaceId, searchSpaceId,
}} }}
documentsPanel={{
open: isDocumentsSidebarOpen,
onOpenChange: setIsDocumentsSidebarOpen,
}}
> >
{children} <Fragment key={chatResetKey}>{children}</Fragment>
</LayoutShell> </LayoutShell>
{/* Delete Chat Dialog */} {/* Delete Chat Dialog */}
<Dialog open={showDeleteChatDialog} onOpenChange={setShowDeleteChatDialog}> <AlertDialog open={showDeleteChatDialog} onOpenChange={setShowDeleteChatDialog}>
<DialogContent className="sm:max-w-md"> <AlertDialogContent className="sm:max-w-md">
<DialogHeader> <AlertDialogHeader>
<DialogTitle className="flex items-center gap-2"> <AlertDialogTitle>{t("delete_chat")}</AlertDialogTitle>
<Trash2 className="h-5 w-5 text-destructive" /> <AlertDialogDescription>
<span>{t("delete_chat")}</span>
</DialogTitle>
<DialogDescription>
{t("delete_chat_confirm")} <span className="font-medium">{chatToDelete?.name}</span>?{" "} {t("delete_chat_confirm")} <span className="font-medium">{chatToDelete?.name}</span>?{" "}
{t("action_cannot_undone")} {t("action_cannot_undone")}
</DialogDescription> </AlertDialogDescription>
</DialogHeader> </AlertDialogHeader>
<DialogFooter className="flex gap-2 sm:justify-end"> <AlertDialogFooter>
<Button <AlertDialogCancel disabled={isDeletingChat}>{tCommon("cancel")}</AlertDialogCancel>
variant="outline" <AlertDialogAction
onClick={() => setShowDeleteChatDialog(false)} onClick={(e) => {
e.preventDefault();
confirmDeleteChat();
}}
disabled={isDeletingChat} disabled={isDeletingChat}
> className="bg-destructive text-destructive-foreground hover:bg-destructive/90 gap-2"
{tCommon("cancel")}
</Button>
<Button
variant="destructive"
onClick={confirmDeleteChat}
disabled={isDeletingChat}
className="gap-2"
> >
{isDeletingChat ? ( {isDeletingChat ? (
<> <>
@ -723,15 +709,12 @@ export function LayoutDataProvider({
{t("deleting")} {t("deleting")}
</> </>
) : ( ) : (
<> tCommon("delete")
<Trash2 className="h-4 w-4" />
{tCommon("delete")}
</>
)} )}
</Button> </AlertDialogAction>
</DialogFooter> </AlertDialogFooter>
</DialogContent> </AlertDialogContent>
</Dialog> </AlertDialog>
{/* Rename Chat Dialog */} {/* Rename Chat Dialog */}
<Dialog open={showRenameChatDialog} onOpenChange={setShowRenameChatDialog}> <Dialog open={showRenameChatDialog} onOpenChange={setShowRenameChatDialog}>
@ -756,7 +739,7 @@ export function LayoutDataProvider({
/> />
<DialogFooter className="flex gap-2 sm:justify-end"> <DialogFooter className="flex gap-2 sm:justify-end">
<Button <Button
variant="outline" variant="secondary"
onClick={() => setShowRenameChatDialog(false)} onClick={() => setShowRenameChatDialog(false)}
disabled={isRenamingChat} disabled={isRenamingChat}
> >
@ -773,10 +756,7 @@ export function LayoutDataProvider({
{tSidebar("renaming") || "Renaming"} {tSidebar("renaming") || "Renaming"}
</> </>
) : ( ) : (
<> tSidebar("rename") || "Rename"
<PencilIcon className="h-4 w-4" />
{tSidebar("rename") || "Rename"}
</>
)} )}
</Button> </Button>
</DialogFooter> </DialogFooter>
@ -784,30 +764,25 @@ export function LayoutDataProvider({
</Dialog> </Dialog>
{/* Delete Search Space Dialog */} {/* Delete Search Space Dialog */}
<Dialog open={showDeleteSearchSpaceDialog} onOpenChange={setShowDeleteSearchSpaceDialog}> <AlertDialog open={showDeleteSearchSpaceDialog} onOpenChange={setShowDeleteSearchSpaceDialog}>
<DialogContent className="sm:max-w-md"> <AlertDialogContent className="sm:max-w-md">
<DialogHeader> <AlertDialogHeader>
<DialogTitle className="flex items-center gap-2"> <AlertDialogTitle>{t("delete_search_space")}</AlertDialogTitle>
<Trash2 className="h-5 w-5 text-destructive" /> <AlertDialogDescription>
<span>{t("delete_search_space")}</span>
</DialogTitle>
<DialogDescription>
{t("delete_space_confirm", { name: searchSpaceToDelete?.name || "" })} {t("delete_space_confirm", { name: searchSpaceToDelete?.name || "" })}
</DialogDescription> </AlertDialogDescription>
</DialogHeader> </AlertDialogHeader>
<DialogFooter className="flex gap-2 sm:justify-end"> <AlertDialogFooter>
<Button <AlertDialogCancel disabled={isDeletingSearchSpace}>
variant="outline"
onClick={() => setShowDeleteSearchSpaceDialog(false)}
disabled={isDeletingSearchSpace}
>
{tCommon("cancel")} {tCommon("cancel")}
</Button> </AlertDialogCancel>
<Button <AlertDialogAction
variant="destructive" onClick={(e) => {
onClick={confirmDeleteSearchSpace} e.preventDefault();
confirmDeleteSearchSpace();
}}
disabled={isDeletingSearchSpace} disabled={isDeletingSearchSpace}
className="gap-2" className="bg-destructive text-destructive-foreground hover:bg-destructive/90 gap-2"
> >
{isDeletingSearchSpace ? ( {isDeletingSearchSpace ? (
<> <>
@ -815,41 +790,33 @@ export function LayoutDataProvider({
{t("deleting")} {t("deleting")}
</> </>
) : ( ) : (
<> tCommon("delete")
<Trash2 className="h-4 w-4" />
{tCommon("delete")}
</>
)} )}
</Button> </AlertDialogAction>
</DialogFooter> </AlertDialogFooter>
</DialogContent> </AlertDialogContent>
</Dialog> </AlertDialog>
{/* Leave Search Space Dialog */} {/* Leave Search Space Dialog */}
<Dialog open={showLeaveSearchSpaceDialog} onOpenChange={setShowLeaveSearchSpaceDialog}> <AlertDialog open={showLeaveSearchSpaceDialog} onOpenChange={setShowLeaveSearchSpaceDialog}>
<DialogContent className="sm:max-w-md"> <AlertDialogContent className="sm:max-w-md">
<DialogHeader> <AlertDialogHeader>
<DialogTitle className="flex items-center gap-2"> <AlertDialogTitle>{t("leave_title")}</AlertDialogTitle>
<LogOut className="h-5 w-5 text-destructive" /> <AlertDialogDescription>
<span>{t("leave_title")}</span>
</DialogTitle>
<DialogDescription>
{t("leave_confirm", { name: searchSpaceToLeave?.name || "" })} {t("leave_confirm", { name: searchSpaceToLeave?.name || "" })}
</DialogDescription> </AlertDialogDescription>
</DialogHeader> </AlertDialogHeader>
<DialogFooter className="flex gap-2 sm:justify-end"> <AlertDialogFooter>
<Button <AlertDialogCancel disabled={isLeavingSearchSpace}>
variant="outline"
onClick={() => setShowLeaveSearchSpaceDialog(false)}
disabled={isLeavingSearchSpace}
>
{tCommon("cancel")} {tCommon("cancel")}
</Button> </AlertDialogCancel>
<Button <AlertDialogAction
variant="destructive" onClick={(e) => {
onClick={confirmLeaveSearchSpace} e.preventDefault();
confirmLeaveSearchSpace();
}}
disabled={isLeavingSearchSpace} disabled={isLeavingSearchSpace}
className="gap-2" className="bg-destructive text-destructive-foreground hover:bg-destructive/90 gap-2"
> >
{isLeavingSearchSpace ? ( {isLeavingSearchSpace ? (
<> <>
@ -857,15 +824,12 @@ export function LayoutDataProvider({
{t("leaving")} {t("leaving")}
</> </>
) : ( ) : (
<> t("leave")
<LogOut className="h-4 w-4" />
{t("leave")}
</>
)} )}
</Button> </AlertDialogAction>
</DialogFooter> </AlertDialogFooter>
</DialogContent> </AlertDialogContent>
</Dialog> </AlertDialog>
{/* Create Search Space Dialog */} {/* Create Search Space Dialog */}
<CreateSearchSpaceDialog <CreateSearchSpaceDialog

View file

@ -1,4 +1,5 @@
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import type { DocumentsProcessingStatus } from "@/hooks/use-documents-processing";
export interface SearchSpace { export interface SearchSpace {
id: number; id: number;
@ -21,6 +22,7 @@ export interface NavItem {
icon: LucideIcon; icon: LucideIcon;
isActive?: boolean; isActive?: boolean;
badge?: string | number; badge?: string | number;
statusIndicator?: DocumentsProcessingStatus;
} }
export interface ChatItem { export interface ChatItem {

View file

@ -138,20 +138,20 @@ export function CreateSearchSpaceDialog({ open, onOpenChange }: CreateSearchSpac
)} )}
/> />
<DialogFooter className="flex-row gap-2 pt-2 sm:pt-3"> <DialogFooter className="flex-row justify-end gap-2 pt-2 sm:pt-3">
<Button <Button
type="button" type="button"
variant="outline" variant="secondary"
onClick={() => handleOpenChange(false)} onClick={() => handleOpenChange(false)}
disabled={isSubmitting} disabled={isSubmitting}
className="flex-1 sm:flex-none sm:w-auto h-8 sm:h-10 text-xs sm:text-sm" className="h-8 sm:h-9 text-xs sm:text-sm"
> >
{tCommon("cancel")} {tCommon("cancel")}
</Button> </Button>
<Button <Button
type="submit" type="submit"
disabled={isSubmitting} disabled={isSubmitting}
className="flex-1 sm:flex-none sm:w-auto h-8 sm:h-10 text-xs sm:text-sm" className="h-8 sm:h-9 text-xs sm:text-sm"
> >
{isSubmitting ? ( {isSubmitting ? (
<> <>

View file

@ -3,33 +3,30 @@
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { currentThreadAtom } from "@/atoms/chat/current-thread.atom"; import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { ChatHeader } from "@/components/new-chat/chat-header";
import { ChatShareButton } from "@/components/new-chat/chat-share-button"; import { ChatShareButton } from "@/components/new-chat/chat-share-button";
import type { ChatVisibility, ThreadRecord } from "@/lib/chat/thread-persistence"; import type { ChatVisibility, ThreadRecord } from "@/lib/chat/thread-persistence";
interface HeaderProps { interface HeaderProps {
breadcrumb?: React.ReactNode;
mobileMenuTrigger?: React.ReactNode; mobileMenuTrigger?: React.ReactNode;
} }
export function Header({ breadcrumb, mobileMenuTrigger }: HeaderProps) { export function Header({ mobileMenuTrigger }: HeaderProps) {
const pathname = usePathname(); const pathname = usePathname();
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
// Check if we're on a chat page
const isChatPage = pathname?.includes("/new-chat") ?? false; const isChatPage = pathname?.includes("/new-chat") ?? false;
// Use Jotai atom for thread state (synced from chat page)
const currentThreadState = useAtomValue(currentThreadAtom); const currentThreadState = useAtomValue(currentThreadAtom);
// Show button only when we have a thread id (thread exists and is synced to Jotai)
const hasThread = isChatPage && currentThreadState.id !== null; const hasThread = isChatPage && currentThreadState.id !== null;
// Create minimal thread object for ChatShareButton (used for API calls)
const threadForButton: ThreadRecord | null = const threadForButton: ThreadRecord | null =
hasThread && currentThreadState.id !== null hasThread && currentThreadState.id !== null
? { ? {
id: currentThreadState.id, id: currentThreadState.id,
visibility: currentThreadState.visibility ?? "PRIVATE", visibility: currentThreadState.visibility ?? "PRIVATE",
// These fields are not used by ChatShareButton for display, only for checks
created_by_id: null, created_by_id: null,
search_space_id: 0, search_space_id: 0,
title: "", title: "",
@ -39,22 +36,20 @@ export function Header({ breadcrumb, mobileMenuTrigger }: HeaderProps) {
} }
: null; : null;
const handleVisibilityChange = (_visibility: ChatVisibility) => { const handleVisibilityChange = (_visibility: ChatVisibility) => {};
// Visibility change is handled by ChatShareButton internally via Jotai
// This callback can be used for additional side effects if needed
};
return ( return (
<header className="sticky top-0 z-10 flex h-14 shrink-0 items-center gap-2 border-b bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 px-4"> <header className="sticky top-0 z-10 flex h-14 shrink-0 items-center gap-2 bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 px-4">
{/* Left side - Mobile menu trigger + Breadcrumb */} {/* Left side - Mobile menu trigger + Model selector */}
<div className="flex flex-1 items-center gap-2 min-w-0"> <div className="flex flex-1 items-center gap-2 min-w-0">
{mobileMenuTrigger} {mobileMenuTrigger}
<div className="hidden md:block">{breadcrumb}</div> {isChatPage && searchSpaceId && (
<ChatHeader searchSpaceId={Number(searchSpaceId)} className="md:h-9 md:px-4 md:text-sm" />
)}
</div> </div>
{/* Right side - Actions */} {/* Right side - Actions */}
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{/* Share button - only show on chat pages when thread exists */}
{hasThread && ( {hasThread && (
<ChatShareButton thread={threadForButton} onVisibilityChange={handleVisibilityChange} /> <ChatShareButton thread={threadForButton} onVisibilityChange={handleVisibilityChange} />
)} )}

View file

@ -159,13 +159,13 @@ export function SearchSpaceAvatar({
)} )}
{onSettings && onDelete && <DropdownMenuSeparator />} {onSettings && onDelete && <DropdownMenuSeparator />}
{onDelete && isOwner && ( {onDelete && isOwner && (
<DropdownMenuItem variant="destructive" onClick={onDelete}> <DropdownMenuItem onClick={onDelete}>
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
{tCommon("delete")} {tCommon("delete")}
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{onDelete && !isOwner && ( {onDelete && !isOwner && (
<DropdownMenuItem variant="destructive" onClick={onDelete}> <DropdownMenuItem onClick={onDelete}>
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
{t("leave")} {t("leave")}
</DropdownMenuItem> </DropdownMenuItem>
@ -217,13 +217,13 @@ export function SearchSpaceAvatar({
)} )}
{onSettings && onDelete && <ContextMenuSeparator />} {onSettings && onDelete && <ContextMenuSeparator />}
{onDelete && isOwner && ( {onDelete && isOwner && (
<ContextMenuItem variant="destructive" onClick={onDelete}> <ContextMenuItem onClick={onDelete}>
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
{tCommon("delete")} {tCommon("delete")}
</ContextMenuItem> </ContextMenuItem>
)} )}
{onDelete && !isOwner && ( {onDelete && !isOwner && (
<ContextMenuItem variant="destructive" onClick={onDelete}> <ContextMenuItem onClick={onDelete}>
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
{t("leave")} {t("leave")}
</ContextMenuItem> </ContextMenuItem>

View file

@ -14,37 +14,33 @@ import {
AllPrivateChatsSidebar, AllPrivateChatsSidebar,
AllSharedChatsSidebar, AllSharedChatsSidebar,
AnnouncementsSidebar, AnnouncementsSidebar,
DocumentsSidebar,
InboxSidebar, InboxSidebar,
MobileSidebar, MobileSidebar,
MobileSidebarTrigger, MobileSidebarTrigger,
Sidebar, Sidebar,
} from "../sidebar"; } from "../sidebar";
// Tab-specific data source props // Per-tab data source
interface TabDataSource { interface TabDataSource {
items: InboxItem[]; items: InboxItem[];
unreadCount: number; unreadCount: number;
loading: boolean; loading: boolean;
loadingMore?: boolean; loadingMore: boolean;
hasMore?: boolean; hasMore: boolean;
loadMore?: () => void; loadMore: () => void;
markAsRead: (id: number) => Promise<boolean>;
markAllAsRead: () => Promise<boolean>;
} }
// Inbox-related props with separate data sources per tab // Inbox-related props — per-tab data sources with independent loading/pagination
interface InboxProps { interface InboxProps {
isOpen: boolean; isOpen: boolean;
onOpenChange: (open: boolean) => void; 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; totalUnreadCount: number;
markAsRead: (id: number) => Promise<boolean>; comments: TabDataSource;
markAllAsRead: () => Promise<boolean>; status: TabDataSource;
/** Whether the inbox is docked (permanent) */
isDocked?: boolean; isDocked?: boolean;
/** Callback to change docked state */
onDockedChange?: (docked: boolean) => void; onDockedChange?: (docked: boolean) => void;
} }
@ -74,7 +70,6 @@ interface LayoutShellProps {
onUserSettings?: () => void; onUserSettings?: () => void;
onLogout?: () => void; onLogout?: () => void;
pageUsage?: PageUsage; pageUsage?: PageUsage;
breadcrumb?: React.ReactNode;
theme?: string; theme?: string;
setTheme?: (theme: "light" | "dark" | "system") => void; setTheme?: (theme: "light" | "dark" | "system") => void;
defaultCollapsed?: boolean; defaultCollapsed?: boolean;
@ -99,6 +94,10 @@ interface LayoutShellProps {
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
searchSpaceId: string; searchSpaceId: string;
}; };
documentsPanel?: {
open: boolean;
onOpenChange: (open: boolean) => void;
};
} }
export function LayoutShell({ export function LayoutShell({
@ -127,7 +126,6 @@ export function LayoutShell({
onUserSettings, onUserSettings,
onLogout, onLogout,
pageUsage, pageUsage,
breadcrumb,
theme, theme,
setTheme, setTheme,
defaultCollapsed = false, defaultCollapsed = false,
@ -139,6 +137,7 @@ export function LayoutShell({
isLoadingChats = false, isLoadingChats = false,
allSharedChatsPanel, allSharedChatsPanel,
allPrivateChatsPanel, allPrivateChatsPanel,
documentsPanel,
}: LayoutShellProps) { }: LayoutShellProps) {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
@ -162,7 +161,6 @@ export function LayoutShell({
<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
breadcrumb={breadcrumb}
mobileMenuTrigger={<MobileSidebarTrigger onClick={() => setMobileMenuOpen(true)} />} mobileMenuTrigger={<MobileSidebarTrigger onClick={() => setMobileMenuOpen(true)} />}
/> />
@ -208,16 +206,22 @@ export function LayoutShell({
<InboxSidebar <InboxSidebar
open={inbox.isOpen} open={inbox.isOpen}
onOpenChange={inbox.onOpenChange} onOpenChange={inbox.onOpenChange}
mentions={inbox.mentions} comments={inbox.comments}
status={inbox.status} status={inbox.status}
totalUnreadCount={inbox.totalUnreadCount} totalUnreadCount={inbox.totalUnreadCount}
markAsRead={inbox.markAsRead}
markAllAsRead={inbox.markAllAsRead}
onCloseMobileSidebar={() => setMobileMenuOpen(false)} onCloseMobileSidebar={() => setMobileMenuOpen(false)}
/> />
)} )}
{/* Mobile Announcements Sidebar - only render when open to avoid scroll blocking */} {/* Mobile Documents Sidebar - slide-out panel */}
{documentsPanel && (
<DocumentsSidebar
open={documentsPanel.open}
onOpenChange={documentsPanel.onOpenChange}
/>
)}
{/* Mobile Announcements Sidebar */}
{announcementsPanel?.open && ( {announcementsPanel?.open && (
<AnnouncementsSidebar <AnnouncementsSidebar
open={announcementsPanel.open} open={announcementsPanel.open}
@ -307,18 +311,16 @@ export function LayoutShell({
<InboxSidebar <InboxSidebar
open={inbox.isOpen} open={inbox.isOpen}
onOpenChange={inbox.onOpenChange} onOpenChange={inbox.onOpenChange}
mentions={inbox.mentions} comments={inbox.comments}
status={inbox.status} status={inbox.status}
totalUnreadCount={inbox.totalUnreadCount} totalUnreadCount={inbox.totalUnreadCount}
markAsRead={inbox.markAsRead}
markAllAsRead={inbox.markAllAsRead}
isDocked={inbox.isDocked} isDocked={inbox.isDocked}
onDockedChange={inbox.onDockedChange} 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 />
<div className={cn("flex-1", isChatPage ? "overflow-hidden" : "overflow-auto")}> <div className={cn("flex-1", isChatPage ? "overflow-hidden" : "overflow-auto")}>
{children} {children}
@ -330,17 +332,23 @@ export function LayoutShell({
<InboxSidebar <InboxSidebar
open={inbox.isOpen} open={inbox.isOpen}
onOpenChange={inbox.onOpenChange} onOpenChange={inbox.onOpenChange}
mentions={inbox.mentions} comments={inbox.comments}
status={inbox.status} status={inbox.status}
totalUnreadCount={inbox.totalUnreadCount} totalUnreadCount={inbox.totalUnreadCount}
markAsRead={inbox.markAsRead}
markAllAsRead={inbox.markAllAsRead}
isDocked={false} isDocked={false}
onDockedChange={inbox.onDockedChange} onDockedChange={inbox.onDockedChange}
/> />
)} )}
{/* Announcements Sidebar - positioned absolutely on top of content */} {/* Documents Sidebar - slide-out panel */}
{documentsPanel && (
<DocumentsSidebar
open={documentsPanel.open}
onOpenChange={documentsPanel.onOpenChange}
/>
)}
{/* Announcements Sidebar */}
{announcementsPanel && ( {announcementsPanel && (
<AnnouncementsSidebar <AnnouncementsSidebar
open={announcementsPanel.open} open={announcementsPanel.open}

View file

@ -16,7 +16,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@ -40,6 +40,7 @@ import { Spinner } from "@/components/ui/spinner";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useLongPress } from "@/hooks/use-long-press";
import { useIsMobile } from "@/hooks/use-mobile"; import { useIsMobile } from "@/hooks/use-mobile";
import { import {
deleteThread, deleteThread,
@ -85,6 +86,15 @@ export function AllPrivateChatsSidebar({
const [isRenaming, setIsRenaming] = useState(false); const [isRenaming, setIsRenaming] = useState(false);
const debouncedSearchQuery = useDebouncedValue(searchQuery, 300); const debouncedSearchQuery = useDebouncedValue(searchQuery, 300);
const pendingThreadIdRef = useRef<number | null>(null);
const { handlers: longPressHandlers, wasLongPress } = useLongPress(
useCallback(() => {
if (pendingThreadIdRef.current !== null) {
setOpenDropdownId(pendingThreadIdRef.current);
}
}, [])
);
const isSearchMode = !!debouncedSearchQuery.trim(); const isSearchMode = !!debouncedSearchQuery.trim();
useEffect(() => { useEffect(() => {
@ -357,7 +367,16 @@ export function AllPrivateChatsSidebar({
{isMobile ? ( {isMobile ? (
<button <button
type="button" type="button"
onClick={() => handleThreadClick(thread.id)} onClick={() => {
if (wasLongPress()) return;
handleThreadClick(thread.id);
}}
onTouchStart={() => {
pendingThreadIdRef.current = thread.id;
longPressHandlers.onTouchStart();
}}
onTouchEnd={longPressHandlers.onTouchEnd}
onTouchMove={longPressHandlers.onTouchMove}
disabled={isBusy} disabled={isBusy}
className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden" className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
> >
@ -396,7 +415,9 @@ export function AllPrivateChatsSidebar({
size="icon" size="icon"
className={cn( className={cn(
"h-6 w-6 shrink-0", "h-6 w-6 shrink-0",
"md:opacity-0 md:group-hover:opacity-100 md:focus:opacity-100", isMobile
? "opacity-0 pointer-events-none absolute"
: "md:opacity-0 md:group-hover:opacity-100 md:focus:opacity-100",
"transition-opacity" "transition-opacity"
)} )}
disabled={isBusy} disabled={isBusy}
@ -435,10 +456,7 @@ export function AllPrivateChatsSidebar({
)} )}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem onClick={() => handleDeleteThread(thread.id)}>
onClick={() => handleDeleteThread(thread.id)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
<span>{t("delete") || "Delete"}</span> <span>{t("delete") || "Delete"}</span>
</DropdownMenuItem> </DropdownMenuItem>
@ -496,7 +514,7 @@ export function AllPrivateChatsSidebar({
/> />
<DialogFooter className="flex gap-2 sm:justify-end"> <DialogFooter className="flex gap-2 sm:justify-end">
<Button <Button
variant="outline" variant="secondary"
onClick={() => setShowRenameDialog(false)} onClick={() => setShowRenameDialog(false)}
disabled={isRenaming} disabled={isRenaming}
> >

View file

@ -16,7 +16,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@ -40,6 +40,7 @@ import { Spinner } from "@/components/ui/spinner";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useLongPress } from "@/hooks/use-long-press";
import { useIsMobile } from "@/hooks/use-mobile"; import { useIsMobile } from "@/hooks/use-mobile";
import { import {
deleteThread, deleteThread,
@ -85,6 +86,15 @@ export function AllSharedChatsSidebar({
const [isRenaming, setIsRenaming] = useState(false); const [isRenaming, setIsRenaming] = useState(false);
const debouncedSearchQuery = useDebouncedValue(searchQuery, 300); const debouncedSearchQuery = useDebouncedValue(searchQuery, 300);
const pendingThreadIdRef = useRef<number | null>(null);
const { handlers: longPressHandlers, wasLongPress } = useLongPress(
useCallback(() => {
if (pendingThreadIdRef.current !== null) {
setOpenDropdownId(pendingThreadIdRef.current);
}
}, [])
);
const isSearchMode = !!debouncedSearchQuery.trim(); const isSearchMode = !!debouncedSearchQuery.trim();
useEffect(() => { useEffect(() => {
@ -357,7 +367,16 @@ export function AllSharedChatsSidebar({
{isMobile ? ( {isMobile ? (
<button <button
type="button" type="button"
onClick={() => handleThreadClick(thread.id)} onClick={() => {
if (wasLongPress()) return;
handleThreadClick(thread.id);
}}
onTouchStart={() => {
pendingThreadIdRef.current = thread.id;
longPressHandlers.onTouchStart();
}}
onTouchEnd={longPressHandlers.onTouchEnd}
onTouchMove={longPressHandlers.onTouchMove}
disabled={isBusy} disabled={isBusy}
className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden" className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
> >
@ -396,7 +415,9 @@ export function AllSharedChatsSidebar({
size="icon" size="icon"
className={cn( className={cn(
"h-6 w-6 shrink-0", "h-6 w-6 shrink-0",
"md:opacity-0 md:group-hover:opacity-100 md:focus:opacity-100", isMobile
? "opacity-0 pointer-events-none absolute"
: "md:opacity-0 md:group-hover:opacity-100 md:focus:opacity-100",
"transition-opacity" "transition-opacity"
)} )}
disabled={isBusy} disabled={isBusy}
@ -435,10 +456,7 @@ export function AllSharedChatsSidebar({
)} )}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem onClick={() => handleDeleteThread(thread.id)}>
onClick={() => handleDeleteThread(thread.id)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
<span>{t("delete") || "Delete"}</span> <span>{t("delete") || "Delete"}</span>
</DropdownMenuItem> </DropdownMenuItem>
@ -496,7 +514,7 @@ export function AllSharedChatsSidebar({
/> />
<DialogFooter className="flex gap-2 sm:justify-end"> <DialogFooter className="flex gap-2 sm:justify-end">
<Button <Button
variant="outline" variant="secondary"
onClick={() => setShowRenameDialog(false)} onClick={() => setShowRenameDialog(false)}
disabled={isRenaming} disabled={isRenaming}
> >

View file

@ -2,8 +2,8 @@
import { ChevronLeft } from "lucide-react"; import { ChevronLeft } from "lucide-react";
import { useEffect } from "react"; import { useEffect } from "react";
import { AnnouncementsEmptyState } from "@/components/announcements/AnnouncementsEmptyState";
import { AnnouncementCard } from "@/components/announcements/AnnouncementCard"; import { AnnouncementCard } from "@/components/announcements/AnnouncementCard";
import { AnnouncementsEmptyState } from "@/components/announcements/AnnouncementsEmptyState";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useAnnouncements } from "@/hooks/use-announcements"; import { useAnnouncements } from "@/hooks/use-announcements";
import { useMediaQuery } from "@/hooks/use-media-query"; import { useMediaQuery } from "@/hooks/use-media-query";
@ -72,4 +72,3 @@ export function AnnouncementsSidebar({
</SidebarSlideOutPanel> </SidebarSlideOutPanel>
); );
} }

View file

@ -9,6 +9,7 @@ import {
Trash2, Trash2,
} from "lucide-react"; } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useCallback, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
DropdownMenu, DropdownMenu,
@ -17,6 +18,9 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { useLongPress } from "@/hooks/use-long-press";
import { useIsMobile } from "@/hooks/use-mobile";
import { useTypewriter } from "@/hooks/use-typewriter";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface ChatListItemProps { interface ChatListItemProps {
@ -39,12 +43,25 @@ export function ChatListItem({
onDelete, onDelete,
}: ChatListItemProps) { }: ChatListItemProps) {
const t = useTranslations("sidebar"); const t = useTranslations("sidebar");
const isMobile = useIsMobile();
const [dropdownOpen, setDropdownOpen] = useState(false);
const animatedName = useTypewriter(name);
const { handlers: longPressHandlers, wasLongPress } = useLongPress(
useCallback(() => setDropdownOpen(true), [])
);
const handleClick = useCallback(() => {
if (wasLongPress()) return;
onClick?.();
}, [onClick, wasLongPress]);
return ( return (
<div className="group/item relative w-full"> <div className="group/item relative w-full">
<button <button
type="button" type="button"
onClick={onClick} onClick={handleClick}
{...(isMobile ? longPressHandlers : {})}
className={cn( className={cn(
"flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-sm text-left transition-colors", "flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-sm text-left transition-colors",
"[&>span:last-child]:truncate", "[&>span:last-child]:truncate",
@ -54,19 +71,24 @@ export function ChatListItem({
)} )}
> >
<MessageSquare className="h-4 w-4 shrink-0 text-muted-foreground" /> <MessageSquare className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="w-[calc(100%-3rem)] ">{name}</span> <span className="w-[calc(100%-3rem)] ">{animatedName}</span>
</button> </button>
{/* Actions dropdown */} {/* Actions dropdown - trigger hidden on mobile, long-press opens it instead */}
<div className="absolute right-1 top-1/2 -translate-y-1/2 opacity-100 md:opacity-0 md:group-hover/item:opacity-100 transition-opacity"> <div
<DropdownMenu> className={cn(
"absolute right-1 top-1/2 -translate-y-1/2 transition-opacity",
isMobile ? "opacity-0 pointer-events-none" : "opacity-0 group-hover/item:opacity-100"
)}
>
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-6 w-6"> <Button variant="ghost" size="icon" className="h-6 w-6">
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" /> <MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
<span className="sr-only">{t("more_options")}</span> <span className="sr-only">{t("more_options")}</span>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" side="right"> <DropdownMenuContent align="end" side="bottom">
{onRename && ( {onRename && (
<DropdownMenuItem <DropdownMenuItem
onClick={(e) => { onClick={(e) => {
@ -105,7 +127,6 @@ export function ChatListItem({
e.stopPropagation(); e.stopPropagation();
onDelete(); onDelete();
}} }}
className="text-destructive focus:text-destructive"
> >
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
<span>{t("delete")}</span> <span>{t("delete")}</span>

View file

@ -0,0 +1,212 @@
"use client";
import { useAtom, useAtomValue } from "jotai";
import { ChevronLeft } from "lucide-react";
import { useParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { DocumentsFilters } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters";
import {
DocumentsTableShell,
type SortKey,
} from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell";
import { sidebarSelectedDocumentsAtom } from "@/atoms/chat/mentioned-documents.atom";
import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
import { Button } from "@/components/ui/button";
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useDocumentSearch } from "@/hooks/use-document-search";
import { useDocuments } from "@/hooks/use-documents";
import { useMediaQuery } from "@/hooks/use-media-query";
import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";
interface DocumentsSidebarProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps) {
const t = useTranslations("documents");
const tSidebar = useTranslations("sidebar");
const params = useParams();
const isMobile = !useMediaQuery("(min-width: 640px)");
const searchSpaceId = Number(params.search_space_id);
const [search, setSearch] = useState("");
const debouncedSearch = useDebouncedValue(search, 250);
const [activeTypes, setActiveTypes] = useState<DocumentTypeEnum[]>([]);
const [sortKey, setSortKey] = useState<SortKey>("created_at");
const [sortDesc, setSortDesc] = useState(true);
const { mutateAsync: deleteDocumentMutation } = useAtomValue(deleteDocumentMutationAtom);
const [sidebarDocs, setSidebarDocs] = useAtom(sidebarSelectedDocumentsAtom);
const mentionedDocIds = useMemo(() => new Set(sidebarDocs.map((d) => d.id)), [sidebarDocs]);
const handleToggleChatMention = useCallback(
(doc: { id: number; title: string; document_type: string }, isMentioned: boolean) => {
if (isMentioned) {
setSidebarDocs((prev) => prev.filter((d) => d.id !== doc.id));
} else {
setSidebarDocs((prev) => {
if (prev.some((d) => d.id === doc.id)) return prev;
return [
...prev,
{ id: doc.id, title: doc.title, document_type: doc.document_type as DocumentTypeEnum },
];
});
}
},
[setSidebarDocs]
);
const isSearchMode = !!debouncedSearch.trim();
const {
documents: realtimeDocuments,
typeCounts: realtimeTypeCounts,
loading: realtimeLoading,
loadingMore: realtimeLoadingMore,
hasMore: realtimeHasMore,
loadMore: realtimeLoadMore,
error: realtimeError,
} = useDocuments(searchSpaceId, activeTypes, sortKey, sortDesc ? "desc" : "asc");
const {
documents: searchDocuments,
loading: searchLoading,
loadingMore: searchLoadingMore,
hasMore: searchHasMore,
loadMore: searchLoadMore,
error: searchError,
removeItems: searchRemoveItems,
} = useDocumentSearch(searchSpaceId, debouncedSearch, activeTypes, isSearchMode && open);
const displayDocs = isSearchMode ? searchDocuments : realtimeDocuments;
const loading = isSearchMode ? searchLoading : realtimeLoading;
const error = isSearchMode ? searchError : !!realtimeError;
const hasMore = isSearchMode ? searchHasMore : realtimeHasMore;
const loadingMore = isSearchMode ? searchLoadingMore : realtimeLoadingMore;
const onLoadMore = isSearchMode ? searchLoadMore : realtimeLoadMore;
const onToggleType = (type: DocumentTypeEnum, checked: boolean) => {
setActiveTypes((prev) => {
if (checked) {
return prev.includes(type) ? prev : [...prev, type];
}
return prev.filter((t) => t !== type);
});
};
const handleDeleteDocument = useCallback(
async (id: number): Promise<boolean> => {
try {
await deleteDocumentMutation({ id });
toast.success(t("delete_success") || "Document deleted");
setSidebarDocs((prev) => prev.filter((d) => d.id !== id));
if (isSearchMode) {
searchRemoveItems([id]);
}
return true;
} catch (e) {
console.error("Error deleting document:", e);
return false;
}
},
[deleteDocumentMutation, isSearchMode, t, searchRemoveItems, setSidebarDocs]
);
const sortKeyRef = useRef(sortKey);
const sortDescRef = useRef(sortDesc);
sortKeyRef.current = sortKey;
sortDescRef.current = sortDesc;
const handleSortChange = useCallback((key: SortKey) => {
const currentKey = sortKeyRef.current;
const currentDesc = sortDescRef.current;
if (currentKey === key && currentDesc) {
setSortKey("created_at");
setSortDesc(true);
} else if (currentKey === key) {
setSortDesc(true);
} else {
setSortKey(key);
setSortDesc(false);
}
}, []);
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape" && open) {
onOpenChange(false);
}
};
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [open, onOpenChange]);
const documentsContent = (
<>
<div className="shrink-0 p-4 pb-10">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{isMobile && (
<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">{tSidebar("close") || "Close"}</span>
</Button>
)}
<h2 className="text-lg font-semibold">{t("title") || "Documents"}</h2>
</div>
</div>
</div>
<div className="flex-1 min-h-0 overflow-x-hidden pt-0 flex flex-col">
<div className="px-4 pb-2">
<DocumentsFilters
typeCounts={realtimeTypeCounts}
onSearch={setSearch}
searchValue={search}
onToggleType={onToggleType}
activeTypes={activeTypes}
/>
</div>
<DocumentsTableShell
documents={displayDocs}
loading={!!loading}
error={!!error}
sortKey={sortKey}
sortDesc={sortDesc}
onSortChange={handleSortChange}
deleteDocument={handleDeleteDocument}
searchSpaceId={String(searchSpaceId)}
hasMore={hasMore}
loadingMore={loadingMore}
onLoadMore={onLoadMore}
mentionedDocIds={mentionedDocIds}
onToggleChatMention={handleToggleChatMention}
onEditNavigate={() => onOpenChange(false)}
isSearchMode={isSearchMode || activeTypes.length > 0}
/>
</div>
</>
);
return (
<SidebarSlideOutPanel
open={open}
onOpenChange={onOpenChange}
ariaLabel={t("title") || "Documents"}
width={isMobile ? undefined : 480}
>
{documentsContent}
</SidebarSlideOutPanel>
);
}

View file

@ -22,6 +22,7 @@ import {
import { useParams, useRouter } from "next/navigation"; import { useParams, 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 { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon";
import { setCommentsCollapsedAtom, setTargetCommentIdAtom } from "@/atoms/chat/current-thread.atom"; 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";
@ -49,6 +50,7 @@ import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import { import {
isCommentReplyMetadata, isCommentReplyMetadata,
isConnectorIndexingMetadata, isConnectorIndexingMetadata,
isDocumentProcessingMetadata,
isNewMentionMetadata, isNewMentionMetadata,
isPageLimitExceededMetadata, isPageLimitExceededMetadata,
} from "@/contracts/types/inbox.types"; } from "@/contracts/types/inbox.types";
@ -60,9 +62,6 @@ import { cacheKeys } from "@/lib/query-client/cache-keys";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel"; import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";
/**
* Get initials from name or email for avatar fallback
*/
function getInitials(name: string | null | undefined, email: string | null | undefined): string { function getInitials(name: string | null | undefined, email: string | null | undefined): string {
if (name) { if (name) {
return name return name
@ -79,9 +78,6 @@ function getInitials(name: string | null | undefined, email: string | null | und
return "U"; return "U";
} }
/**
* Format count for display: shows numbers up to 999, then "1k+", "2k+", etc.
*/
function formatInboxCount(count: number): string { function formatInboxCount(count: number): string {
if (count <= 999) { if (count <= 999) {
return count.toString(); return count.toString();
@ -90,9 +86,6 @@ function formatInboxCount(count: number): string {
return `${thousands}k+`; return `${thousands}k+`;
} }
/**
* Get display name for connector type
*/
function getConnectorTypeDisplayName(connectorType: string): string { function getConnectorTypeDisplayName(connectorType: string): string {
const displayNames: Record<string, string> = { const displayNames: Record<string, string> = {
GITHUB_CONNECTOR: "GitHub", GITHUB_CONNECTOR: "GitHub",
@ -135,44 +128,36 @@ function getConnectorTypeDisplayName(connectorType: string): string {
} }
type InboxTab = "comments" | "status"; type InboxTab = "comments" | "status";
type InboxFilter = "all" | "unread"; type InboxFilter = "all" | "unread" | "errors";
// Tab-specific data source with independent pagination
interface TabDataSource { interface TabDataSource {
items: InboxItem[]; items: InboxItem[];
unreadCount: number; unreadCount: number;
loading: boolean; loading: boolean;
loadingMore?: boolean; loadingMore: boolean;
hasMore?: boolean; hasMore: boolean;
loadMore?: () => void; loadMore: () => void;
markAsRead: (id: number) => Promise<boolean>;
markAllAsRead: () => Promise<boolean>;
} }
interface InboxSidebarProps { interface InboxSidebarProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
/** Mentions tab data source with independent pagination */ comments: TabDataSource;
mentions: TabDataSource;
/** Status tab data source with independent pagination */
status: TabDataSource; status: TabDataSource;
/** Combined unread count for mark all as read */
totalUnreadCount: number; totalUnreadCount: number;
markAsRead: (id: number) => Promise<boolean>;
markAllAsRead: () => Promise<boolean>;
onCloseMobileSidebar?: () => void; onCloseMobileSidebar?: () => void;
/** Whether the inbox is docked (permanent) or floating */
isDocked?: boolean; isDocked?: boolean;
/** Callback to toggle docked state */
onDockedChange?: (docked: boolean) => void; onDockedChange?: (docked: boolean) => void;
} }
export function InboxSidebar({ export function InboxSidebar({
open, open,
onOpenChange, onOpenChange,
mentions, comments,
status, status,
totalUnreadCount, totalUnreadCount,
markAsRead,
markAllAsRead,
onCloseMobileSidebar, onCloseMobileSidebar,
isDocked = false, isDocked = false,
onDockedChange, onDockedChange,
@ -183,9 +168,7 @@ export function InboxSidebar({
const isMobile = !useMediaQuery("(min-width: 640px)"); const isMobile = !useMediaQuery("(min-width: 640px)");
const searchSpaceId = params?.search_space_id ? Number(params.search_space_id) : null; const searchSpaceId = params?.search_space_id ? Number(params.search_space_id) : null;
// Comments collapsed state (desktop only, when docked)
const [, setCommentsCollapsed] = useAtom(setCommentsCollapsedAtom); const [, setCommentsCollapsed] = useAtom(setCommentsCollapsedAtom);
// Target comment for navigation - also ensures comments panel is visible
const [, setTargetCommentId] = useAtom(setTargetCommentIdAtom); const [, setTargetCommentId] = useAtom(setTargetCommentIdAtom);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
@ -193,11 +176,9 @@ export function InboxSidebar({
const isSearchMode = !!debouncedSearch.trim(); const isSearchMode = !!debouncedSearch.trim();
const [activeTab, setActiveTab] = useState<InboxTab>("comments"); const [activeTab, setActiveTab] = useState<InboxTab>("comments");
const [activeFilter, setActiveFilter] = useState<InboxFilter>("all"); const [activeFilter, setActiveFilter] = useState<InboxFilter>("all");
const [selectedConnector, setSelectedConnector] = useState<string | null>(null); const [selectedSource, setSelectedSource] = useState<string | null>(null);
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
// Dropdown state for filter menu (desktop only)
const [openDropdown, setOpenDropdown] = useState<"filter" | null>(null); const [openDropdown, setOpenDropdown] = useState<"filter" | null>(null);
// Scroll shadow state for connector list
const [connectorScrollPos, setConnectorScrollPos] = useState<"top" | "middle" | "bottom">("top"); const [connectorScrollPos, setConnectorScrollPos] = useState<"top" | "middle" | "bottom">("top");
const handleConnectorScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => { const handleConnectorScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
const el = e.currentTarget; const el = e.currentTarget;
@ -205,15 +186,12 @@ export function InboxSidebar({
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2; const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
setConnectorScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle"); setConnectorScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
}, []); }, []);
// Drawer state for filter menu (mobile only)
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false); const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
const [markingAsReadId, setMarkingAsReadId] = useState<number | null>(null); const [markingAsReadId, setMarkingAsReadId] = useState<number | null>(null);
// Prefetch trigger ref - placed on item near the end
const prefetchTriggerRef = useRef<HTMLDivElement>(null); const prefetchTriggerRef = useRef<HTMLDivElement>(null);
// Server-side search query (enabled only when user is typing a search) // Server-side search query
// Determines which notification types to search based on active tab
const searchTypeFilter = activeTab === "comments" ? ("new_mention" as const) : undefined; const searchTypeFilter = activeTab === "comments" ? ("new_mention" as const) : undefined;
const { data: searchResponse, isLoading: isSearchLoading } = useQuery({ const { data: searchResponse, isLoading: isSearchLoading } = useQuery({
queryKey: cacheKeys.notifications.search(searchSpaceId, debouncedSearch.trim(), activeTab), queryKey: cacheKeys.notifications.search(searchSpaceId, debouncedSearch.trim(), activeTab),
@ -226,7 +204,7 @@ export function InboxSidebar({
limit: 50, limit: 50,
}, },
}), }),
staleTime: 30 * 1000, // 30 seconds (search results don't need to be super fresh) staleTime: 30 * 1000,
enabled: isSearchMode && open, enabled: isSearchMode && open,
}); });
@ -244,129 +222,128 @@ 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 || !isMobile) return; if (!open || !isMobile) return;
// Store original overflow to restore on cleanup
const originalOverflow = document.body.style.overflow; const originalOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden"; document.body.style.overflow = "hidden";
return () => { return () => {
document.body.style.overflow = originalOverflow; document.body.style.overflow = originalOverflow;
}; };
}, [open, isMobile]); }, [open, isMobile]);
// Reset connector filter when switching away from status tab
useEffect(() => { useEffect(() => {
if (activeTab !== "status") { if (activeTab !== "status") {
setSelectedConnector(null); setSelectedSource(null);
} }
}, [activeTab]); }, [activeTab]);
// Each tab uses its own data source for independent pagination // Active tab's data source — fully independent loading, pagination, and counts
// Comments tab: uses mentions data source (fetches only mention/reply types from server) const activeSource = activeTab === "comments" ? comments : status;
const commentsItems = mentions.items;
// Status tab: filters status data source (fetches all types) to status-specific types // Fetch source types for the status tab filter
const statusItems = useMemo( const { data: sourceTypesData } = useQuery({
() => queryKey: cacheKeys.notifications.sourceTypes(searchSpaceId),
status.items.filter( queryFn: () => notificationsApiService.getSourceTypes(searchSpaceId ?? undefined),
(item) => staleTime: 60 * 1000,
item.type === "connector_indexing" || enabled: open && activeTab === "status",
item.type === "document_processing" || });
item.type === "page_limit_exceeded" ||
item.type === "connector_deletion" const statusSourceOptions = useMemo(() => {
), if (!sourceTypesData?.sources) return [];
[status.items]
return sourceTypesData.sources.map((source) => ({
key: source.key,
type: source.type,
category: source.category,
displayName:
source.category === "connector"
? getConnectorTypeDisplayName(source.type)
: getDocumentTypeLabel(source.type),
}));
}, [sourceTypesData]);
// Client-side filter: source type
const matchesSourceFilter = useCallback(
(item: InboxItem): boolean => {
if (!selectedSource) return true;
if (selectedSource.startsWith("connector:")) {
const connectorType = selectedSource.slice("connector:".length);
return (
item.type === "connector_indexing" &&
isConnectorIndexingMetadata(item.metadata) &&
item.metadata.connector_type === connectorType
);
}
if (selectedSource.startsWith("doctype:")) {
const docType = selectedSource.slice("doctype:".length);
return (
item.type === "document_processing" &&
isDocumentProcessingMetadata(item.metadata) &&
item.metadata.document_type === docType
);
}
return true;
},
[selectedSource]
); );
// Pagination switches based on active tab // Client-side filter: unread / errors
const loading = activeTab === "comments" ? mentions.loading : status.loading; const matchesActiveFilter = useCallback(
const loadingMore = (item: InboxItem): boolean => {
activeTab === "comments" ? (mentions.loadingMore ?? false) : (status.loadingMore ?? false); if (activeFilter === "unread") return !item.read;
const hasMore = if (activeFilter === "errors") {
activeTab === "comments" ? (mentions.hasMore ?? false) : (status.hasMore ?? false); if (item.type === "page_limit_exceeded") return true;
const loadMore = activeTab === "comments" ? mentions.loadMore : status.loadMore; const meta = item.metadata as Record<string, unknown> | undefined;
return typeof meta?.status === "string" && meta.status === "failed";
}
return true;
},
[activeFilter]
);
// Get unique connector types from status items for filtering // Two data paths: search mode (API) or default (per-tab data source)
const uniqueConnectorTypes = useMemo(() => {
const connectorTypes = new Set<string>();
statusItems
.filter((item) => item.type === "connector_indexing")
.forEach((item) => {
// Use type guard for safe metadata access
if (isConnectorIndexingMetadata(item.metadata)) {
connectorTypes.add(item.metadata.connector_type);
}
});
return Array.from(connectorTypes).map((type) => ({
type,
displayName: getConnectorTypeDisplayName(type),
}));
}, [statusItems]);
// Get items for current tab
const displayItems = activeTab === "comments" ? commentsItems : statusItems;
// Filter items based on filter type, connector filter, and search mode
// When searching: use server-side API results (searches ALL notifications)
// When not searching: use Electric real-time items (fast, local)
const filteredItems = useMemo(() => { const filteredItems = useMemo(() => {
// In search mode, use API results let tabItems: InboxItem[];
let items: InboxItem[] = isSearchMode ? (searchResponse?.items ?? []) : displayItems;
// For status tab search results, filter to status-specific types if (isSearchMode) {
if (isSearchMode && activeTab === "status") { tabItems = searchResponse?.items ?? [];
items = items.filter( } else {
(item) => tabItems = activeSource.items;
item.type === "connector_indexing" ||
item.type === "document_processing" ||
item.type === "page_limit_exceeded" ||
item.type === "connector_deletion"
);
} }
// Apply read/unread filter let result = tabItems;
if (activeFilter === "unread") { if (activeFilter !== "all") {
items = items.filter((item) => !item.read); result = result.filter(matchesActiveFilter);
}
if (activeTab === "status" && selectedSource) {
result = result.filter(matchesSourceFilter);
} }
// Apply connector filter (only for status tab) return result;
if (activeTab === "status" && selectedConnector) { }, [
items = items.filter((item) => { isSearchMode,
if (item.type === "connector_indexing") { searchResponse,
// Use type guard for safe metadata access activeSource.items,
if (isConnectorIndexingMetadata(item.metadata)) { activeTab,
return item.metadata.connector_type === selectedConnector; activeFilter,
} selectedSource,
return false; matchesActiveFilter,
} matchesSourceFilter,
return false; // Hide document_processing when a specific connector is selected ]);
});
}
return items; // Infinite scroll — uses active tab's pagination
}, [displayItems, searchResponse, isSearchMode, activeFilter, activeTab, selectedConnector]);
// Intersection Observer for infinite scroll with prefetching
// Re-runs when active tab changes so each tab gets its own pagination
// Disabled during server-side search (search results are not paginated via infinite scroll)
useEffect(() => { useEffect(() => {
if (!loadMore || !hasMore || loadingMore || !open || isSearchMode) return; if (!activeSource.hasMore || activeSource.loadingMore || !open || isSearchMode) return;
const observer = new IntersectionObserver( const observer = new IntersectionObserver(
(entries) => { (entries) => {
// When trigger element is visible, load more
if (entries[0]?.isIntersecting) { if (entries[0]?.isIntersecting) {
loadMore(); activeSource.loadMore();
} }
}, },
{ {
root: null, // viewport root: null,
rootMargin: "100px", // Start loading 100px before visible rootMargin: "100px",
threshold: 0, threshold: 0,
} }
); );
@ -376,17 +353,13 @@ export function InboxSidebar({
} }
return () => observer.disconnect(); return () => observer.disconnect();
}, [loadMore, hasMore, loadingMore, open, isSearchMode, activeTab]); }, [activeSource.hasMore, activeSource.loadingMore, activeSource.loadMore, open, isSearchMode]);
// Unread counts from server-side accurate totals (passed via props)
const unreadCommentsCount = mentions.unreadCount;
const unreadStatusCount = status.unreadCount;
const handleItemClick = useCallback( const handleItemClick = useCallback(
async (item: InboxItem) => { async (item: InboxItem) => {
if (!item.read) { if (!item.read) {
setMarkingAsReadId(item.id); setMarkingAsReadId(item.id);
await markAsRead(item.id); await activeSource.markAsRead(item.id);
setMarkingAsReadId(null); setMarkingAsReadId(null);
} }
@ -427,7 +400,6 @@ export function InboxSidebar({
} }
} }
} else if (item.type === "page_limit_exceeded") { } else if (item.type === "page_limit_exceeded") {
// Navigate to the upgrade/more-pages page
if (isPageLimitExceededMetadata(item.metadata)) { if (isPageLimitExceededMetadata(item.metadata)) {
const actionUrl = item.metadata.action_url; const actionUrl = item.metadata.action_url;
if (actionUrl) { if (actionUrl) {
@ -438,12 +410,12 @@ export function InboxSidebar({
} }
} }
}, },
[markAsRead, router, onOpenChange, onCloseMobileSidebar, setTargetCommentId] [activeSource.markAsRead, router, onOpenChange, onCloseMobileSidebar, setTargetCommentId]
); );
const handleMarkAllAsRead = useCallback(async () => { const handleMarkAllAsRead = useCallback(async () => {
await markAllAsRead(); await Promise.all([comments.markAllAsRead(), status.markAllAsRead()]);
}, [markAllAsRead]); }, [comments.markAllAsRead, status.markAllAsRead]);
const handleClearSearch = useCallback(() => { const handleClearSearch = useCallback(() => {
setSearchQuery(""); setSearchQuery("");
@ -469,7 +441,6 @@ export function InboxSidebar({
}; };
const getStatusIcon = (item: InboxItem) => { const getStatusIcon = (item: InboxItem) => {
// For mentions and comment replies, show the author's avatar
if (item.type === "new_mention" || item.type === "comment_reply") { if (item.type === "new_mention" || item.type === "comment_reply") {
const metadata = const metadata =
item.type === "new_mention" item.type === "new_mention"
@ -501,7 +472,6 @@ export function InboxSidebar({
); );
} }
// For page limit exceeded, show a warning icon with amber/orange color
if (item.type === "page_limit_exceeded") { if (item.type === "page_limit_exceeded") {
return ( return (
<div className="h-8 w-8 flex items-center justify-center rounded-full bg-amber-500/10"> <div className="h-8 w-8 flex items-center justify-center rounded-full bg-amber-500/10">
@ -510,8 +480,6 @@ export function InboxSidebar({
); );
} }
// For status items (connector/document), show status icons
// Safely access status from metadata
const metadata = item.metadata as Record<string, unknown>; const metadata = item.metadata as Record<string, unknown>;
const status = typeof metadata?.status === "string" ? metadata.status : undefined; const status = typeof metadata?.status === "string" ? metadata.status : undefined;
@ -558,13 +526,13 @@ export function InboxSidebar({
if (!mounted) return null; if (!mounted) return null;
// Shared content component for both docked and floating modes const isLoading = isSearchMode ? isSearchLoading : activeSource.loading;
const inboxContent = ( const inboxContent = (
<> <>
<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">
{/* Back button - mobile only */}
{isMobile && ( {isMobile && (
<Button <Button
variant="ghost" variant="ghost"
@ -579,7 +547,6 @@ export function InboxSidebar({
<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">
{/* Mobile: Button that opens bottom drawer */}
{isMobile ? ( {isMobile ? (
<> <>
<Button <Button
@ -605,7 +572,6 @@ export function InboxSidebar({
</DrawerTitle> </DrawerTitle>
</DrawerHeader> </DrawerHeader>
<div className="flex-1 overflow-y-auto p-4 space-y-4"> <div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* Filter section */}
<div className="space-y-2"> <div className="space-y-2">
<p className="text-xs text-muted-foreground/80 font-medium px-1"> <p className="text-xs text-muted-foreground/80 font-medium px-1">
{t("filter") || "Filter"} {t("filter") || "Filter"}
@ -649,56 +615,74 @@ export function InboxSidebar({
</span> </span>
{activeFilter === "unread" && <Check className="h-4 w-4" />} {activeFilter === "unread" && <Check className="h-4 w-4" />}
</button> </button>
{activeTab === "status" && (
<button
type="button"
onClick={() => {
setActiveFilter("errors");
setFilterDrawerOpen(false);
}}
className={cn(
"flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
activeFilter === "errors"
? "bg-primary/10 text-primary"
: "hover:bg-muted"
)}
>
<span className="flex items-center gap-2">
<AlertCircle className="h-4 w-4" />
<span>{t("errors_only") || "Errors only"}</span>
</span>
{activeFilter === "errors" && <Check className="h-4 w-4" />}
</button>
)}
</div> </div>
</div> </div>
{/* Connectors section - only for status tab */} {activeTab === "status" && statusSourceOptions.length > 0 && (
{activeTab === "status" && uniqueConnectorTypes.length > 0 && (
<div className="space-y-2"> <div className="space-y-2">
<p className="text-xs text-muted-foreground/80 font-medium px-1"> <p className="text-xs text-muted-foreground/80 font-medium px-1">
{t("connectors") || "Connectors"} {t("sources") || "Sources"}
</p> </p>
<div className="space-y-1"> <div className="space-y-1">
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
setSelectedConnector(null); setSelectedSource(null);
setFilterDrawerOpen(false); setFilterDrawerOpen(false);
}} }}
className={cn( className={cn(
"flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors", "flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
selectedConnector === null selectedSource === null
? "bg-primary/10 text-primary" ? "bg-primary/10 text-primary"
: "hover:bg-muted" : "hover:bg-muted"
)} )}
> >
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<LayoutGrid className="h-4 w-4" /> <LayoutGrid className="h-4 w-4" />
<span>{t("all_connectors") || "All connectors"}</span> <span>{t("all_sources") || "All sources"}</span>
</span> </span>
{selectedConnector === null && <Check className="h-4 w-4" />} {selectedSource === null && <Check className="h-4 w-4" />}
</button> </button>
{uniqueConnectorTypes.map((connector) => ( {statusSourceOptions.map((source) => (
<button <button
key={connector.type} key={source.key}
type="button" type="button"
onClick={() => { onClick={() => {
setSelectedConnector(connector.type); setSelectedSource(source.key);
setFilterDrawerOpen(false); setFilterDrawerOpen(false);
}} }}
className={cn( className={cn(
"flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors", "flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
selectedConnector === connector.type selectedSource === source.key
? "bg-primary/10 text-primary" ? "bg-primary/10 text-primary"
: "hover:bg-muted" : "hover:bg-muted"
)} )}
> >
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
{getConnectorIcon(connector.type, "h-4 w-4")} {getConnectorIcon(source.type, "h-4 w-4")}
<span>{connector.displayName}</span> <span>{source.displayName}</span>
</span> </span>
{selectedConnector === connector.type && ( {selectedSource === source.key && <Check className="h-4 w-4" />}
<Check className="h-4 w-4" />
)}
</button> </button>
))} ))}
</div> </div>
@ -709,7 +693,6 @@ export function InboxSidebar({
</Drawer> </Drawer>
</> </>
) : ( ) : (
/* Desktop: Dropdown menu */
<DropdownMenu <DropdownMenu
open={openDropdown === "filter"} open={openDropdown === "filter"}
onOpenChange={(isOpen) => setOpenDropdown(isOpen ? "filter" : null)} onOpenChange={(isOpen) => setOpenDropdown(isOpen ? "filter" : null)}
@ -727,7 +710,10 @@ export function InboxSidebar({
</Tooltip> </Tooltip>
<DropdownMenuContent <DropdownMenuContent
align="end" align="end"
className={cn("z-80 select-none", activeTab === "status" ? "w-52" : "w-44")} className={cn(
"z-80 select-none max-h-[60vh] overflow-hidden flex flex-col",
activeTab === "status" ? "w-52" : "w-44"
)}
> >
<DropdownMenuLabel className="text-xs text-muted-foreground/80 font-normal"> <DropdownMenuLabel className="text-xs text-muted-foreground/80 font-normal">
{t("filter") || "Filter"} {t("filter") || "Filter"}
@ -752,13 +738,25 @@ export function InboxSidebar({
</span> </span>
{activeFilter === "unread" && <Check className="h-4 w-4" />} {activeFilter === "unread" && <Check className="h-4 w-4" />}
</DropdownMenuItem> </DropdownMenuItem>
{activeTab === "status" && uniqueConnectorTypes.length > 0 && ( {activeTab === "status" && (
<DropdownMenuItem
onClick={() => setActiveFilter("errors")}
className="flex items-center justify-between"
>
<span className="flex items-center gap-2">
<AlertCircle className="h-4 w-4" />
<span>{t("errors_only") || "Errors only"}</span>
</span>
{activeFilter === "errors" && <Check className="h-4 w-4" />}
</DropdownMenuItem>
)}
{activeTab === "status" && statusSourceOptions.length > 0 && (
<> <>
<DropdownMenuLabel className="text-xs text-muted-foreground/80 font-normal mt-2"> <DropdownMenuLabel className="text-xs text-muted-foreground/80 font-normal mt-2">
{t("connectors") || "Connectors"} {t("sources") || "Sources"}
</DropdownMenuLabel> </DropdownMenuLabel>
<div <div
className="relative max-h-[30vh] overflow-y-auto -mb-1" className="relative max-h-[30vh] overflow-y-auto overflow-x-hidden -mb-1"
onScroll={handleConnectorScroll} onScroll={handleConnectorScroll}
style={{ style={{
maskImage: `linear-gradient(to bottom, ${connectorScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${connectorScrollPos === "bottom" ? "black" : "transparent"})`, maskImage: `linear-gradient(to bottom, ${connectorScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${connectorScrollPos === "bottom" ? "black" : "transparent"})`,
@ -766,26 +764,26 @@ export function InboxSidebar({
}} }}
> >
<DropdownMenuItem <DropdownMenuItem
onClick={() => setSelectedConnector(null)} onClick={() => setSelectedSource(null)}
className="flex items-center justify-between" className="flex items-center justify-between"
> >
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<LayoutGrid className="h-4 w-4" /> <LayoutGrid className="h-4 w-4" />
<span>{t("all_connectors") || "All connectors"}</span> <span>{t("all_sources") || "All sources"}</span>
</span> </span>
{selectedConnector === null && <Check className="h-4 w-4" />} {selectedSource === null && <Check className="h-4 w-4" />}
</DropdownMenuItem> </DropdownMenuItem>
{uniqueConnectorTypes.map((connector) => ( {statusSourceOptions.map((source) => (
<DropdownMenuItem <DropdownMenuItem
key={connector.type} key={source.key}
onClick={() => setSelectedConnector(connector.type)} onClick={() => setSelectedSource(source.key)}
className="flex items-center justify-between" className="flex items-center justify-between"
> >
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
{getConnectorIcon(connector.type, "h-4 w-4")} {getConnectorIcon(source.type, "h-4 w-4")}
<span>{connector.displayName}</span> <span>{source.displayName}</span>
</span> </span>
{selectedConnector === connector.type && <Check className="h-4 w-4" />} {selectedSource === source.key && <Check className="h-4 w-4" />}
</DropdownMenuItem> </DropdownMenuItem>
))} ))}
</div> </div>
@ -824,7 +822,6 @@ export function InboxSidebar({
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
)} )}
{/* Dock/Undock button - desktop only */}
{!isMobile && onDockedChange && ( {!isMobile && onDockedChange && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@ -834,12 +831,10 @@ export function InboxSidebar({
className="h-8 w-8 rounded-full" className="h-8 w-8 rounded-full"
onClick={() => { onClick={() => {
if (isDocked) { if (isDocked) {
// Collapse: show comments immediately, then close inbox
setCommentsCollapsed(false); setCommentsCollapsed(false);
onDockedChange(false); onDockedChange(false);
onOpenChange(false); onOpenChange(false);
} else { } else {
// Expand: hide comments immediately
setCommentsCollapsed(true); setCommentsCollapsed(true);
onDockedChange(true); onDockedChange(true);
} }
@ -886,7 +881,13 @@ export function InboxSidebar({
<Tabs <Tabs
value={activeTab} value={activeTab}
onValueChange={(value) => setActiveTab(value as InboxTab)} onValueChange={(value) => {
const tab = value as InboxTab;
setActiveTab(tab);
if (tab !== "status" && activeFilter === "errors") {
setActiveFilter("all");
}
}}
className="shrink-0 mx-4" className="shrink-0 mx-4"
> >
<TabsList className="w-full h-auto p-0 bg-transparent rounded-none border-b"> <TabsList className="w-full h-auto p-0 bg-transparent rounded-none border-b">
@ -898,7 +899,7 @@ export function InboxSidebar({
<MessageSquare className="h-4 w-4" /> <MessageSquare className="h-4 w-4" />
<span>{t("comments") || "Comments"}</span> <span>{t("comments") || "Comments"}</span>
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium"> <span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
{formatInboxCount(unreadCommentsCount)} {formatInboxCount(comments.unreadCount)}
</span> </span>
</span> </span>
</TabsTrigger> </TabsTrigger>
@ -910,7 +911,7 @@ export function InboxSidebar({
<History className="h-4 w-4" /> <History className="h-4 w-4" />
<span>{t("status") || "Status"}</span> <span>{t("status") || "Status"}</span>
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium"> <span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
{formatInboxCount(unreadStatusCount)} {formatInboxCount(status.unreadCount)}
</span> </span>
</span> </span>
</TabsTrigger> </TabsTrigger>
@ -918,11 +919,10 @@ export function InboxSidebar({
</Tabs> </Tabs>
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2"> <div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
{(isSearchMode ? isSearchLoading : loading) ? ( {isLoading ? (
<div className="space-y-2"> <div className="space-y-2">
{activeTab === "comments" {activeTab === "comments"
? /* Comments skeleton: avatar + two-line text + time */ ? [85, 60, 90, 70, 50, 75].map((titleWidth, i) => (
[85, 60, 90, 70, 50, 75].map((titleWidth, i) => (
<div <div
key={`skeleton-comment-${i}`} key={`skeleton-comment-${i}`}
className="flex items-center gap-3 rounded-lg px-3 py-3 h-[80px]" className="flex items-center gap-3 rounded-lg px-3 py-3 h-[80px]"
@ -935,8 +935,7 @@ export function InboxSidebar({
<Skeleton className="h-3 w-6 shrink-0 rounded" /> <Skeleton className="h-3 w-6 shrink-0 rounded" />
</div> </div>
)) ))
: /* Status skeleton: status icon circle + two-line text + time */ : [75, 90, 55, 80, 65, 85].map((titleWidth, i) => (
[75, 90, 55, 80, 65, 85].map((titleWidth, i) => (
<div <div
key={`skeleton-status-${i}`} key={`skeleton-status-${i}`}
className="flex items-center gap-3 rounded-lg px-3 py-3 h-[80px]" className="flex items-center gap-3 rounded-lg px-3 py-3 h-[80px]"
@ -957,9 +956,8 @@ export function InboxSidebar({
<div className="space-y-2"> <div className="space-y-2">
{filteredItems.map((item, index) => { {filteredItems.map((item, index) => {
const isMarkingAsRead = markingAsReadId === item.id; const isMarkingAsRead = markingAsReadId === item.id;
// Place prefetch trigger on 5th item from end (only when not searching)
const isPrefetchTrigger = const isPrefetchTrigger =
!isSearchMode && hasMore && index === filteredItems.length - 5; !isSearchMode && activeSource.hasMore && index === filteredItems.length - 5;
return ( return (
<div <div
@ -1028,7 +1026,6 @@ export function InboxSidebar({
</Tooltip> </Tooltip>
)} )}
{/* Time and unread dot - fixed width to prevent content shift */}
<div className="flex items-center justify-end gap-1.5 shrink-0 w-10"> <div className="flex items-center justify-end gap-1.5 shrink-0 w-10">
<span className="text-[10px] text-muted-foreground"> <span className="text-[10px] text-muted-foreground">
{formatTime(item.created_at)} {formatTime(item.created_at)}
@ -1038,12 +1035,10 @@ export function InboxSidebar({
</div> </div>
); );
})} })}
{/* Fallback trigger at the very end if less than 5 items and not searching */} {!isSearchMode && filteredItems.length < 5 && activeSource.hasMore && (
{!isSearchMode && filteredItems.length < 5 && hasMore && (
<div ref={prefetchTriggerRef} className="h-1" /> <div ref={prefetchTriggerRef} className="h-1" />
)} )}
{/* Loading more skeletons at the bottom during infinite scroll */} {activeSource.loadingMore &&
{loadingMore &&
(activeTab === "comments" (activeTab === "comments"
? [80, 60, 90].map((titleWidth, i) => ( ? [80, 60, 90].map((titleWidth, i) => (
<div <div
@ -1100,11 +1095,10 @@ export function InboxSidebar({
</> </>
); );
// DOCKED MODE: Render as a static flex child (no animation, no click-away)
if (isDocked && open && !isMobile) { if (isDocked && open && !isMobile) {
return ( return (
<aside <aside
className="h-full w-[360px] shrink-0 bg-background flex flex-col border-r" className="h-full w-[360px] shrink-0 bg-sidebar text-sidebar-foreground flex flex-col border-r"
aria-label={t("inbox") || "Inbox"} aria-label={t("inbox") || "Inbox"}
> >
{inboxContent} {inboxContent}
@ -1112,7 +1106,6 @@ export function InboxSidebar({
); );
} }
// FLOATING MODE: Render with animation and click-away layer
return ( return (
<SidebarSlideOutPanel open={open} onOpenChange={onOpenChange} ariaLabel={t("inbox") || "Inbox"}> <SidebarSlideOutPanel open={open} onOpenChange={onOpenChange} ariaLabel={t("inbox") || "Inbox"}>
{inboxContent} {inboxContent}

View file

@ -166,9 +166,30 @@ export function MobileSidebar({
: undefined : undefined
} }
user={user} user={user}
onSettings={onSettings} onSettings={
onManageMembers={onManageMembers} onSettings
onUserSettings={onUserSettings} ? () => {
onOpenChange(false);
onSettings();
}
: undefined
}
onManageMembers={
onManageMembers
? () => {
onOpenChange(false);
onManageMembers();
}
: undefined
}
onUserSettings={
onUserSettings
? () => {
onOpenChange(false);
onUserSettings();
}
: undefined
}
onLogout={onLogout} onLogout={onLogout}
pageUsage={pageUsage} pageUsage={pageUsage}
theme={theme} theme={theme}

View file

@ -1,5 +1,7 @@
"use client"; "use client";
import { CheckCircle2, CircleAlert } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { NavItem } from "../../types/layout.types"; import type { NavItem } from "../../types/layout.types";
@ -10,13 +12,67 @@ interface NavSectionProps {
isCollapsed?: boolean; isCollapsed?: boolean;
} }
function StatusBadge({ status }: { status: NavItem["statusIndicator"] }) {
if (status === "processing") {
return (
<span className="absolute top-0.5 right-0.5 inline-flex items-center justify-center h-[14px] w-[14px] rounded-full bg-primary/15">
<Spinner size="xs" className="text-primary" />
</span>
);
}
if (status === "success") {
return (
<span className="absolute top-0.5 right-0.5 inline-flex items-center justify-center h-[14px] w-[14px] rounded-full bg-emerald-500/15 animate-in fade-in duration-300">
<CheckCircle2 className="h-[10px] w-[10px] text-emerald-500" />
</span>
);
}
if (status === "error") {
return (
<span className="absolute top-0.5 right-0.5 inline-flex items-center justify-center h-[14px] w-[14px] rounded-full bg-destructive/15 animate-in fade-in duration-300">
<CircleAlert className="h-[10px] w-[10px] text-destructive" />
</span>
);
}
return null;
}
function StatusIcon({
status,
FallbackIcon,
className,
}: {
status: NavItem["statusIndicator"];
FallbackIcon: NavItem["icon"];
className?: string;
}) {
if (status === "processing") {
return <Spinner size="sm" className={cn("shrink-0 text-primary", className)} />;
}
if (status === "success") {
return (
<CheckCircle2
className={cn("shrink-0 text-emerald-500 animate-in fade-in duration-300", className)}
/>
);
}
if (status === "error") {
return (
<CircleAlert
className={cn("shrink-0 text-destructive animate-in fade-in duration-300", className)}
/>
);
}
return <FallbackIcon className={cn("shrink-0", className)} />;
}
export function NavSection({ items, onItemClick, isCollapsed = false }: NavSectionProps) { export function NavSection({ items, onItemClick, isCollapsed = false }: NavSectionProps) {
return ( return (
<div className={cn("flex flex-col gap-0.5 py-2", isCollapsed && "items-center")}> <div className={cn("flex flex-col gap-0.5 py-2", isCollapsed && "items-center")}>
{items.map((item) => { {items.map((item) => {
const Icon = item.icon; const Icon = item.icon;
const indicator = item.statusIndicator;
// Add data-joyride for onboarding tour
const joyrideAttr = const joyrideAttr =
item.title === "Documents" || item.title.toLowerCase().includes("documents") item.title === "Documents" || item.title.toLowerCase().includes("documents")
? { "data-joyride": "documents-sidebar" } ? { "data-joyride": "documents-sidebar" }
@ -39,11 +95,13 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
{...joyrideAttr} {...joyrideAttr}
> >
<Icon className="h-4 w-4" /> <Icon className="h-4 w-4" />
{item.badge && ( {indicator && indicator !== "idle" ? (
<StatusBadge status={indicator} />
) : item.badge ? (
<span className="absolute top-0.5 right-0.5 inline-flex items-center justify-center min-w-[14px] h-[14px] px-0.5 rounded-full bg-red-500 text-white text-[9px] font-medium"> <span className="absolute top-0.5 right-0.5 inline-flex items-center justify-center min-w-[14px] h-[14px] px-0.5 rounded-full bg-red-500 text-white text-[9px] font-medium">
{item.badge} {item.badge}
</span> </span>
)} ) : null}
<span className="sr-only">{item.title}</span> <span className="sr-only">{item.title}</span>
</button> </button>
</TooltipTrigger> </TooltipTrigger>
@ -67,7 +125,7 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
)} )}
{...joyrideAttr} {...joyrideAttr}
> >
<Icon className="h-4 w-4 shrink-0" /> <StatusIcon status={indicator} FallbackIcon={Icon} className="h-4 w-4" />
<span className="flex-1 truncate">{item.title}</span> <span className="flex-1 truncate">{item.title}</span>
{item.badge && ( {item.badge && (
<span className="inline-flex items-center justify-center min-w-4 h-4 px-1 rounded-full bg-red-500 text-white text-[10px] font-medium"> <span className="inline-flex items-center justify-center min-w-4 h-4 px-1 rounded-full bg-red-500 text-white text-[10px] font-medium">

View file

@ -3,6 +3,7 @@
import { PanelLeft, PanelLeftClose } from "lucide-react"; import { PanelLeft, PanelLeftClose } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ShortcutKbd } from "@/components/ui/shortcut-kbd";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { usePlatformShortcut } from "@/hooks/use-platform-shortcut"; import { usePlatformShortcut } from "@/hooks/use-platform-shortcut";
@ -18,7 +19,7 @@ export function SidebarCollapseButton({
disableTooltip = false, disableTooltip = false,
}: SidebarCollapseButtonProps) { }: SidebarCollapseButtonProps) {
const t = useTranslations("sidebar"); const t = useTranslations("sidebar");
const { shortcut } = usePlatformShortcut(); const { shortcutKeys } = usePlatformShortcut();
const button = ( const button = (
<Button variant="ghost" size="icon" onClick={onToggle} className="h-8 w-8 shrink-0"> <Button variant="ghost" size="icon" onClick={onToggle} className="h-8 w-8 shrink-0">
@ -35,9 +36,10 @@ export function SidebarCollapseButton({
<Tooltip> <Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger> <TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent side={isCollapsed ? "right" : "bottom"}> <TooltipContent side={isCollapsed ? "right" : "bottom"}>
{isCollapsed <span className="flex items-center">
? `${t("expand_sidebar")} ${shortcut("Mod", "\\")}` {isCollapsed ? t("expand_sidebar") : t("collapse_sidebar")}
: `${t("collapse_sidebar")} ${shortcut("Mod", "\\")}`} <ShortcutKbd keys={shortcutKeys("Mod", "\\")} />
</span>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
); );

View file

@ -8,7 +8,6 @@ import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -56,7 +55,6 @@ export function SidebarHeader({
<UserPen className="h-4 w-4" /> <UserPen className="h-4 w-4" />
{t("manage_members")} {t("manage_members")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onSettings}> <DropdownMenuItem onClick={onSettings}>
<Settings className="h-4 w-4" /> <Settings className="h-4 w-4" />
{t("search_space_settings")} {t("search_space_settings")}

View file

@ -65,7 +65,7 @@ export function SidebarSlideOutPanel({
exit={{ x: "-100%" }} exit={{ x: "-100%" }}
transition={{ type: "tween", duration: 0.2, ease: [0.4, 0, 0.2, 1] }} transition={{ type: "tween", duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
className={cn( className={cn(
"h-full w-full bg-background flex flex-col pointer-events-auto select-none", "h-full w-full bg-sidebar text-sidebar-foreground flex flex-col pointer-events-auto select-none",
"sm:border-r sm:shadow-xl" "sm:border-r sm:shadow-xl"
)} )}
role="dialog" role="dialog"

View file

@ -1,6 +1,18 @@
"use client"; "use client";
import { Check, ChevronUp, Languages, Laptop, LogOut, Moon, Settings, Sun } from "lucide-react"; import {
Check,
ChevronUp,
ExternalLink,
Info,
Languages,
Laptop,
LogOut,
Moon,
Settings,
Sun,
} from "lucide-react";
import Image from "next/image";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useState } from "react"; import { useState } from "react";
import { import {
@ -16,8 +28,8 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useLocaleContext } from "@/contexts/LocaleContext"; import { useLocaleContext } from "@/contexts/LocaleContext";
import { APP_VERSION } from "@/lib/env-config";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { User } from "../../types/layout.types"; import type { User } from "../../types/layout.types";
@ -37,6 +49,11 @@ const THEMES = [
{ value: "system" as const, name: "System", icon: Laptop }, { value: "system" as const, name: "System", icon: Laptop },
]; ];
const LEARN_MORE_LINKS = [
{ key: "documentation" as const, href: "https://surfsense.com/docs" },
{ key: "github" as const, href: "https://github.com/MODSetter/SurfSense" },
];
interface SidebarUserProfileProps { interface SidebarUserProfileProps {
user: User; user: User;
onUserSettings?: () => void; onUserSettings?: () => void;
@ -100,11 +117,14 @@ function UserAvatar({
}) { }) {
if (avatarUrl) { if (avatarUrl) {
return ( return (
<img <Image
src={avatarUrl} src={avatarUrl}
alt="User avatar" alt="User avatar"
width={32}
height={32}
className="h-8 w-8 shrink-0 rounded-lg object-cover" className="h-8 w-8 shrink-0 rounded-lg object-cover"
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
unoptimized
/> />
); );
} }
@ -157,25 +177,20 @@ export function SidebarUserProfile({
return ( return (
<div className="border-t p-2"> <div className="border-t p-2">
<DropdownMenu> <DropdownMenu>
<Tooltip> <DropdownMenuTrigger asChild>
<TooltipTrigger asChild> <button
<DropdownMenuTrigger asChild> type="button"
<button className={cn(
type="button" "flex h-10 w-full items-center justify-center rounded-md",
className={cn( "hover:bg-accent transition-colors",
"flex h-10 w-full items-center justify-center rounded-md", "focus:outline-none focus-visible:outline-none",
"hover:bg-accent transition-colors", "data-[state=open]:bg-transparent"
"focus:outline-none focus-visible:outline-none", )}
"data-[state=open]:bg-transparent" >
)} <UserAvatar avatarUrl={user.avatarUrl} initials={initials} bgColor={bgColor} />
> <span className="sr-only">{displayName}</span>
<UserAvatar avatarUrl={user.avatarUrl} initials={initials} bgColor={bgColor} /> </button>
<span className="sr-only">{displayName}</span> </DropdownMenuTrigger>
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="right">{displayName}</TooltipContent>
</Tooltip>
<DropdownMenuContent className="w-48" side="right" align="center" sideOffset={8}> <DropdownMenuContent className="w-48" side="right" align="center" sideOffset={8}>
<DropdownMenuLabel className="font-normal"> <DropdownMenuLabel className="font-normal">
@ -188,7 +203,7 @@ export function SidebarUserProfile({
</div> </div>
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator className="dark:bg-neutral-700" />
<DropdownMenuItem onClick={onUserSettings}> <DropdownMenuItem onClick={onUserSettings}>
<Settings className="h-4 w-4" /> <Settings className="h-4 w-4" />
@ -256,7 +271,30 @@ export function SidebarUserProfile({
</DropdownMenuPortal> </DropdownMenuPortal>
</DropdownMenuSub> </DropdownMenuSub>
<DropdownMenuSeparator /> <DropdownMenuSub>
<DropdownMenuSubTrigger>
<Info className="h-4 w-4" />
{t("learn_more")}
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent className="min-w-[180px] gap-1">
{LEARN_MORE_LINKS.map((link) => (
<DropdownMenuItem key={link.key} asChild>
<a href={link.href} target="_blank" rel="noopener noreferrer">
<span className="flex-1">{t(link.key)}</span>
<ExternalLink className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</a>
</DropdownMenuItem>
))}
<DropdownMenuSeparator className="dark:bg-neutral-700" />
<p className="select-none px-2 py-1.5 text-xs text-muted-foreground/50">
v{APP_VERSION}
</p>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
<DropdownMenuSeparator className="dark:bg-neutral-700" />
<DropdownMenuItem onClick={handleLogout} disabled={isLoggingOut}> <DropdownMenuItem onClick={handleLogout} disabled={isLoggingOut}>
{isLoggingOut ? ( {isLoggingOut ? (
@ -310,7 +348,7 @@ export function SidebarUserProfile({
</div> </div>
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator className="dark:bg-neutral-700" />
<DropdownMenuItem onClick={onUserSettings}> <DropdownMenuItem onClick={onUserSettings}>
<Settings className="h-4 w-4" /> <Settings className="h-4 w-4" />
@ -378,7 +416,30 @@ export function SidebarUserProfile({
</DropdownMenuPortal> </DropdownMenuPortal>
</DropdownMenuSub> </DropdownMenuSub>
<DropdownMenuSeparator /> <DropdownMenuSub>
<DropdownMenuSubTrigger>
<Info className="h-4 w-4" />
{t("learn_more")}
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent className="min-w-[180px] gap-1">
{LEARN_MORE_LINKS.map((link) => (
<DropdownMenuItem key={link.key} asChild>
<a href={link.href} target="_blank" rel="noopener noreferrer">
<span className="flex-1">{t(link.key)}</span>
<ExternalLink className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</a>
</DropdownMenuItem>
))}
<DropdownMenuSeparator className="dark:bg-neutral-700" />
<p className="select-none px-2 py-1.5 text-xs text-muted-foreground/50">
v{APP_VERSION}
</p>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
<DropdownMenuSeparator className="dark:bg-neutral-700" />
<DropdownMenuItem onClick={handleLogout} disabled={isLoggingOut}> <DropdownMenuItem onClick={handleLogout} disabled={isLoggingOut}>
{isLoggingOut ? <Spinner size="sm" className="mr-2" /> : <LogOut className="h-4 w-4" />} {isLoggingOut ? <Spinner size="sm" className="mr-2" /> : <LogOut className="h-4 w-4" />}

View file

@ -2,6 +2,7 @@ export { AllPrivateChatsSidebar } from "./AllPrivateChatsSidebar";
export { AllSharedChatsSidebar } from "./AllSharedChatsSidebar"; export { AllSharedChatsSidebar } from "./AllSharedChatsSidebar";
export { AnnouncementsSidebar } from "./AnnouncementsSidebar"; export { AnnouncementsSidebar } from "./AnnouncementsSidebar";
export { ChatListItem } from "./ChatListItem"; export { ChatListItem } from "./ChatListItem";
export { DocumentsSidebar } from "./DocumentsSidebar";
export { InboxSidebar } from "./InboxSidebar"; export { InboxSidebar } from "./InboxSidebar";
export { MobileSidebar, MobileSidebarTrigger } from "./MobileSidebar"; export { MobileSidebar, MobileSidebarTrigger } from "./MobileSidebar";
export { NavSection } from "./NavSection"; export { NavSection } from "./NavSection";

View file

@ -7,38 +7,39 @@ import type {
ImageGenerationConfig, ImageGenerationConfig,
NewLLMConfigPublic, NewLLMConfigPublic,
} from "@/contracts/types/new-llm-config.types"; } from "@/contracts/types/new-llm-config.types";
import { ImageConfigSidebar } from "./image-config-sidebar"; import { ImageConfigDialog } from "./image-config-dialog";
import { ModelConfigSidebar } from "./model-config-sidebar"; import { ModelConfigDialog } from "./model-config-dialog";
import { ModelSelector } from "./model-selector"; import { ModelSelector } from "./model-selector";
interface ChatHeaderProps { interface ChatHeaderProps {
searchSpaceId: number; searchSpaceId: number;
className?: string;
} }
export function ChatHeader({ searchSpaceId }: ChatHeaderProps) { export function ChatHeader({ searchSpaceId, className }: ChatHeaderProps) {
// LLM config sidebar state // LLM config dialog state
const [sidebarOpen, setSidebarOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [selectedConfig, setSelectedConfig] = useState< const [selectedConfig, setSelectedConfig] = useState<
NewLLMConfigPublic | GlobalNewLLMConfig | null NewLLMConfigPublic | GlobalNewLLMConfig | null
>(null); >(null);
const [isGlobal, setIsGlobal] = useState(false); const [isGlobal, setIsGlobal] = useState(false);
const [sidebarMode, setSidebarMode] = useState<"create" | "edit" | "view">("view"); const [dialogMode, setDialogMode] = useState<"create" | "edit" | "view">("view");
// Image config sidebar state // Image config dialog state
const [imageSidebarOpen, setImageSidebarOpen] = useState(false); const [imageDialogOpen, setImageDialogOpen] = useState(false);
const [selectedImageConfig, setSelectedImageConfig] = useState< const [selectedImageConfig, setSelectedImageConfig] = useState<
ImageGenerationConfig | GlobalImageGenConfig | null ImageGenerationConfig | GlobalImageGenConfig | null
>(null); >(null);
const [isImageGlobal, setIsImageGlobal] = useState(false); const [isImageGlobal, setIsImageGlobal] = useState(false);
const [imageSidebarMode, setImageSidebarMode] = useState<"create" | "edit" | "view">("view"); const [imageDialogMode, setImageDialogMode] = useState<"create" | "edit" | "view">("view");
// LLM handlers // LLM handlers
const handleEditLLMConfig = useCallback( const handleEditLLMConfig = useCallback(
(config: NewLLMConfigPublic | GlobalNewLLMConfig, global: boolean) => { (config: NewLLMConfigPublic | GlobalNewLLMConfig, global: boolean) => {
setSelectedConfig(config); setSelectedConfig(config);
setIsGlobal(global); setIsGlobal(global);
setSidebarMode(global ? "view" : "edit"); setDialogMode(global ? "view" : "edit");
setSidebarOpen(true); setDialogOpen(true);
}, },
[] []
); );
@ -46,12 +47,12 @@ export function ChatHeader({ searchSpaceId }: ChatHeaderProps) {
const handleAddNewLLM = useCallback(() => { const handleAddNewLLM = useCallback(() => {
setSelectedConfig(null); setSelectedConfig(null);
setIsGlobal(false); setIsGlobal(false);
setSidebarMode("create"); setDialogMode("create");
setSidebarOpen(true); setDialogOpen(true);
}, []); }, []);
const handleSidebarClose = useCallback((open: boolean) => { const handleDialogClose = useCallback((open: boolean) => {
setSidebarOpen(open); setDialogOpen(open);
if (!open) setSelectedConfig(null); if (!open) setSelectedConfig(null);
}, []); }, []);
@ -59,22 +60,22 @@ export function ChatHeader({ searchSpaceId }: ChatHeaderProps) {
const handleAddImageModel = useCallback(() => { const handleAddImageModel = useCallback(() => {
setSelectedImageConfig(null); setSelectedImageConfig(null);
setIsImageGlobal(false); setIsImageGlobal(false);
setImageSidebarMode("create"); setImageDialogMode("create");
setImageSidebarOpen(true); setImageDialogOpen(true);
}, []); }, []);
const handleEditImageConfig = useCallback( const handleEditImageConfig = useCallback(
(config: ImageGenerationConfig | GlobalImageGenConfig, global: boolean) => { (config: ImageGenerationConfig | GlobalImageGenConfig, global: boolean) => {
setSelectedImageConfig(config); setSelectedImageConfig(config);
setIsImageGlobal(global); setIsImageGlobal(global);
setImageSidebarMode(global ? "view" : "edit"); setImageDialogMode(global ? "view" : "edit");
setImageSidebarOpen(true); setImageDialogOpen(true);
}, },
[] []
); );
const handleImageSidebarClose = useCallback((open: boolean) => { const handleImageDialogClose = useCallback((open: boolean) => {
setImageSidebarOpen(open); setImageDialogOpen(open);
if (!open) setSelectedImageConfig(null); if (!open) setSelectedImageConfig(null);
}, []); }, []);
@ -85,22 +86,23 @@ export function ChatHeader({ searchSpaceId }: ChatHeaderProps) {
onAddNewLLM={handleAddNewLLM} onAddNewLLM={handleAddNewLLM}
onEditImage={handleEditImageConfig} onEditImage={handleEditImageConfig}
onAddNewImage={handleAddImageModel} onAddNewImage={handleAddImageModel}
className={className}
/> />
<ModelConfigSidebar <ModelConfigDialog
open={sidebarOpen} open={dialogOpen}
onOpenChange={handleSidebarClose} onOpenChange={handleDialogClose}
config={selectedConfig} config={selectedConfig}
isGlobal={isGlobal} isGlobal={isGlobal}
searchSpaceId={searchSpaceId} searchSpaceId={searchSpaceId}
mode={sidebarMode} mode={dialogMode}
/> />
<ImageConfigSidebar <ImageConfigDialog
open={imageSidebarOpen} open={imageDialogOpen}
onOpenChange={handleImageSidebarClose} onOpenChange={handleImageDialogClose}
config={selectedImageConfig} config={selectedImageConfig}
isGlobal={isImageGlobal} isGlobal={isImageGlobal}
searchSpaceId={searchSpaceId} searchSpaceId={searchSpaceId}
mode={imageSidebarMode} mode={imageDialogMode}
/> />
</div> </div>
); );

View file

@ -72,12 +72,15 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
// Query to check if thread has public snapshots // Query to check if thread has public snapshots
const { data: snapshotsData } = useQuery({ const { data: snapshotsData } = useQuery({
queryKey: ["thread-snapshots", thread?.id], queryKey: ["thread-snapshots", thread?.id],
queryFn: () => chatThreadsApiService.listPublicChatSnapshots({ thread_id: thread!.id }), queryFn: () => {
const id = thread?.id;
if (id == null) throw new Error("Missing thread id");
return chatThreadsApiService.listPublicChatSnapshots({ thread_id: id });
},
enabled: !!thread?.id, enabled: !!thread?.id,
staleTime: 30000, // Cache for 30 seconds staleTime: 30000, // Cache for 30 seconds
}); });
const hasPublicSnapshots = (snapshotsData?.snapshots?.length ?? 0) > 0; const hasPublicSnapshots = (snapshotsData?.snapshots?.length ?? 0) > 0;
const snapshotCount = snapshotsData?.snapshots?.length ?? 0;
// Use Jotai visibility if available (synced from chat page), otherwise fall back to thread prop // Use Jotai visibility if available (synced from chat page), otherwise fall back to thread prop
const currentVisibility = currentThreadState.visibility ?? thread?.visibility ?? "PRIVATE"; const currentVisibility = currentThreadState.visibility ?? thread?.visibility ?? "PRIVATE";
@ -145,18 +148,14 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
<button <button
type="button" type="button"
onClick={() => onClick={() =>
router.push(`/dashboard/${params.search_space_id}/settings?section=public-links`) router.push(`/dashboard/${params.search_space_id}/settings?tab=public-links`)
} }
className="flex items-center justify-center h-8 w-8 rounded-md bg-muted/50 hover:bg-muted transition-colors" className="flex items-center justify-center h-8 w-8 rounded-md bg-muted/50 hover:bg-muted transition-colors"
> >
<Earth className="h-4 w-4 text-muted-foreground" /> <Earth className="h-4 w-4 text-muted-foreground" />
</button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>Manage public links</TooltipContent>
{snapshotCount === 1
? "This chat has a public link"
: `This chat has ${snapshotCount} public links`}
</TooltipContent>
</Tooltip> </Tooltip>
)} )}
@ -167,7 +166,7 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
<Button <Button
variant="outline" variant="outline"
size="icon" size="icon"
className="h-8 w-8 md:w-auto md:px-3 md:gap-2 relative bg-muted hover:bg-muted/80 border-0" className="h-8 w-8 md:w-auto md:px-3 md:gap-2 relative bg-muted hover:bg-muted/80 border-0 select-none"
> >
<CurrentIcon className="h-4 w-4" /> <CurrentIcon className="h-4 w-4" />
<span className="hidden md:inline text-sm">{buttonLabel}</span> <span className="hidden md:inline text-sm">{buttonLabel}</span>
@ -178,12 +177,12 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
</Tooltip> </Tooltip>
<PopoverContent <PopoverContent
className="w-[280px] md:w-[320px] p-0 rounded-lg shadow-lg border-border/60" className="w-[280px] md:w-[320px] p-0 rounded-lg shadow-lg border-border/60 dark:bg-neutral-900 dark:border dark:border-white/5 select-none"
align="end" align="end"
sideOffset={8} sideOffset={8}
onCloseAutoFocus={(e) => e.preventDefault()} onCloseAutoFocus={(e) => e.preventDefault()}
> >
<div className="p-1.5 space-y-1 select-none"> <div className="p-1.5 space-y-1">
{/* Visibility Options */} {/* Visibility Options */}
{visibilityOptions.map((option) => { {visibilityOptions.map((option) => {
const isSelected = currentVisibility === option.value; const isSelected = currentVisibility === option.value;
@ -196,27 +195,32 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
onClick={() => handleVisibilityChange(option.value)} onClick={() => handleVisibilityChange(option.value)}
className={cn( className={cn(
"w-full flex items-center gap-2.5 px-2.5 py-2 rounded-md transition-all", "w-full flex items-center gap-2.5 px-2.5 py-2 rounded-md transition-all",
"hover:bg-accent/50 cursor-pointer", "hover:bg-accent/50 dark:hover:bg-white/10 cursor-pointer",
"focus:outline-none", "focus:outline-none",
isSelected && "bg-accent/80" isSelected && "bg-accent/80 dark:bg-white/10"
)} )}
> >
<div <div
className={cn( className={cn(
"size-7 rounded-md shrink-0 grid place-items-center", "size-7 rounded-md shrink-0 grid place-items-center",
isSelected ? "bg-primary/10" : "bg-muted" isSelected ? "bg-primary/10 dark:bg-white/10" : "bg-muted dark:bg-white/5"
)} )}
> >
<Icon <Icon
className={cn( className={cn(
"size-4 block", "size-4 block",
isSelected ? "text-primary" : "text-muted-foreground" isSelected ? "text-primary dark:text-white" : "text-muted-foreground"
)} )}
/> />
</div> </div>
<div className="flex-1 text-left min-w-0"> <div className="flex-1 text-left min-w-0">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className={cn("text-sm font-medium", isSelected && "text-primary")}> <span
className={cn(
"text-sm font-medium",
isSelected && "text-primary dark:text-white"
)}
>
{option.label} {option.label}
</span> </span>
</div> </div>
@ -231,7 +235,7 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
{canCreatePublicLink && ( {canCreatePublicLink && (
<> <>
{/* Divider */} {/* Divider */}
<div className="border-t border-border my-1" /> <div className="border-t border-border dark:border-white/5 my-1" />
{/* Public Link Option */} {/* Public Link Option */}
<button <button
@ -240,12 +244,12 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
disabled={isCreatingSnapshot} disabled={isCreatingSnapshot}
className={cn( className={cn(
"w-full flex items-center gap-2.5 px-2.5 py-2 rounded-md transition-all", "w-full flex items-center gap-2.5 px-2.5 py-2 rounded-md transition-all",
"hover:bg-accent/50 cursor-pointer", "hover:bg-accent/50 dark:hover:bg-white/10 cursor-pointer",
"focus:outline-none", "focus:outline-none",
"disabled:opacity-50 disabled:cursor-not-allowed" "disabled:opacity-50 disabled:cursor-not-allowed"
)} )}
> >
<div className="size-7 rounded-md shrink-0 grid place-items-center bg-muted"> <div className="size-7 rounded-md shrink-0 grid place-items-center bg-muted dark:bg-white/5">
<Earth className="size-4 block text-muted-foreground" /> <Earth className="size-4 block text-muted-foreground" />
</div> </div>
<div className="flex-1 text-left min-w-0"> <div className="flex-1 text-left min-w-0">

View file

@ -396,7 +396,7 @@ export const DocumentMentionPicker = forwardRef<
return ( return (
<div <div
className="fixed shadow-2xl rounded-lg border border-border overflow-hidden bg-popover flex flex-col w-[280px] sm:w-[320px]" className="fixed shadow-2xl rounded-lg border border-border dark:border-white/5 overflow-hidden bg-popover dark:bg-neutral-900 flex flex-col w-[280px] sm:w-[320px] select-none"
style={{ style={{
zIndex: 9999, zIndex: 9999,
...containerStyle, ...containerStyle,
@ -486,6 +486,9 @@ export const DocumentMentionPicker = forwardRef<
{/* User Documents */} {/* User Documents */}
{userDocsList.length > 0 && ( {userDocsList.length > 0 && (
<> <>
{surfsenseDocsList.length > 0 && (
<div className="mx-2 my-4 border-t border-border dark:border-white/5" />
)}
<div className="px-3 py-2 text-xs font-bold text-muted-foreground/55"> <div className="px-3 py-2 text-xs font-bold text-muted-foreground/55">
Your Documents Your Documents
</div> </div>

View file

@ -1,19 +1,9 @@
"use client"; "use client";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { import { AlertCircle, Check, ChevronsUpDown, X } from "lucide-react";
AlertCircle,
Check,
ChevronsUpDown,
Globe,
ImageIcon,
Key,
Shuffle,
X,
Zap,
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react"; import { AnimatePresence, motion } from "motion/react";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
@ -48,10 +38,11 @@ import { IMAGE_GEN_MODELS, IMAGE_GEN_PROVIDERS } from "@/contracts/enums/image-g
import type { import type {
GlobalImageGenConfig, GlobalImageGenConfig,
ImageGenerationConfig, ImageGenerationConfig,
ImageGenProvider,
} from "@/contracts/types/new-llm-config.types"; } from "@/contracts/types/new-llm-config.types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface ImageConfigSidebarProps { interface ImageConfigDialogProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
config: ImageGenerationConfig | GlobalImageGenConfig | null; config: ImageGenerationConfig | GlobalImageGenConfig | null;
@ -70,24 +61,25 @@ const INITIAL_FORM = {
api_version: "", api_version: "",
}; };
export function ImageConfigSidebar({ export function ImageConfigDialog({
open, open,
onOpenChange, onOpenChange,
config, config,
isGlobal, isGlobal,
searchSpaceId, searchSpaceId,
mode, mode,
}: ImageConfigSidebarProps) { }: ImageConfigDialogProps) {
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const [formData, setFormData] = useState(INITIAL_FORM); const [formData, setFormData] = useState(INITIAL_FORM);
const [modelComboboxOpen, setModelComboboxOpen] = useState(false); const [modelComboboxOpen, setModelComboboxOpen] = useState(false);
const [scrollPos, setScrollPos] = useState<"top" | "middle" | "bottom">("top");
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
setMounted(true); setMounted(true);
}, []); }, []);
// Reset form when opening
useEffect(() => { useEffect(() => {
if (open) { if (open) {
if (mode === "edit" && config && !isGlobal) { if (mode === "edit" && config && !isGlobal) {
@ -103,15 +95,14 @@ export function ImageConfigSidebar({
} else if (mode === "create") { } else if (mode === "create") {
setFormData(INITIAL_FORM); setFormData(INITIAL_FORM);
} }
setScrollPos("top");
} }
}, [open, mode, config, isGlobal]); }, [open, mode, config, isGlobal]);
// Mutations
const { mutateAsync: createConfig } = useAtomValue(createImageGenConfigMutationAtom); const { mutateAsync: createConfig } = useAtomValue(createImageGenConfigMutationAtom);
const { mutateAsync: updateConfig } = useAtomValue(updateImageGenConfigMutationAtom); const { mutateAsync: updateConfig } = useAtomValue(updateImageGenConfigMutationAtom);
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom); const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
// Escape key
useEffect(() => { useEffect(() => {
const handleEscape = (e: KeyboardEvent) => { const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape" && open) onOpenChange(false); if (e.key === "Escape" && open) onOpenChange(false);
@ -120,6 +111,13 @@ export function ImageConfigSidebar({
return () => window.removeEventListener("keydown", handleEscape); return () => window.removeEventListener("keydown", handleEscape);
}, [open, onOpenChange]); }, [open, onOpenChange]);
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
const el = e.currentTarget;
const atTop = el.scrollTop <= 2;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
setScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
}, []);
const isAutoMode = config && "is_auto_mode" in config && config.is_auto_mode; const isAutoMode = config && "is_auto_mode" in config && config.is_auto_mode;
const suggestedModels = useMemo(() => { const suggestedModels = useMemo(() => {
@ -134,13 +132,20 @@ export function ImageConfigSidebar({
return "Edit Image Model"; return "Edit Image Model";
}; };
const getSubtitle = () => {
if (mode === "create") return "Set up a new image generation provider";
if (isAutoMode) return "Automatically routes requests across providers";
if (isGlobal) return "Read-only global configuration";
return "Update your image model settings";
};
const handleSubmit = useCallback(async () => { const handleSubmit = useCallback(async () => {
setIsSubmitting(true); setIsSubmitting(true);
try { try {
if (mode === "create") { if (mode === "create") {
const result = await createConfig({ const result = await createConfig({
name: formData.name, name: formData.name,
provider: formData.provider, provider: formData.provider as ImageGenProvider,
model_name: formData.model_name, model_name: formData.model_name,
api_key: formData.api_key, api_key: formData.api_key,
api_base: formData.api_base || undefined, api_base: formData.api_base || undefined,
@ -148,7 +153,6 @@ export function ImageConfigSidebar({
description: formData.description || undefined, description: formData.description || undefined,
search_space_id: searchSpaceId, search_space_id: searchSpaceId,
}); });
// Set as active image model
if (result?.id) { if (result?.id) {
await updatePreferences({ await updatePreferences({
search_space_id: searchSpaceId, search_space_id: searchSpaceId,
@ -163,7 +167,7 @@ export function ImageConfigSidebar({
data: { data: {
name: formData.name, name: formData.name,
description: formData.description || undefined, description: formData.description || undefined,
provider: formData.provider, provider: formData.provider as ImageGenProvider,
model_name: formData.model_name, model_name: formData.model_name,
api_key: formData.api_key, api_key: formData.api_key,
api_base: formData.api_base || undefined, api_base: formData.api_base || undefined,
@ -214,126 +218,96 @@ export function ImageConfigSidebar({
if (!mounted) return null; if (!mounted) return null;
const sidebarContent = ( const dialogContent = (
<AnimatePresence> <AnimatePresence>
{open && ( {open && (
<> <>
{/* Backdrop */}
<motion.div <motion.div
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={{ duration: 0.2 }} transition={{ duration: 0.15 }}
className="fixed inset-0 z-50 bg-black/20 backdrop-blur-sm" className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm"
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
/> />
{/* Sidebar */}
<motion.div <motion.div
initial={{ x: "100%", opacity: 0 }} initial={{ opacity: 0, scale: 0.96 }}
animate={{ x: 0, opacity: 1 }} animate={{ opacity: 1, scale: 1 }}
exit={{ x: "100%", opacity: 0 }} exit={{ opacity: 0, scale: 0.96 }}
transition={{ type: "spring", damping: 30, stiffness: 300 }} transition={{ duration: 0.15, ease: "easeOut" }}
className={cn( className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6"
"fixed right-0 top-0 z-50 h-full w-full sm:w-[480px] lg:w-[540px]",
"bg-background border-l border-border/50 shadow-2xl",
"flex flex-col"
)}
> >
{/* Header */}
<div <div
role="dialog"
aria-modal="true"
className={cn( className={cn(
"flex items-center justify-between px-6 py-4 border-b border-border/50", "relative w-full max-w-lg h-[85vh]",
isAutoMode "rounded-xl bg-background shadow-2xl",
? "bg-gradient-to-r from-violet-500/10 to-purple-500/10" "dark:bg-neutral-900",
: "bg-gradient-to-r from-teal-500/10 to-cyan-500/10" "flex flex-col overflow-hidden"
)} )}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === "Escape") onOpenChange(false);
}}
> >
<div className="flex items-center gap-3"> {/* Header */}
<div <div className="flex items-start justify-between px-6 pt-6 pb-4">
className={cn( <div className="space-y-1 pr-8">
"flex items-center justify-center size-10 rounded-xl", <div className="flex items-center gap-2">
isAutoMode <h2 className="text-lg font-semibold tracking-tight">{getTitle()}</h2>
? "bg-gradient-to-br from-violet-500 to-purple-600" {isAutoMode && (
: "bg-gradient-to-br from-teal-500 to-cyan-600" <Badge variant="secondary" className="text-[10px]">
)}
>
{isAutoMode ? (
<Shuffle className="size-5 text-white" />
) : (
<ImageIcon className="size-5 text-white" />
)}
</div>
<div>
<h2 className="text-base sm:text-lg font-semibold">{getTitle()}</h2>
<div className="flex items-center gap-2 mt-0.5">
{isAutoMode ? (
<Badge
variant="secondary"
className="gap-1 text-xs bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300"
>
<Zap className="size-3" />
Recommended Recommended
</Badge> </Badge>
) : isGlobal ? ( )}
<Badge variant="secondary" className="gap-1 text-xs"> {isGlobal && !isAutoMode && mode !== "create" && (
<Globe className="size-3" /> <Badge variant="secondary" className="text-[10px]">
Global Global
</Badge> </Badge>
) : null}
{config && !isAutoMode && (
<span className="text-xs text-muted-foreground">{config.model_name}</span>
)} )}
</div> </div>
<p className="text-sm text-muted-foreground">{getSubtitle()}</p>
{config && !isAutoMode && mode !== "create" && (
<p className="text-xs font-mono text-muted-foreground/70">
{config.model_name}
</p>
)}
</div> </div>
<Button
variant="ghost"
size="icon"
onClick={() => onOpenChange(false)}
className="absolute right-4 top-4 h-8 w-8 rounded-full text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</Button>
</div> </div>
<Button
variant="ghost"
size="icon"
onClick={() => onOpenChange(false)}
className="h-8 w-8 rounded-full"
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</Button>
</div>
{/* Content */} {/* Scrollable content */}
<div className="flex-1 overflow-y-auto"> <div
<div className="p-6"> ref={scrollRef}
{/* Auto mode */} onScroll={handleScroll}
className="flex-1 overflow-y-auto px-6 py-5"
style={{
maskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
WebkitMaskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
}}
>
{isAutoMode && ( {isAutoMode && (
<> <Alert className="mb-5 border-violet-500/30 bg-violet-500/5">
<Alert className="mb-6 border-violet-500/30 bg-violet-500/5"> <AlertDescription className="text-sm text-violet-700 dark:text-violet-400">
<Shuffle className="size-4 text-violet-500" /> Auto mode distributes image generation requests across all configured
<AlertDescription className="text-sm text-violet-700 dark:text-violet-400"> providers for optimal performance and rate limit protection.
Auto mode distributes image generation requests across all configured </AlertDescription>
providers for optimal performance and rate limit protection. </Alert>
</AlertDescription>
</Alert>
<div className="flex gap-3 pt-4 border-t border-border/50">
<Button
variant="outline"
className="flex-1"
onClick={() => onOpenChange(false)}
>
Close
</Button>
<Button
className="flex-1 gap-2 bg-gradient-to-r from-violet-500 to-purple-600 hover:from-violet-600 hover:to-purple-700"
onClick={handleUseGlobalConfig}
disabled={isSubmitting}
>
{isSubmitting ? "Loading..." : "Use Auto Mode"}
</Button>
</div>
</>
)} )}
{/* Global config (read-only) */}
{isGlobal && !isAutoMode && config && ( {isGlobal && !isAutoMode && config && (
<> <>
<Alert className="mb-6 border-amber-500/30 bg-amber-500/5"> <Alert className="mb-5 border-amber-500/30 bg-amber-500/5">
<AlertCircle className="size-4 text-amber-500" /> <AlertCircle className="size-4 text-amber-500" />
<AlertDescription className="text-sm text-amber-700 dark:text-amber-400"> <AlertDescription className="text-sm text-amber-700 dark:text-amber-400">
Global configurations are read-only. To customize, create a new model. Global configurations are read-only. To customize, create a new model.
@ -372,29 +346,11 @@ export function ImageConfigSidebar({
</div> </div>
</div> </div>
</div> </div>
<div className="flex gap-3 pt-6 border-t border-border/50 mt-6">
<Button
variant="outline"
className="flex-1"
onClick={() => onOpenChange(false)}
>
Close
</Button>
<Button
className="flex-1 gap-2"
onClick={handleUseGlobalConfig}
disabled={isSubmitting}
>
{isSubmitting ? "Loading..." : "Use This Model"}
</Button>
</div>
</> </>
)} )}
{/* Create / Edit form */}
{(mode === "create" || (mode === "edit" && !isGlobal)) && ( {(mode === "create" || (mode === "edit" && !isGlobal)) && (
<div className="space-y-4"> <div className="space-y-4">
{/* Name */}
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm font-medium">Name *</Label> <Label className="text-sm font-medium">Name *</Label>
<Input <Input
@ -404,7 +360,6 @@ export function ImageConfigSidebar({
/> />
</div> </div>
{/* Description */}
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm font-medium">Description</Label> <Label className="text-sm font-medium">Description</Label>
<Input <Input
@ -418,7 +373,6 @@ export function ImageConfigSidebar({
<Separator /> <Separator />
{/* Provider */}
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm font-medium">Provider *</Label> <Label className="text-sm font-medium">Provider *</Label>
<Select <Select
@ -430,20 +384,16 @@ export function ImageConfigSidebar({
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select a provider" /> <SelectValue placeholder="Select a provider" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent className="bg-muted dark:border-neutral-700">
{IMAGE_GEN_PROVIDERS.map((p) => ( {IMAGE_GEN_PROVIDERS.map((p) => (
<SelectItem key={p.value} value={p.value}> <SelectItem key={p.value} value={p.value} description={p.example}>
<div className="flex flex-col"> {p.label}
<span className="font-medium">{p.label}</span>
<span className="text-xs text-muted-foreground">{p.example}</span>
</div>
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
{/* Model Name */}
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm font-medium">Model Name *</Label> <Label className="text-sm font-medium">Model Name *</Label>
{suggestedModels.length > 0 ? ( {suggestedModels.length > 0 ? (
@ -452,14 +402,17 @@ export function ImageConfigSidebar({
<Button <Button
variant="outline" variant="outline"
role="combobox" role="combobox"
className="w-full justify-between font-normal" className="w-full justify-between font-normal bg-transparent"
> >
{formData.model_name || "Select or type a model..."} {formData.model_name || "Select or type a model..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-full p-0" align="start"> <PopoverContent
<Command> className="w-full p-0 bg-muted dark:border-neutral-700"
align="start"
>
<Command className="bg-transparent">
<CommandInput <CommandInput
placeholder="Search or type model..." placeholder="Search or type model..."
value={formData.model_name} value={formData.model_name}
@ -513,11 +466,8 @@ export function ImageConfigSidebar({
)} )}
</div> </div>
{/* API Key */}
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm font-medium flex items-center gap-1.5"> <Label className="text-sm font-medium">API Key *</Label>
<Key className="h-3.5 w-3.5" /> API Key *
</Label>
<Input <Input
type="password" type="password"
placeholder="sk-..." placeholder="sk-..."
@ -526,7 +476,6 @@ export function ImageConfigSidebar({
/> />
</div> </div>
{/* API Base */}
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm font-medium">API Base URL</Label> <Label className="text-sm font-medium">API Base URL</Label>
<Input <Input
@ -536,7 +485,6 @@ export function ImageConfigSidebar({
/> />
</div> </div>
{/* Azure API Version */}
{formData.provider === "AZURE_OPENAI" && ( {formData.provider === "AZURE_OPENAI" && (
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm font-medium">API Version (Azure)</Label> <Label className="text-sm font-medium">API Version (Azure)</Label>
@ -549,28 +497,56 @@ export function ImageConfigSidebar({
/> />
</div> </div>
)} )}
{/* Actions */}
<div className="flex gap-3 pt-4 border-t">
<Button
variant="outline"
className="flex-1"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button
className="flex-1 gap-2 bg-gradient-to-r from-teal-500 to-cyan-600 hover:from-teal-600 hover:to-cyan-700"
onClick={handleSubmit}
disabled={isSubmitting || !isFormValid}
>
{isSubmitting ? <Spinner size="sm" className="mr-2" /> : null}
{mode === "edit" ? "Save Changes" : "Create & Use"}
</Button>
</div>
</div> </div>
)} )}
</div> </div>
{/* Fixed footer */}
<div className="shrink-0 px-6 py-4 flex items-center justify-end gap-3">
<Button
type="button"
variant="secondary"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
className="text-sm h-9"
>
Cancel
</Button>
{mode === "create" || (mode === "edit" && !isGlobal) ? (
<Button
onClick={handleSubmit}
disabled={isSubmitting || !isFormValid}
className="text-sm h-9 min-w-[120px]"
>
{isSubmitting ? (
<>
<Spinner size="sm" />
{mode === "edit" ? "Saving" : "Creating"}
</>
) : mode === "edit" ? (
"Save Changes"
) : (
"Create & Use"
)}
</Button>
) : isAutoMode ? (
<Button
className="text-sm h-9 gap-2 bg-gradient-to-r from-violet-500 to-purple-600 hover:from-violet-600 hover:to-purple-700"
onClick={handleUseGlobalConfig}
disabled={isSubmitting}
>
{isSubmitting ? "Loading..." : "Use Auto Mode"}
</Button>
) : isGlobal && config ? (
<Button
className="text-sm h-9 gap-2"
onClick={handleUseGlobalConfig}
disabled={isSubmitting}
>
{isSubmitting ? "Loading..." : "Use This Model"}
</Button>
) : null}
</div>
</div> </div>
</motion.div> </motion.div>
</> </>
@ -578,5 +554,5 @@ export function ImageConfigSidebar({
</AnimatePresence> </AnimatePresence>
); );
return typeof document !== "undefined" ? createPortal(sidebarContent, document.body) : null; return typeof document !== "undefined" ? createPortal(dialogContent, document.body) : null;
} }

View file

@ -1,9 +1,9 @@
"use client"; "use client";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { AlertCircle, Bot, ChevronRight, Globe, Shuffle, User, X, Zap } from "lucide-react"; import { AlertCircle, X, Zap } from "lucide-react";
import { AnimatePresence, motion } from "motion/react"; import { AnimatePresence, motion } from "motion/react";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
@ -15,13 +15,15 @@ import { LLMConfigForm, type LLMConfigFormData } from "@/components/shared/llm-c
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import type { import type {
GlobalNewLLMConfig, GlobalNewLLMConfig,
LiteLLMProvider,
NewLLMConfigPublic, NewLLMConfigPublic,
} from "@/contracts/types/new-llm-config.types"; } from "@/contracts/types/new-llm-config.types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface ModelConfigSidebarProps { interface ModelConfigDialogProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
config: NewLLMConfigPublic | GlobalNewLLMConfig | null; config: NewLLMConfigPublic | GlobalNewLLMConfig | null;
@ -30,28 +32,34 @@ interface ModelConfigSidebarProps {
mode: "create" | "edit" | "view"; mode: "create" | "edit" | "view";
} }
export function ModelConfigSidebar({ export function ModelConfigDialog({
open, open,
onOpenChange, onOpenChange,
config, config,
isGlobal, isGlobal,
searchSpaceId, searchSpaceId,
mode, mode,
}: ModelConfigSidebarProps) { }: ModelConfigDialogProps) {
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const [scrollPos, setScrollPos] = useState<"top" | "middle" | "bottom">("top");
const scrollRef = useRef<HTMLDivElement>(null);
// Handle SSR - only render portal on client
useEffect(() => { useEffect(() => {
setMounted(true); setMounted(true);
}, []); }, []);
// Mutations - use mutateAsync from the atom value const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
const el = e.currentTarget;
const atTop = el.scrollTop <= 2;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
setScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
}, []);
const { mutateAsync: createConfig } = useAtomValue(createNewLLMConfigMutationAtom); const { mutateAsync: createConfig } = useAtomValue(createNewLLMConfigMutationAtom);
const { mutateAsync: updateConfig } = useAtomValue(updateNewLLMConfigMutationAtom); const { mutateAsync: updateConfig } = useAtomValue(updateNewLLMConfigMutationAtom);
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom); const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
// Handle escape key
useEffect(() => { useEffect(() => {
const handleEscape = (e: KeyboardEvent) => { const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape" && open) { if (e.key === "Escape" && open) {
@ -62,10 +70,8 @@ export function ModelConfigSidebar({
return () => window.removeEventListener("keydown", handleEscape); return () => window.removeEventListener("keydown", handleEscape);
}, [open, onOpenChange]); }, [open, onOpenChange]);
// Check if this is Auto mode
const isAutoMode = config && "is_auto_mode" in config && config.is_auto_mode; const isAutoMode = config && "is_auto_mode" in config && config.is_auto_mode;
// Get title based on mode
const getTitle = () => { const getTitle = () => {
if (mode === "create") return "Add New Configuration"; if (mode === "create") return "Add New Configuration";
if (isAutoMode) return "Auto Mode (Fastest)"; if (isAutoMode) return "Auto Mode (Fastest)";
@ -73,19 +79,23 @@ export function ModelConfigSidebar({
return "Edit Configuration"; return "Edit Configuration";
}; };
// Handle form submit const getSubtitle = () => {
if (mode === "create") return "Set up a new LLM provider for this search space";
if (isAutoMode) return "Automatically routes requests across providers";
if (isGlobal) return "Read-only global configuration";
return "Update your configuration settings";
};
const handleSubmit = useCallback( const handleSubmit = useCallback(
async (data: LLMConfigFormData) => { async (data: LLMConfigFormData) => {
setIsSubmitting(true); setIsSubmitting(true);
try { try {
if (mode === "create") { if (mode === "create") {
// Create new config
const result = await createConfig({ const result = await createConfig({
...data, ...data,
search_space_id: searchSpaceId, search_space_id: searchSpaceId,
}); });
// Assign the new config to the agent role
if (result?.id) { if (result?.id) {
await updatePreferences({ await updatePreferences({
search_space_id: searchSpaceId, search_space_id: searchSpaceId,
@ -98,7 +108,6 @@ export function ModelConfigSidebar({
toast.success("Configuration created and assigned!"); toast.success("Configuration created and assigned!");
onOpenChange(false); onOpenChange(false);
} else if (!isGlobal && config) { } else if (!isGlobal && config) {
// Update existing user config
await updateConfig({ await updateConfig({
id: config.id, id: config.id,
data: { data: {
@ -137,7 +146,6 @@ export function ModelConfigSidebar({
] ]
); );
// Handle "Use this model" for global configs
const handleUseGlobalConfig = useCallback(async () => { const handleUseGlobalConfig = useCallback(async () => {
if (!config || !isGlobal) return; if (!config || !isGlobal) return;
setIsSubmitting(true); setIsSubmitting(true);
@ -160,7 +168,7 @@ export function ModelConfigSidebar({
if (!mounted) return null; if (!mounted) return null;
const sidebarContent = ( const dialogContent = (
<AnimatePresence> <AnimatePresence>
{open && ( {open && (
<> <>
@ -169,93 +177,84 @@ export function ModelConfigSidebar({
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={{ duration: 0.2 }} transition={{ duration: 0.15 }}
className="fixed inset-0 z-[24] bg-black/20 backdrop-blur-sm" className="fixed inset-0 z-[24] bg-black/50 backdrop-blur-sm"
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
/> />
{/* Sidebar Panel */} {/* Dialog */}
<motion.div <motion.div
initial={{ x: "100%", opacity: 0 }} initial={{ opacity: 0, scale: 0.96 }}
animate={{ x: 0, opacity: 1 }} animate={{ opacity: 1, scale: 1 }}
exit={{ x: "100%", opacity: 0 }} exit={{ opacity: 0, scale: 0.96 }}
transition={{ transition={{ duration: 0.15, ease: "easeOut" }}
type: "spring", className="fixed inset-0 z-[25] flex items-center justify-center p-4 sm:p-6"
damping: 30,
stiffness: 300,
}}
className={cn(
"fixed right-0 top-0 z-[25] h-full w-full sm:w-[480px] lg:w-[540px]",
"bg-background border-l border-border/50 shadow-2xl",
"flex flex-col"
)}
> >
{/* Header */}
<div <div
role="dialog"
aria-modal="true"
className={cn( className={cn(
"flex items-center justify-between px-6 py-4 border-b border-border/50", "relative w-full max-w-lg h-[85vh]",
isAutoMode ? "bg-gradient-to-r from-violet-500/10 to-purple-500/10" : "bg-muted/20" "rounded-xl bg-background shadow-2xl",
"dark:bg-neutral-900",
"flex flex-col overflow-hidden"
)} )}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === "Escape") onOpenChange(false);
}}
> >
<div className="flex items-center gap-3"> {/* Header */}
<div <div className="flex items-start justify-between px-6 pt-6 pb-4">
className={cn( <div className="space-y-1 pr-8">
"flex items-center justify-center size-10 rounded-xl", <div className="flex items-center gap-2">
isAutoMode ? "bg-gradient-to-br from-violet-500 to-purple-600" : "bg-primary/10" <h2 className="text-lg font-semibold tracking-tight">{getTitle()}</h2>
)} {isAutoMode && (
> <Badge variant="secondary" className="text-[10px]">
{isAutoMode ? (
<Shuffle className="size-5 text-white" />
) : (
<Bot className="size-5 text-primary" />
)}
</div>
<div>
<h2 className="text-base sm:text-lg font-semibold">{getTitle()}</h2>
<div className="flex items-center gap-2 mt-0.5">
{isAutoMode ? (
<Badge
variant="secondary"
className="gap-1 text-xs bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300"
>
<Zap className="size-3" />
Recommended Recommended
</Badge> </Badge>
) : isGlobal ? ( )}
<Badge variant="secondary" className="gap-1 text-xs"> {isGlobal && !isAutoMode && mode !== "create" && (
<Globe className="size-3" /> <Badge variant="secondary" className="text-[10px]">
Global Global
</Badge> </Badge>
) : mode !== "create" ? ( )}
<Badge variant="outline" className="gap-1 text-xs"> {!isGlobal && mode !== "create" && !isAutoMode && (
<User className="size-3" /> <Badge variant="outline" className="text-[10px]">
Custom Custom
</Badge> </Badge>
) : null}
{config && !isAutoMode && (
<span className="text-xs text-muted-foreground">{config.model_name}</span>
)} )}
</div> </div>
<p className="text-sm text-muted-foreground">{getSubtitle()}</p>
{config && !isAutoMode && mode !== "create" && (
<p className="text-xs font-mono text-muted-foreground/70">
{config.model_name}
</p>
)}
</div> </div>
<Button
variant="ghost"
size="icon"
onClick={() => onOpenChange(false)}
className="absolute right-4 top-4 h-8 w-8 rounded-full text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</Button>
</div> </div>
<Button
variant="ghost"
size="icon"
onClick={() => onOpenChange(false)}
className="h-8 w-8 rounded-full"
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</Button>
</div>
{/* Content - use overflow-y-auto instead of ScrollArea for better compatibility */} {/* Scrollable content */}
<div className="flex-1 overflow-y-auto"> <div
<div className="p-6"> ref={scrollRef}
{/* Auto mode info banner */} onScroll={handleScroll}
className="flex-1 overflow-y-auto px-6 py-5"
style={{
maskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
WebkitMaskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
}}
>
{isAutoMode && ( {isAutoMode && (
<Alert className="mb-6 border-violet-500/30 bg-violet-500/5"> <Alert className="mb-5 border-violet-500/30 bg-violet-500/5">
<Shuffle className="size-4 text-violet-500" />
<AlertDescription className="text-sm text-violet-700 dark:text-violet-400"> <AlertDescription className="text-sm text-violet-700 dark:text-violet-400">
Auto mode automatically distributes requests across all available LLM Auto mode automatically distributes requests across all available LLM
providers to optimize performance and avoid rate limits. providers to optimize performance and avoid rate limits.
@ -263,9 +262,8 @@ export function ModelConfigSidebar({
</Alert> </Alert>
)} )}
{/* Global config notice */}
{isGlobal && !isAutoMode && mode !== "create" && ( {isGlobal && !isAutoMode && mode !== "create" && (
<Alert className="mb-6 border-amber-500/30 bg-amber-500/5"> <Alert className="mb-5 border-amber-500/30 bg-amber-500/5">
<AlertCircle className="size-4 text-amber-500" /> <AlertCircle className="size-4 text-amber-500" />
<AlertDescription className="text-sm text-amber-700 dark:text-amber-400"> <AlertDescription className="text-sm text-amber-700 dark:text-amber-400">
Global configurations are read-only. To customize settings, create a new Global configurations are read-only. To customize settings, create a new
@ -274,20 +272,17 @@ export function ModelConfigSidebar({
</Alert> </Alert>
)} )}
{/* Form */}
{mode === "create" ? ( {mode === "create" ? (
<LLMConfigForm <LLMConfigForm
searchSpaceId={searchSpaceId} searchSpaceId={searchSpaceId}
onSubmit={handleSubmit} onSubmit={handleSubmit}
onCancel={() => onOpenChange(false)}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
mode="create" mode="create"
submitLabel="Create & Use" formId="model-config-form"
hideActions
/> />
) : isAutoMode && config ? ( ) : isAutoMode && config ? (
// Special view for Auto mode
<div className="space-y-6"> <div className="space-y-6">
{/* Auto Mode Features */}
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-1.5"> <div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider"> <div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
@ -339,36 +334,9 @@ export function ModelConfigSidebar({
</div> </div>
</div> </div>
</div> </div>
{/* Action Buttons */}
<div className="flex gap-3 pt-4 border-t border-border/50">
<Button
variant="outline"
className="flex-1"
onClick={() => onOpenChange(false)}
>
Close
</Button>
<Button
className="flex-1 gap-2 bg-gradient-to-r from-violet-500 to-purple-600 hover:from-violet-600 hover:to-purple-700"
onClick={handleUseGlobalConfig}
disabled={isSubmitting}
>
{isSubmitting ? (
<>Loading...</>
) : (
<>
<ChevronRight className="size-4" />
Use Auto Mode
</>
)}
</Button>
</div>
</div> </div>
) : isGlobal && config ? ( ) : isGlobal && config ? (
// Read-only view for global configs
<div className="space-y-6"> <div className="space-y-6">
{/* Config Details */}
<div className="space-y-4"> <div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5"> <div className="space-y-1.5">
@ -436,43 +404,17 @@ export function ModelConfigSidebar({
</> </>
)} )}
</div> </div>
{/* Action Buttons */}
<div className="flex gap-3 pt-4 border-t border-border/50">
<Button
variant="outline"
className="flex-1"
onClick={() => onOpenChange(false)}
>
Close
</Button>
<Button
className="flex-1 gap-2"
onClick={handleUseGlobalConfig}
disabled={isSubmitting}
>
{isSubmitting ? (
<>Loading...</>
) : (
<>
<ChevronRight className="size-4" />
Use This Model
</>
)}
</Button>
</div>
</div> </div>
) : config ? ( ) : config ? (
// Edit form for user configs
<LLMConfigForm <LLMConfigForm
searchSpaceId={searchSpaceId} searchSpaceId={searchSpaceId}
initialData={{ initialData={{
name: config.name, name: config.name,
description: config.description, description: config.description,
provider: config.provider, provider: config.provider as LiteLLMProvider,
custom_provider: config.custom_provider, custom_provider: config.custom_provider,
model_name: config.model_name, model_name: config.model_name,
api_key: config.api_key, api_key: "api_key" in config ? (config.api_key as string) : "",
api_base: config.api_base, api_base: config.api_base,
litellm_params: config.litellm_params, litellm_params: config.litellm_params,
system_instructions: config.system_instructions, system_instructions: config.system_instructions,
@ -481,13 +423,61 @@ export function ModelConfigSidebar({
search_space_id: searchSpaceId, search_space_id: searchSpaceId,
}} }}
onSubmit={handleSubmit} onSubmit={handleSubmit}
onCancel={() => onOpenChange(false)}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
mode="edit" mode="edit"
submitLabel="Save Changes" formId="model-config-form"
hideActions
/> />
) : null} ) : null}
</div> </div>
{/* Fixed footer */}
<div className="shrink-0 px-6 py-4 flex items-center justify-end gap-3">
<Button
type="button"
variant="secondary"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
className="text-sm h-9"
>
Cancel
</Button>
{mode === "create" || (!isGlobal && !isAutoMode && config) ? (
<Button
type="submit"
form="model-config-form"
disabled={isSubmitting}
className="text-sm h-9 min-w-[120px]"
>
{isSubmitting ? (
<>
<Spinner size="sm" />
{mode === "edit" ? "Saving" : "Creating"}
</>
) : mode === "edit" ? (
"Save Changes"
) : (
"Create & Use"
)}
</Button>
) : isAutoMode ? (
<Button
className="text-sm h-9 gap-2 bg-gradient-to-r from-violet-500 to-purple-600 hover:from-violet-600 hover:to-purple-700"
onClick={handleUseGlobalConfig}
disabled={isSubmitting}
>
{isSubmitting ? "Loading..." : "Use Auto Mode"}
</Button>
) : isGlobal && config ? (
<Button
className="text-sm h-9 gap-2"
onClick={handleUseGlobalConfig}
disabled={isSubmitting}
>
{isSubmitting ? "Loading..." : "Use This Model"}
</Button>
) : null}
</div>
</div> </div>
</motion.div> </motion.div>
</> </>
@ -495,5 +485,5 @@ export function ModelConfigSidebar({
</AnimatePresence> </AnimatePresence>
); );
return typeof document !== "undefined" ? createPortal(sidebarContent, document.body) : null; return typeof document !== "undefined" ? createPortal(dialogContent, document.body) : null;
} }

View file

@ -2,7 +2,7 @@
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { Bot, Check, ChevronDown, Edit3, ImageIcon, Plus, Zap } from "lucide-react"; import { Bot, Check, ChevronDown, Edit3, ImageIcon, Plus, Zap } from "lucide-react";
import { useCallback, useMemo, useState } from "react"; import { type UIEvent, useCallback, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
globalImageGenConfigsAtom, globalImageGenConfigsAtom,
@ -57,6 +57,17 @@ export function ModelSelector({
const [activeTab, setActiveTab] = useState<"llm" | "image">("llm"); const [activeTab, setActiveTab] = useState<"llm" | "image">("llm");
const [llmSearchQuery, setLlmSearchQuery] = useState(""); const [llmSearchQuery, setLlmSearchQuery] = useState("");
const [imageSearchQuery, setImageSearchQuery] = useState(""); const [imageSearchQuery, setImageSearchQuery] = useState("");
const [llmScrollPos, setLlmScrollPos] = useState<"top" | "middle" | "bottom">("top");
const [imageScrollPos, setImageScrollPos] = useState<"top" | "middle" | "bottom">("top");
const handleListScroll = useCallback(
(setter: typeof setLlmScrollPos) => (e: UIEvent<HTMLDivElement>) => {
const el = e.currentTarget;
const atTop = el.scrollTop <= 2;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
setter(atTop ? "top" : atBottom ? "bottom" : "middle");
},
[]
);
// LLM data // LLM data
const { data: llmUserConfigs, isLoading: llmUserLoading } = useAtomValue(newLLMConfigsAtom); const { data: llmUserConfigs, isLoading: llmUserLoading } = useAtomValue(newLLMConfigsAtom);
@ -253,7 +264,7 @@ export function ModelSelector({
)} )}
{/* Divider */} {/* Divider */}
<div className="h-4 w-px bg-border/60 mx-0.5" /> <div className="h-4 w-px bg-border/60 dark:bg-white/10 mx-0.5" />
{/* Image section */} {/* Image section */}
{currentImageConfig ? ( {currentImageConfig ? (
@ -280,7 +291,7 @@ export function ModelSelector({
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent
className="w-[280px] md:w-[360px] p-0 rounded-lg shadow-lg border-border/60 dark:bg-muted dark:border dark:border-neutral-700 select-none" className="w-[280px] md:w-[360px] p-0 rounded-lg shadow-lg bg-white border-border/60 dark:bg-neutral-900 dark:border dark:border-white/5 select-none"
align="start" align="start"
sideOffset={8} sideOffset={8}
> >
@ -289,18 +300,18 @@ export function ModelSelector({
onValueChange={(v) => setActiveTab(v as "llm" | "image")} onValueChange={(v) => setActiveTab(v as "llm" | "image")}
className="w-full" className="w-full"
> >
<div className="border-b border-border/80 dark:border-white/5"> <div className="border-b border-border/80 dark:border-neutral-800">
<TabsList className="w-full grid grid-cols-2 rounded-none rounded-t-lg bg-transparent h-11 p-0 gap-0"> <TabsList className="w-full grid grid-cols-2 rounded-none rounded-t-lg bg-transparent h-11 p-0 gap-0">
<TabsTrigger <TabsTrigger
value="llm" value="llm"
className="gap-2 text-sm font-medium rounded-none text-muted-foreground/60 transition-all duration-200 h-full bg-transparent data-[state=active]:bg-transparent shadow-none data-[state=active]:shadow-none border-b-[1.5px] border-transparent data-[state=active]:border-foreground dark:data-[state=active]:border-white data-[state=active]:text-foreground" className="gap-2 text-sm font-medium rounded-none text-muted-foreground transition-all duration-200 h-full bg-transparent data-[state=active]:bg-transparent shadow-none data-[state=active]:shadow-none border-b-[1.5px] border-transparent data-[state=active]:border-foreground dark:data-[state=active]:border-white data-[state=active]:text-foreground"
> >
<Zap className="size-4" /> <Zap className="size-4" />
LLM LLM
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
value="image" value="image"
className="gap-2 text-sm font-medium rounded-none text-muted-foreground/60 transition-all duration-200 h-full bg-transparent data-[state=active]:bg-transparent shadow-none data-[state=active]:shadow-none border-b-[1.5px] border-transparent data-[state=active]:border-foreground dark:data-[state=active]:border-white data-[state=active]:text-foreground" className="gap-2 text-sm font-medium rounded-none text-muted-foreground transition-all duration-200 h-full bg-transparent data-[state=active]:bg-transparent shadow-none data-[state=active]:shadow-none border-b-[1.5px] border-transparent data-[state=active]:border-foreground dark:data-[state=active]:border-white data-[state=active]:text-foreground"
> >
<ImageIcon className="size-4" /> <ImageIcon className="size-4" />
Image Image
@ -312,7 +323,7 @@ export function ModelSelector({
<TabsContent value="llm" className="mt-0"> <TabsContent value="llm" className="mt-0">
<Command <Command
shouldFilter={false} shouldFilter={false}
className="rounded-none rounded-b-lg relative dark:bg-muted [&_[data-slot=command-input-wrapper]]:border-0 [&_[data-slot=command-input-wrapper]]:px-0 [&_[data-slot=command-input-wrapper]]:gap-2" className="rounded-none rounded-b-lg relative dark:bg-neutral-900 [&_[data-slot=command-input-wrapper]]:border-0 [&_[data-slot=command-input-wrapper]]:px-0 [&_[data-slot=command-input-wrapper]]:gap-2"
> >
{totalLLMModels > 3 && ( {totalLLMModels > 3 && (
<div className="px-2 md:px-3 py-1.5 md:py-2"> <div className="px-2 md:px-3 py-1.5 md:py-2">
@ -325,7 +336,14 @@ export function ModelSelector({
</div> </div>
)} )}
<CommandList className="max-h-[300px] md:max-h-[400px] overflow-y-auto"> <CommandList
className="max-h-[300px] md:max-h-[400px] overflow-y-auto"
onScroll={handleListScroll(setLlmScrollPos)}
style={{
maskImage: `linear-gradient(to bottom, ${llmScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${llmScrollPos === "bottom" ? "black" : "transparent"})`,
WebkitMaskImage: `linear-gradient(to bottom, ${llmScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${llmScrollPos === "bottom" ? "black" : "transparent"})`,
}}
>
<CommandEmpty className="py-8 text-center"> <CommandEmpty className="py-8 text-center">
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<Bot className="size-8 text-muted-foreground" /> <Bot className="size-8 text-muted-foreground" />
@ -350,8 +368,8 @@ export function ModelSelector({
onSelect={() => handleSelectLLM(config)} onSelect={() => handleSelectLLM(config)}
className={cn( className={cn(
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all", "mx-2 rounded-lg mb-1 cursor-pointer group transition-all",
"hover:bg-accent/50 dark:hover:bg-white/10", "hover:bg-accent/50 dark:hover:bg-white/[0.06]",
isSelected && "bg-accent/80 dark:bg-white/10", isSelected && "bg-accent/80 dark:bg-white/[0.06]",
isAutoMode && "" isAutoMode && ""
)} )}
> >
@ -426,8 +444,8 @@ export function ModelSelector({
onSelect={() => handleSelectLLM(config)} onSelect={() => handleSelectLLM(config)}
className={cn( className={cn(
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all", "mx-2 rounded-lg mb-1 cursor-pointer group transition-all",
"hover:bg-accent/50 dark:hover:bg-white/10", "hover:bg-accent/50 dark:hover:bg-white/[0.06]",
isSelected && "bg-accent/80 dark:bg-white/10" isSelected && "bg-accent/80 dark:bg-white/[0.06]"
)} )}
> >
<div className="flex items-center justify-between w-full gap-2"> <div className="flex items-center justify-between w-full gap-2">
@ -471,11 +489,11 @@ export function ModelSelector({
)} )}
{/* Add New LLM Config */} {/* Add New LLM Config */}
<div className="p-2 bg-muted/20 dark:bg-muted"> <div className="p-2 bg-muted/20 dark:bg-neutral-900">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="w-full justify-start gap-2 h-9 rounded-lg hover:bg-accent/50 dark:hover:bg-white/10" className="w-full justify-start gap-2 h-9 rounded-lg hover:bg-accent/50 dark:hover:bg-white/[0.06]"
onClick={() => { onClick={() => {
setOpen(false); setOpen(false);
onAddNewLLM(); onAddNewLLM();
@ -493,7 +511,7 @@ export function ModelSelector({
<TabsContent value="image" className="mt-0"> <TabsContent value="image" className="mt-0">
<Command <Command
shouldFilter={false} shouldFilter={false}
className="rounded-none rounded-b-lg dark:bg-muted [&_[data-slot=command-input-wrapper]]:border-0 [&_[data-slot=command-input-wrapper]]:px-0 [&_[data-slot=command-input-wrapper]]:gap-2" className="rounded-none rounded-b-lg dark:bg-neutral-900 [&_[data-slot=command-input-wrapper]]:border-0 [&_[data-slot=command-input-wrapper]]:px-0 [&_[data-slot=command-input-wrapper]]:gap-2"
> >
{totalImageModels > 3 && ( {totalImageModels > 3 && (
<div className="px-2 md:px-3 py-1.5 md:py-2"> <div className="px-2 md:px-3 py-1.5 md:py-2">
@ -505,7 +523,14 @@ export function ModelSelector({
/> />
</div> </div>
)} )}
<CommandList className="max-h-[300px] md:max-h-[400px] overflow-y-auto"> <CommandList
className="max-h-[300px] md:max-h-[400px] overflow-y-auto"
onScroll={handleListScroll(setImageScrollPos)}
style={{
maskImage: `linear-gradient(to bottom, ${imageScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${imageScrollPos === "bottom" ? "black" : "transparent"})`,
WebkitMaskImage: `linear-gradient(to bottom, ${imageScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${imageScrollPos === "bottom" ? "black" : "transparent"})`,
}}
>
<CommandEmpty className="py-8 text-center"> <CommandEmpty className="py-8 text-center">
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<ImageIcon className="size-8 text-muted-foreground" /> <ImageIcon className="size-8 text-muted-foreground" />
@ -528,8 +553,8 @@ export function ModelSelector({
value={`img-g-${config.id}`} value={`img-g-${config.id}`}
onSelect={() => handleSelectImage(config.id)} onSelect={() => handleSelectImage(config.id)}
className={cn( className={cn(
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all hover:bg-accent/50 dark:hover:bg-white/10", "mx-2 rounded-lg mb-1 cursor-pointer group transition-all hover:bg-accent/50 dark:hover:bg-white/[0.06]",
isSelected && "bg-accent/80 dark:bg-white/10", isSelected && "bg-accent/80 dark:bg-white/[0.06]",
isAuto && "" isAuto && ""
)} )}
> >
@ -593,8 +618,8 @@ export function ModelSelector({
value={`img-u-${config.id}`} value={`img-u-${config.id}`}
onSelect={() => handleSelectImage(config.id)} onSelect={() => handleSelectImage(config.id)}
className={cn( className={cn(
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all hover:bg-accent/50 dark:hover:bg-white/10", "mx-2 rounded-lg mb-1 cursor-pointer group transition-all hover:bg-accent/50 dark:hover:bg-white/[0.06]",
isSelected && "bg-accent/80 dark:bg-white/10" isSelected && "bg-accent/80 dark:bg-white/[0.06]"
)} )}
> >
<div className="flex items-center gap-3 min-w-0 flex-1"> <div className="flex items-center gap-3 min-w-0 flex-1">
@ -634,11 +659,11 @@ export function ModelSelector({
{/* Add New Image Config */} {/* Add New Image Config */}
{onAddNewImage && ( {onAddNewImage && (
<div className="p-2 bg-muted/20 dark:bg-muted"> <div className="p-2 bg-muted/20 dark:bg-neutral-900">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="w-full justify-start gap-2 h-9 rounded-lg hover:bg-accent/50 dark:hover:bg-white/10" className="w-full justify-start gap-2 h-9 rounded-lg hover:bg-accent/50 dark:hover:bg-white/[0.06]"
onClick={() => { onClick={() => {
setOpen(false); setOpen(false);
onAddNewImage(); onAddNewImage();

View file

@ -334,7 +334,7 @@ function ReportPanelContent({
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
align="start" align="start"
className={`min-w-[180px] bg-muted dark:border dark:border-neutral-700${insideDrawer ? " z-[100]" : ""}`} className={`min-w-[180px] dark:bg-neutral-900 dark:border dark:border-white/5${insideDrawer ? " z-[100]" : ""}`}
> >
<DropdownMenuItem onClick={() => handleExport("md")}> <DropdownMenuItem onClick={() => handleExport("md")}>
Download Markdown Download Markdown
@ -371,7 +371,7 @@ function ReportPanelContent({
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
align="start" align="start"
className={`min-w-[120px] bg-muted dark:border dark:border-neutral-700${insideDrawer ? " z-[100]" : ""}`} className={`min-w-[120px] dark:bg-neutral-900 dark:border dark:border-white/5${insideDrawer ? " z-[100]" : ""}`}
> >
{versions.map((v, i) => ( {versions.map((v, i) => (
<DropdownMenuItem <DropdownMenuItem

View file

@ -578,10 +578,7 @@ function RolesContent({
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<DropdownMenuItem <DropdownMenuItem onSelect={(e) => e.preventDefault()}>
className="text-destructive focus:text-destructive"
onSelect={(e) => e.preventDefault()}
>
<Trash2 className="h-4 w-4 mr-2" /> <Trash2 className="h-4 w-4 mr-2" />
Delete Role Delete Role
</DropdownMenuItem> </DropdownMenuItem>

View file

@ -2,16 +2,7 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { import { Check, ChevronDown, ChevronsUpDown } from "lucide-react";
Bot,
Check,
ChevronDown,
ChevronsUpDown,
Key,
MessageSquareQuote,
Rocket,
Sparkles,
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react"; import { AnimatePresence, motion } from "motion/react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@ -88,6 +79,8 @@ interface LLMConfigFormProps {
submitLabel?: string; submitLabel?: string;
showAdvanced?: boolean; showAdvanced?: boolean;
compact?: boolean; compact?: boolean;
formId?: string;
hideActions?: boolean;
} }
export function LLMConfigForm({ export function LLMConfigForm({
@ -100,6 +93,8 @@ export function LLMConfigForm({
submitLabel, submitLabel,
showAdvanced = true, showAdvanced = true,
compact = false, compact = false,
formId,
hideActions = false,
}: LLMConfigFormProps) { }: LLMConfigFormProps) {
const { data: defaultInstructions, isSuccess: defaultInstructionsLoaded } = useAtomValue( const { data: defaultInstructions, isSuccess: defaultInstructionsLoaded } = useAtomValue(
defaultSystemInstructionsAtom defaultSystemInstructionsAtom
@ -164,11 +159,10 @@ export function LLMConfigForm({
return ( return (
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(handleFormSubmit)} className="space-y-6"> <form id={formId} onSubmit={form.handleSubmit(handleFormSubmit)} className="space-y-6">
{/* Model Configuration Section */} {/* Model Configuration Section */}
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center gap-2 text-xs sm:text-sm font-medium text-muted-foreground"> <div className="text-xs sm:text-sm font-medium text-muted-foreground">
<Bot className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
Model Configuration Model Configuration
</div> </div>
@ -179,16 +173,9 @@ export function LLMConfigForm({
name="name" name="name"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel className="flex items-center gap-2 text-xs sm:text-sm"> <FormLabel className="text-xs sm:text-sm">Configuration Name</FormLabel>
<Sparkles className="h-3.5 w-3.5 text-violet-500" />
Configuration Name
</FormLabel>
<FormControl> <FormControl>
<Input <Input placeholder="e.g., My GPT-4 Agent" {...field} />
placeholder="e.g., My GPT-4 Agent"
className="transition-all focus-visible:ring-violet-500/50"
{...field}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@ -224,19 +211,18 @@ export function LLMConfigForm({
<FormLabel className="text-xs sm:text-sm">LLM Provider</FormLabel> <FormLabel className="text-xs sm:text-sm">LLM Provider</FormLabel>
<Select value={field.value} onValueChange={handleProviderChange}> <Select value={field.value} onValueChange={handleProviderChange}>
<FormControl> <FormControl>
<SelectTrigger className="transition-all focus:ring-violet-500/50"> <SelectTrigger>
<SelectValue placeholder="Select a provider" /> <SelectValue placeholder="Select a provider" />
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent className="max-h-[300px]"> <SelectContent className="max-h-[300px] bg-muted dark:border-neutral-700">
{LLM_PROVIDERS.map((provider) => ( {LLM_PROVIDERS.map((provider) => (
<SelectItem key={provider.value} value={provider.value}> <SelectItem
<div className="flex flex-col py-0.5"> key={provider.value}
<span className="font-medium">{provider.label}</span> value={provider.value}
<span className="text-xs text-muted-foreground"> description={provider.description}
{provider.description} >
</span> {provider.label}
</div>
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@ -290,7 +276,7 @@ export function LLMConfigForm({
role="combobox" role="combobox"
aria-expanded={modelComboboxOpen} aria-expanded={modelComboboxOpen}
className={cn( className={cn(
"w-full justify-between font-normal", "w-full justify-between font-normal bg-transparent",
!field.value && "text-muted-foreground" !field.value && "text-muted-foreground"
)} )}
> >
@ -299,8 +285,11 @@ export function LLMConfigForm({
</Button> </Button>
</FormControl> </FormControl>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-full p-0" align="start"> <PopoverContent
<Command shouldFilter={false}> className="w-full p-0 bg-muted dark:border-neutral-700"
align="start"
>
<Command shouldFilter={false} className="bg-transparent">
<CommandInput <CommandInput
placeholder={selectedProvider?.example || "Type model name..."} placeholder={selectedProvider?.example || "Type model name..."}
value={field.value} value={field.value}
@ -371,10 +360,7 @@ export function LLMConfigForm({
name="api_key" name="api_key"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel className="flex items-center gap-2 text-xs sm:text-sm"> <FormLabel className="text-xs sm:text-sm">API Key</FormLabel>
<Key className="h-3.5 w-3.5 text-amber-500" />
API Key
</FormLabel>
<FormControl> <FormControl>
<Input <Input
type="password" type="password"
@ -460,10 +446,7 @@ export function LLMConfigForm({
type="button" type="button"
className="flex w-full items-center justify-between py-2 text-xs sm:text-sm font-medium text-muted-foreground hover:text-foreground transition-colors" className="flex w-full items-center justify-between py-2 text-xs sm:text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
> >
<div className="flex items-center gap-2"> <span>Advanced Parameters</span>
<Sparkles className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
Advanced Parameters
</div>
<ChevronDown <ChevronDown
className={cn( className={cn(
"h-4 w-4 transition-transform duration-200", "h-4 w-4 transition-transform duration-200",
@ -501,10 +484,7 @@ export function LLMConfigForm({
type="button" type="button"
className="flex w-full items-center justify-between py-2 text-xs sm:text-sm font-medium text-muted-foreground hover:text-foreground transition-colors" className="flex w-full items-center justify-between py-2 text-xs sm:text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
> >
<div className="flex items-center gap-2"> <span>System Instructions</span>
<MessageSquareQuote className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
System Instructions
</div>
<ChevronDown <ChevronDown
className={cn( className={cn(
"h-4 w-4 transition-transform duration-200", "h-4 w-4 transition-transform duration-200",
@ -575,42 +555,43 @@ export function LLMConfigForm({
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
{/* Action Buttons */} {!hideActions && (
<div <div
className={cn( className={cn(
"flex gap-3 pt-4", "flex gap-3 pt-4",
compact ? "justify-end" : "justify-center sm:justify-end" compact ? "justify-end" : "justify-center sm:justify-end"
)}
>
{onCancel && (
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={isSubmitting}
className="text-xs sm:text-sm h-9 sm:h-10"
>
Cancel
</Button>
)}
<Button
type="submit"
disabled={isSubmitting}
className="gap-2 min-w-[140px] sm:min-w-[160px] text-xs sm:text-sm h-9 sm:h-10"
>
{isSubmitting ? (
<>
<Spinner size="sm" />
{mode === "edit" ? "Updating..." : "Creating"}
</>
) : (
<>
{!compact && <Rocket className="h-3.5 w-3.5 sm:h-4 sm:w-4" />}
{submitLabel ?? (mode === "edit" ? "Update Configuration" : "Create Configuration")}
</>
)} )}
</Button> >
</div> {onCancel && (
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={isSubmitting}
className="text-xs sm:text-sm h-9 sm:h-10"
>
Cancel
</Button>
)}
<Button
type="submit"
disabled={isSubmitting}
className="gap-2 min-w-[140px] sm:min-w-[160px] text-xs sm:text-sm h-9 sm:h-10"
>
{isSubmitting ? (
<>
<Spinner size="sm" />
{mode === "edit" ? "Updating..." : "Creating"}
</>
) : (
<>
{submitLabel ??
(mode === "edit" ? "Update Configuration" : "Create Configuration")}
</>
)}
</Button>
</div>
)}
</form> </form>
</Form> </Form>
); );

View file

@ -2,7 +2,7 @@
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { CheckCircle2, FileType, Info, Upload, X } from "lucide-react"; import { CheckCircle2, FileType, Info, Upload, X } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useCallback, useMemo, useRef, useState } from "react"; import { useCallback, useMemo, useRef, useState } from "react";
import { useDropzone } from "react-dropzone"; import { useDropzone } from "react-dropzone";
@ -109,6 +109,11 @@ const FILE_TYPE_CONFIG: Record<string, Record<string, string[]>> = {
}, },
}; };
interface FileWithId {
id: string;
file: File;
}
const cardClass = "border border-border bg-slate-400/5 dark:bg-white/5"; const cardClass = "border border-border bg-slate-400/5 dark:bg-white/5";
// Upload limits — files are sent in batches of 5 to avoid proxy timeouts // Upload limits — files are sent in batches of 5 to avoid proxy timeouts
@ -122,7 +127,7 @@ export function DocumentUploadTab({
onAccordionStateChange, onAccordionStateChange,
}: DocumentUploadTabProps) { }: DocumentUploadTabProps) {
const t = useTranslations("upload_documents"); const t = useTranslations("upload_documents");
const [files, setFiles] = useState<File[]>([]); const [files, setFiles] = useState<FileWithId[]>([]);
const [uploadProgress, setUploadProgress] = useState(0); const [uploadProgress, setUploadProgress] = useState(0);
const [accordionValue, setAccordionValue] = useState<string>(""); const [accordionValue, setAccordionValue] = useState<string>("");
const [shouldSummarize, setShouldSummarize] = useState(false); const [shouldSummarize, setShouldSummarize] = useState(false);
@ -143,9 +148,12 @@ export function DocumentUploadTab({
const onDrop = useCallback( const onDrop = useCallback(
(acceptedFiles: File[]) => { (acceptedFiles: File[]) => {
setFiles((prev) => { setFiles((prev) => {
const newFiles = [...prev, ...acceptedFiles]; const newEntries = acceptedFiles.map((f) => ({
id: crypto.randomUUID?.() ?? `file-${Date.now()}-${Math.random().toString(36)}`,
file: f,
}));
const newFiles = [...prev, ...newEntries];
// Check file count limit
if (newFiles.length > MAX_FILES) { if (newFiles.length > MAX_FILES) {
toast.error(t("max_files_exceeded"), { toast.error(t("max_files_exceeded"), {
description: t("max_files_exceeded_desc", { max: MAX_FILES }), description: t("max_files_exceeded_desc", { max: MAX_FILES }),
@ -153,8 +161,7 @@ export function DocumentUploadTab({
return prev; return prev;
} }
// Check total size limit const newTotalSize = newFiles.reduce((sum, entry) => sum + entry.file.size, 0);
const newTotalSize = newFiles.reduce((sum, file) => sum + file.size, 0);
if (newTotalSize > MAX_TOTAL_SIZE_BYTES) { if (newTotalSize > MAX_TOTAL_SIZE_BYTES) {
toast.error(t("max_size_exceeded"), { toast.error(t("max_size_exceeded"), {
description: t("max_size_exceeded_desc", { max: MAX_TOTAL_SIZE_MB }), description: t("max_size_exceeded_desc", { max: MAX_TOTAL_SIZE_MB }),
@ -189,7 +196,7 @@ export function DocumentUploadTab({
return `${parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`; return `${parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`;
}; };
const totalFileSize = files.reduce((total, file) => total + file.size, 0); const totalFileSize = files.reduce((total, entry) => total + entry.file.size, 0);
// Check if limits are reached // Check if limits are reached
const isFileCountLimitReached = files.length >= MAX_FILES; const isFileCountLimitReached = files.length >= MAX_FILES;
@ -217,8 +224,13 @@ export function DocumentUploadTab({
setUploadProgress((prev) => (prev >= 90 ? prev : prev + Math.random() * 10)); setUploadProgress((prev) => (prev >= 90 ? prev : prev + Math.random() * 10));
}, 200); }, 200);
const rawFiles = files.map((entry) => entry.file);
uploadDocuments( uploadDocuments(
{ files, search_space_id: Number(searchSpaceId), should_summarize: shouldSummarize }, {
files: rawFiles,
search_space_id: Number(searchSpaceId),
should_summarize: shouldSummarize,
},
{ {
onSuccess: () => { onSuccess: () => {
clearInterval(progressInterval); clearInterval(progressInterval);
@ -241,12 +253,7 @@ export function DocumentUploadTab({
}; };
return ( return (
<motion.div <div className="space-y-3 sm:space-y-6 max-w-4xl mx-auto pt-0">
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="space-y-3 sm:space-y-6 max-w-4xl mx-auto pt-0"
>
<Alert className="border border-border bg-slate-400/5 dark:bg-white/5 flex items-start gap-3 [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg~*]:pl-0"> <Alert className="border border-border bg-slate-400/5 dark:bg-white/5 flex items-start gap-3 [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg~*]:pl-0">
<Info className="h-4 w-4 shrink-0 mt-0.5" /> <Info className="h-4 w-4 shrink-0 mt-0.5" />
<AlertDescription className="text-xs sm:text-sm leading-relaxed pt-0.5"> <AlertDescription className="text-xs sm:text-sm leading-relaxed pt-0.5">
@ -287,14 +294,10 @@ export function DocumentUploadTab({
</div> </div>
</div> </div>
) : isDragActive ? ( ) : isDragActive ? (
<motion.div <div className="flex flex-col items-center gap-2 sm:gap-4">
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-2 sm:gap-4"
>
<Upload className="h-8 w-8 sm:h-12 sm:w-12 text-primary" /> <Upload className="h-8 w-8 sm:h-12 sm:w-12 text-primary" />
<p className="text-sm sm:text-lg font-medium text-primary">{t("drop_files")}</p> <p className="text-sm sm:text-lg font-medium text-primary">{t("drop_files")}</p>
</motion.div> </div>
) : ( ) : (
<div className="flex flex-col items-center gap-2 sm:gap-4"> <div className="flex flex-col items-center gap-2 sm:gap-4">
<Upload className="h-8 w-8 sm:h-12 sm:w-12 text-muted-foreground" /> <Upload className="h-8 w-8 sm:h-12 sm:w-12 text-muted-foreground" />
@ -312,7 +315,7 @@ export function DocumentUploadTab({
{!isFileCountLimitReached && ( {!isFileCountLimitReached && (
<div className="mt-2 sm:mt-4"> <div className="mt-2 sm:mt-4">
<Button <Button
variant="outline" variant="secondary"
size="sm" size="sm"
className="text-xs sm:text-sm" className="text-xs sm:text-sm"
onClick={(e) => { onClick={(e) => {
@ -329,124 +332,102 @@ export function DocumentUploadTab({
</CardContent> </CardContent>
</Card> </Card>
<AnimatePresence mode="wait"> {files.length > 0 && (
{files.length > 0 && ( <Card className={cardClass}>
<motion.div <CardHeader className="p-4 sm:p-6">
initial={{ opacity: 0, height: 0 }} <div className="flex items-center justify-between gap-2">
animate={{ opacity: 1, height: "auto" }} <div className="min-w-0 flex-1">
exit={{ opacity: 0, height: 0 }} <CardTitle className="text-base sm:text-2xl">
transition={{ duration: 0.3 }} {t("selected_files", { count: files.length })}
> </CardTitle>
<Card className={cardClass}> <CardDescription className="text-xs sm:text-sm">
<CardHeader className="p-4 sm:p-6"> {t("total_size")}: {formatFileSize(totalFileSize)}
<div className="flex items-center justify-between gap-2"> </CardDescription>
<div className="min-w-0 flex-1"> </div>
<CardTitle className="text-base sm:text-2xl"> <Button
{t("selected_files", { count: files.length })} variant="outline"
</CardTitle> size="sm"
<CardDescription className="text-xs sm:text-sm"> className="text-xs sm:text-sm shrink-0"
{t("total_size")}: {formatFileSize(totalFileSize)} onClick={() => setFiles([])}
</CardDescription> disabled={isUploading}
>
{t("clear_all")}
</Button>
</div>
</CardHeader>
<CardContent className="p-4 sm:p-6 pt-0">
<div className="space-y-2 sm:space-y-3 max-h-[250px] sm:max-h-[400px] overflow-y-auto">
{files.map((entry) => (
<div
key={entry.id}
className={`flex items-center justify-between p-2 sm:p-4 rounded-lg border border-border ${cardClass} hover:bg-slate-400/10 dark:hover:bg-white/10 transition-colors`}
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<FileType className="h-5 w-5 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm sm:text-base font-medium truncate">{entry.file.name}</p>
<div className="flex items-center gap-2 mt-1">
<Badge variant="secondary" className="text-xs">
{formatFileSize(entry.file.size)}
</Badge>
<Badge variant="outline" className="text-xs">
{entry.file.type || "Unknown type"}
</Badge>
</div>
</div>
</div> </div>
<Button <Button
variant="outline" variant="ghost"
size="sm" size="icon"
className="text-xs sm:text-sm shrink-0" onClick={() => setFiles((prev) => prev.filter((e) => e.id !== entry.id))}
onClick={() => setFiles([])}
disabled={isUploading} disabled={isUploading}
className="h-8 w-8"
> >
{t("clear_all")} <X className="h-4 w-4" />
</Button> </Button>
</div> </div>
</CardHeader> ))}
<CardContent className="p-4 sm:p-6 pt-0"> </div>
<div className="space-y-2 sm:space-y-3 max-h-[250px] sm:max-h-[400px] overflow-y-auto">
<AnimatePresence>
{files.map((file, index) => (
<motion.div
key={`${file.name}-${index}`}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
className={`flex items-center justify-between p-2 sm:p-4 rounded-lg border border-border ${cardClass} hover:bg-slate-400/10 dark:hover:bg-white/10 transition-colors`}
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<FileType className="h-5 w-5 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm sm:text-base font-medium truncate">{file.name}</p>
<div className="flex items-center gap-2 mt-1">
<Badge variant="secondary" className="text-xs">
{formatFileSize(file.size)}
</Badge>
<Badge variant="outline" className="text-xs">
{file.type || "Unknown type"}
</Badge>
</div>
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => setFiles((prev) => prev.filter((_, i) => i !== index))}
disabled={isUploading}
className="h-8 w-8"
>
<X className="h-4 w-4" />
</Button>
</motion.div>
))}
</AnimatePresence>
</div>
{isUploading && ( {isUploading && (
<motion.div <div className="mt-3 sm:mt-6 space-y-2 sm:space-y-3">
initial={{ opacity: 0, y: 10 }} <Separator className="bg-border" />
animate={{ opacity: 1, y: 0 }} <div className="space-y-2">
className="mt-3 sm:mt-6 space-y-2 sm:space-y-3" <div className="flex items-center justify-between text-xs sm:text-sm">
> <span>{t("uploading_files")}</span>
<Separator className="bg-border" /> <span>{Math.round(uploadProgress)}%</span>
<div className="space-y-2"> </div>
<div className="flex items-center justify-between text-xs sm:text-sm"> <Progress value={uploadProgress} className="h-2" />
<span>{t("uploading_files")}</span> </div>
<span>{Math.round(uploadProgress)}%</span> </div>
</div> )}
<Progress value={uploadProgress} className="h-2" />
</div> <div className="mt-3 sm:mt-6">
</motion.div> <SummaryConfig enabled={shouldSummarize} onEnabledChange={setShouldSummarize} />
</div>
<div className="mt-3 sm:mt-6">
<Button
className="w-full py-3 sm:py-6 text-xs sm:text-base font-medium"
onClick={handleUpload}
disabled={isUploading || files.length === 0}
>
{isUploading ? (
<span className="flex items-center gap-2">
<Spinner size="sm" />
{t("uploading")}
</span>
) : (
<span className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 sm:h-5 sm:w-5" />
{t("upload_button", { count: files.length })}
</span>
)} )}
</Button>
<div className="mt-3 sm:mt-6"> </div>
<SummaryConfig enabled={shouldSummarize} onEnabledChange={setShouldSummarize} /> </CardContent>
</div> </Card>
)}
<motion.div
className="mt-3 sm:mt-6"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
>
<Button
className="w-full py-3 sm:py-6 text-xs sm:text-base font-medium"
onClick={handleUpload}
disabled={isUploading || files.length === 0}
>
{isUploading ? (
<span className="flex items-center gap-2">
<Spinner size="sm" />
{t("uploading")}
</span>
) : (
<span className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 sm:h-5 sm:w-5" />
{t("upload_button", { count: files.length })}
</span>
)}
</Button>
</motion.div>
</CardContent>
</Card>
</motion.div>
)}
</AnimatePresence>
<Accordion <Accordion
type="single" type="single"
@ -479,6 +460,6 @@ export function DocumentUploadTab({
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
</Accordion> </Accordion>
</motion.div> </div>
); );
} }

View file

@ -6,7 +6,7 @@ import {
CheckIcon, CheckIcon,
FileIcon, FileIcon,
Loader2Icon, Loader2Icon,
PencilIcon, Pen,
RefreshCwIcon, RefreshCwIcon,
XIcon, XIcon,
} from "lucide-react"; } from "lucide-react";
@ -400,7 +400,7 @@ function ApprovalCard({
)} )}
{canEdit && ( {canEdit && (
<Button size="sm" variant="outline" onClick={() => setIsEditing(true)}> <Button size="sm" variant="outline" onClick={() => setIsEditing(true)}>
<PencilIcon /> <Pen />
Edit Edit
</Button> </Button>
)} )}

View file

@ -1,7 +1,7 @@
"use client"; "use client";
import { makeAssistantToolUI } from "@assistant-ui/react"; import { makeAssistantToolUI } from "@assistant-ui/react";
import { AlertTriangleIcon, CheckIcon, Loader2Icon, PencilIcon, XIcon } from "lucide-react"; import { AlertTriangleIcon, CheckIcon, Loader2Icon, Pen, XIcon } from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@ -495,7 +495,7 @@ function ApprovalCard({
)} )}
{canEdit && ( {canEdit && (
<Button size="sm" variant="outline" onClick={() => setIsEditing(true)}> <Button size="sm" variant="outline" onClick={() => setIsEditing(true)}>
<PencilIcon /> <Pen />
Edit Edit
</Button> </Button>
)} )}

View file

@ -1,14 +1,7 @@
"use client"; "use client";
import { makeAssistantToolUI } from "@assistant-ui/react"; import { makeAssistantToolUI } from "@assistant-ui/react";
import { import { AlertTriangleIcon, CheckIcon, InfoIcon, Loader2Icon, Pen, XIcon } from "lucide-react";
AlertTriangleIcon,
CheckIcon,
InfoIcon,
Loader2Icon,
PencilIcon,
XIcon,
} from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@ -618,7 +611,7 @@ function ApprovalCard({
)} )}
{canEdit && ( {canEdit && (
<Button size="sm" variant="outline" onClick={() => setIsEditing(true)}> <Button size="sm" variant="outline" onClick={() => setIsEditing(true)}>
<PencilIcon /> <Pen />
Edit Edit
</Button> </Button>
)} )}

View file

@ -1,7 +1,7 @@
"use client"; "use client";
import { makeAssistantToolUI } from "@assistant-ui/react"; import { makeAssistantToolUI } from "@assistant-ui/react";
import { AlertTriangleIcon, CheckIcon, Loader2Icon, PencilIcon, XIcon } from "lucide-react"; import { AlertTriangleIcon, CheckIcon, Loader2Icon, Pen, XIcon } from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@ -373,7 +373,7 @@ function ApprovalCard({
)} )}
{canEdit && ( {canEdit && (
<Button size="sm" variant="outline" onClick={() => setIsEditing(true)}> <Button size="sm" variant="outline" onClick={() => setIsEditing(true)}>
<PencilIcon /> <Pen />
Edit Edit
</Button> </Button>
)} )}

View file

@ -8,7 +8,7 @@ import {
Loader2Icon, Loader2Icon,
MaximizeIcon, MaximizeIcon,
MinimizeIcon, MinimizeIcon,
PencilIcon, Pen,
XIcon, XIcon,
} from "lucide-react"; } from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
@ -336,7 +336,7 @@ function ApprovalCard({
)} )}
{canEdit && ( {canEdit && (
<Button size="sm" variant="outline" onClick={() => setIsEditing(true)}> <Button size="sm" variant="outline" onClick={() => setIsEditing(true)}>
<PencilIcon /> <Pen />
Edit Edit
</Button> </Button>
)} )}

View file

@ -27,7 +27,7 @@ function AlertDialogOverlay({
<AlertDialogPrimitive.Overlay <AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay" data-slot="alert-dialog-overlay"
className={cn( className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80", "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 backdrop-blur-sm",
className className
)} )}
{...props} {...props}
@ -45,7 +45,7 @@ function AlertDialogContent({
<AlertDialogPrimitive.Content <AlertDialogPrimitive.Content
data-slot="alert-dialog-content" data-slot="alert-dialog-content"
className={cn( className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg", "bg-background dark:bg-neutral-900 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-xl p-6 shadow-2xl duration-200 sm:max-w-lg",
className className
)} )}
{...props} {...props}
@ -113,7 +113,7 @@ function AlertDialogCancel({
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) { }: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return ( return (
<AlertDialogPrimitive.Cancel <AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "outline" }), className)} className={cn(buttonVariants({ variant: "secondary" }), className)}
{...props} {...props}
/> />
); );

View file

@ -0,0 +1,551 @@
"use client";
import React, {
createContext,
forwardRef,
type ReactNode,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { cn } from "@/lib/utils";
/*
Context (replaces cloneElement)
*/
interface TabsContextValue {
activeValue: string;
onValueChange: (value: string) => void;
}
const TabsContext = createContext<TabsContextValue | null>(null);
function useTabsContext() {
const ctx = useContext(TabsContext);
if (!ctx) {
throw new Error("AnimatedTabs compound components must be rendered inside <Tabs>");
}
return ctx;
}
/*
Constants (hoisted out of render)
*/
const SIZE_CLASSES = {
sm: "h-[32px] text-sm",
md: "h-[40px] text-base",
lg: "h-[48px] text-lg",
} as const;
const VARIANT_CLASSES = {
default: "",
pills: "rounded-full",
underlined: "",
} as const;
const ACTIVE_INDICATOR_CLASSES = {
default: "h-[4px] bg-primary dark:bg-primary",
pills: "hidden",
underlined: "h-[4px] bg-primary dark:bg-primary",
} as const;
const HOVER_INDICATOR_CLASSES = {
default: "bg-muted dark:bg-muted rounded-[6px]",
pills: "bg-muted dark:bg-muted rounded-full",
underlined: "bg-muted dark:bg-muted rounded-[6px]",
} as const;
/*
XScrollable (internal)
*/
const XScrollable = forwardRef<
HTMLDivElement,
{
className?: string;
children?: ReactNode;
showScrollbar?: boolean;
contentClassName?: string;
} & React.HTMLAttributes<HTMLDivElement>
>(({ className, children, showScrollbar = true, contentClassName, ...props }, ref) => {
const scrollRef = useRef<HTMLDivElement | null>(null);
const dragging = useRef(false);
const startX = useRef(0);
const startScrollLeft = useRef(0);
const [scrollPos, setScrollPos] = useState<"start" | "middle" | "end">("start");
const updateScrollPos = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
const canScroll = el.scrollWidth > el.clientWidth + 1;
if (!canScroll) {
setScrollPos("start");
return;
}
const atStart = el.scrollLeft <= 2;
const atEnd = el.scrollWidth - el.scrollLeft - el.clientWidth <= 2;
setScrollPos(atStart ? "start" : atEnd ? "end" : "middle");
}, []);
useEffect(() => {
updateScrollPos();
const el = scrollRef.current;
if (!el) return;
const ro = new ResizeObserver(updateScrollPos);
ro.observe(el);
return () => ro.disconnect();
}, [updateScrollPos]);
const onMouseDown = (e: React.MouseEvent) => {
if (!scrollRef.current) return;
dragging.current = true;
startX.current = e.clientX;
startScrollLeft.current = scrollRef.current.scrollLeft;
};
const endDrag = () => {
dragging.current = false;
};
const onMouseMove = (e: React.MouseEvent) => {
if (!dragging.current || !scrollRef.current) return;
e.preventDefault();
const dx = e.clientX - startX.current;
scrollRef.current.scrollLeft = startScrollLeft.current - dx;
};
const onWheel = (e: React.WheelEvent) => {
if (!scrollRef.current) return;
const delta = Math.abs(e.deltaY) > Math.abs(e.deltaX) ? e.deltaY : e.deltaX;
if (delta !== 0) {
e.preventDefault();
scrollRef.current.scrollLeft += delta;
}
};
const handleScroll = useCallback(() => {
updateScrollPos();
}, [updateScrollPos]);
const maskStart = scrollPos === "start" ? "black" : "transparent";
const maskEnd = scrollPos === "end" ? "black" : "transparent";
const maskImage = `linear-gradient(to right, ${maskStart}, black 24px, black calc(100% - 24px), ${maskEnd})`;
return (
// biome-ignore lint/a11y/noStaticElementInteractions: drag-scroll container needs mouse events
<div
ref={ref}
className={cn("relative", className)}
{...props}
onMouseLeave={endDrag}
onMouseUp={endDrag}
onMouseMove={onMouseMove}
>
{/* biome-ignore lint/a11y/noStaticElementInteractions: drag-scroll requires onMouseDown */}
<div
ref={scrollRef}
className={cn(
"overflow-x-auto overflow-y-hidden whitespace-nowrap [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden",
!showScrollbar && "scrollbar-none",
contentClassName
)}
style={{
maskImage,
WebkitMaskImage: maskImage,
}}
onWheel={onWheel}
onMouseDown={onMouseDown}
onScroll={handleScroll}
>
{children}
</div>
</div>
);
});
XScrollable.displayName = "XScrollable";
/*
Tabs (root)
*/
const Tabs = forwardRef<
HTMLDivElement,
{
defaultValue?: string;
value?: string;
onValueChange?: (value: string) => void;
className?: string;
children?: ReactNode;
}
>(({ defaultValue, value, onValueChange, className, children, ...props }, ref) => {
const [activeValue, setActiveValue] = useState(value || defaultValue || "");
useEffect(() => {
if (value !== undefined) {
setActiveValue(value);
}
}, [value]);
const handleValueChange = useCallback(
(newValue: string) => {
if (value === undefined) {
setActiveValue(newValue);
}
onValueChange?.(newValue);
},
[onValueChange, value]
);
return (
<TabsContext.Provider value={{ activeValue, onValueChange: handleValueChange }}>
<div ref={ref} className={cn("tabs-container", className)} {...props}>
{children}
</div>
</TabsContext.Provider>
);
});
Tabs.displayName = "Tabs";
/*
TabsList
*/
type TabsListVariant = "default" | "pills" | "underlined";
type TabsListSize = "sm" | "md" | "lg";
const TabsList = forwardRef<
HTMLDivElement,
{
className?: string;
children?: ReactNode;
showHoverEffect?: boolean;
showActiveIndicator?: boolean;
activeIndicatorPosition?: "top" | "bottom";
activeIndicatorOffset?: number;
size?: TabsListSize;
variant?: TabsListVariant;
stretch?: boolean;
ariaLabel?: string;
showBottomBorder?: boolean;
bottomBorderClassName?: string;
activeIndicatorClassName?: string;
hoverIndicatorClassName?: string;
}
>(
(
{
className,
children,
showHoverEffect = true,
showActiveIndicator = true,
activeIndicatorPosition = "bottom",
activeIndicatorOffset = 0,
size = "sm",
variant = "default",
stretch = false,
ariaLabel = "Tabs",
showBottomBorder = false,
bottomBorderClassName,
activeIndicatorClassName,
hoverIndicatorClassName,
...props
},
ref
) => {
const { activeValue, onValueChange } = useTabsContext();
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const [hoverStyle, setHoverStyle] = useState({});
const [activeStyle, setActiveStyle] = useState({
left: "0px",
width: "0px",
});
const tabRefs = useRef<(HTMLDivElement | null)[]>([]);
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const activeIndex = React.Children.toArray(children).findIndex(
(child) =>
React.isValidElement(child) &&
(child as React.ReactElement<{ value: string }>).props.value === activeValue
);
useEffect(() => {
if (hoveredIndex !== null && showHoverEffect) {
const hoveredElement = tabRefs.current[hoveredIndex];
if (hoveredElement) {
const { offsetLeft, offsetWidth } = hoveredElement;
setHoverStyle({
left: `${offsetLeft}px`,
width: `${offsetWidth}px`,
});
}
}
}, [hoveredIndex, showHoverEffect]);
const updateActiveIndicator = useCallback(() => {
if (showActiveIndicator && activeIndex >= 0) {
const activeElement = tabRefs.current[activeIndex];
if (activeElement) {
const { offsetLeft, offsetWidth } = activeElement;
setActiveStyle({
left: `${offsetLeft}px`,
width: `${offsetWidth}px`,
});
}
}
}, [showActiveIndicator, activeIndex]);
useEffect(() => {
updateActiveIndicator();
}, [updateActiveIndicator]);
useEffect(() => {
requestAnimationFrame(updateActiveIndicator);
}, [updateActiveIndicator]);
const scrollTabToCenter = useCallback((index: number) => {
const tabElement = tabRefs.current[index];
const scrollContainer = scrollContainerRef.current;
if (tabElement && scrollContainer) {
const containerWidth = scrollContainer.offsetWidth;
const tabWidth = tabElement.offsetWidth;
const tabLeft = tabElement.offsetLeft;
const scrollTarget = tabLeft - containerWidth / 2 + tabWidth / 2;
scrollContainer.scrollTo({ left: scrollTarget, behavior: "smooth" });
}
}, []);
const setTabRef = useCallback((el: HTMLDivElement | null, index: number) => {
tabRefs.current[index] = el;
}, []);
const handleScrollableRef = useCallback((node: HTMLDivElement | null) => {
if (node) {
const scrollableDiv = node.querySelector('div[class*="overflow-x-auto"]');
if (scrollableDiv) {
scrollContainerRef.current = scrollableDiv as HTMLDivElement;
}
}
}, []);
useEffect(() => {
if (activeIndex >= 0) {
const timer = setTimeout(() => {
scrollTabToCenter(activeIndex);
}, 100);
return () => clearTimeout(timer);
}
}, [activeIndex, scrollTabToCenter]);
return (
<div
ref={handleScrollableRef}
className={cn("relative", className)}
role="tablist"
aria-label={ariaLabel}
{...props}
>
{showBottomBorder && (
<div
className={cn(
"absolute bottom-0 left-0 right-0 h-px bg-border dark:bg-border z-0",
bottomBorderClassName
)}
/>
)}
<XScrollable showScrollbar={false}>
<div className={cn("relative", showBottomBorder && "pb-px")}>
{showHoverEffect && (
<div
className={cn(
"absolute transition-all duration-300 ease-out flex items-center z-0",
SIZE_CLASSES[size],
HOVER_INDICATOR_CLASSES[variant],
hoverIndicatorClassName
)}
style={{
...hoverStyle,
opacity: hoveredIndex !== null ? 1 : 0,
transition: "all 300ms ease-out",
}}
aria-hidden="true"
/>
)}
<div
ref={ref}
className={cn(
"relative flex items-center",
stretch ? "w-full" : "",
variant === "default" ? "space-x-[6px]" : "space-x-[2px]"
)}
>
{React.Children.map(children, (child, index) => {
if (!React.isValidElement(child)) return child;
const childProps = (
child as React.ReactElement<{
value: string;
disabled?: boolean;
label?: string;
className?: string;
activeClassName?: string;
inactiveClassName?: string;
disabledClassName?: string;
}>
).props;
const { value, disabled } = childProps;
const isActive = value === activeValue;
return (
<div
key={value}
ref={(el) => setTabRef(el, index)}
className={cn(
"px-3 py-2 sm:mb-1.5 mb-2 cursor-pointer transition-colors duration-300",
SIZE_CLASSES[size],
variant === "pills" && isActive
? "bg-[#0e0f1114] dark:bg-[#ffffff1a] rounded-full"
: "",
disabled ? "opacity-50 cursor-not-allowed" : "",
stretch ? "flex-1 text-center" : "",
isActive
? childProps.activeClassName || "text-foreground dark:text-foreground"
: childProps.inactiveClassName ||
"text-muted-foreground dark:text-muted-foreground",
disabled && childProps.disabledClassName,
VARIANT_CLASSES[variant],
childProps.className
)}
onMouseEnter={() => !disabled && setHoveredIndex(index)}
onMouseLeave={() => setHoveredIndex(null)}
onClick={() => {
if (!disabled) {
onValueChange(value);
scrollTabToCenter(index);
}
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (!disabled) {
onValueChange(value);
scrollTabToCenter(index);
}
}
}}
role="tab"
aria-selected={isActive}
aria-disabled={disabled}
aria-controls={`tabpanel-${value}`}
id={`tab-${value}`}
tabIndex={isActive ? 0 : -1}
>
<div className="whitespace-nowrap flex items-center justify-center h-full">
{child}
</div>
</div>
);
})}
</div>
{showActiveIndicator && variant !== "pills" && activeIndex >= 0 && (
<div
className={cn(
"absolute transition-all duration-300 ease-out z-10",
ACTIVE_INDICATOR_CLASSES[variant],
activeIndicatorPosition === "top" ? "top-[-1px]" : "bottom-[-1px]",
activeIndicatorClassName
)}
style={{
...activeStyle,
transition: "all 300ms ease-out",
[activeIndicatorPosition]: `${activeIndicatorOffset}px`,
}}
aria-hidden="true"
/>
)}
</div>
</XScrollable>
</div>
);
}
);
TabsList.displayName = "TabsList";
/*
TabsTrigger
*/
const TabsTrigger = forwardRef<
HTMLDivElement,
{
value: string;
disabled?: boolean;
label?: string;
className?: string;
activeClassName?: string;
inactiveClassName?: string;
disabledClassName?: string;
children?: ReactNode;
}
>(
(
{
value,
disabled = false,
label,
className,
activeClassName,
inactiveClassName,
disabledClassName,
children,
...props
},
ref
) => {
return (
<div ref={ref} className={cn("flex items-center", className)} {...props}>
{label || children}
</div>
);
}
);
TabsTrigger.displayName = "TabsTrigger";
/*
TabsContent
*/
const TabsContent = forwardRef<
HTMLDivElement,
{
value: string;
className?: string;
children: ReactNode;
}
>(({ value, className, children, ...props }, ref) => {
const { activeValue } = useTabsContext();
if (value !== activeValue) return null;
return (
<div
ref={ref}
role="tabpanel"
id={`tabpanel-${value}`}
aria-labelledby={`tab-${value}`}
className={className}
{...props}
>
{children}
</div>
);
});
TabsContent.displayName = "TabsContent";
export { Tabs, TabsList, TabsTrigger, TabsContent };

View file

@ -1,100 +0,0 @@
import { Slot } from "@radix-ui/react-slot";
import { ChevronRight, MoreHorizontal } from "lucide-react";
import { cn } from "@/lib/utils";
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
className
)}
{...props}
/>
);
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
);
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "a";
return (
<Comp
data-slot="breadcrumb-link"
className={cn("hover:text-foreground transition-colors", className)}
{...props}
/>
);
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
aria-disabled="true"
aria-current="page"
className={cn("text-foreground font-normal", className)}
{...props}
/>
);
}
function BreadcrumbSeparator({ children, className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
);
}
function BreadcrumbEllipsis({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
);
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};

View file

@ -88,11 +88,14 @@ function Calendar({
"relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none", "relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
defaultClassNames.day defaultClassNames.day
), ),
range_start: cn("rounded-l-md bg-accent", defaultClassNames.range_start), range_start: cn(
"rounded-l-md bg-accent dark:bg-neutral-700",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle), range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end), range_end: cn("rounded-r-md bg-accent dark:bg-neutral-700", defaultClassNames.range_end),
today: cn( today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none", "bg-accent dark:bg-neutral-700 text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today defaultClassNames.today
), ),
outside: cn( outside: cn(
@ -164,7 +167,7 @@ function CalendarDayButton({
data-range-end={modifiers.range_end} data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle} data-range-middle={modifiers.range_middle}
className={cn( className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70", "data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent dark:data-[range-middle=true]:bg-neutral-700 data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:bg-neutral-700 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day, defaultClassNames.day,
className className
)} )}

View file

@ -66,7 +66,7 @@ function ContextMenuSubContent({
<ContextMenuPrimitive.SubContent <ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content" data-slot="context-menu-sub-content"
className={cn( className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg", "bg-muted text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border dark:border-neutral-700 p-1 shadow-lg",
className className
)} )}
{...props} {...props}
@ -83,7 +83,7 @@ function ContextMenuContent({
<ContextMenuPrimitive.Content <ContextMenuPrimitive.Content
data-slot="context-menu-content" data-slot="context-menu-content"
className={cn( className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md", "bg-muted text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border dark:border-neutral-700 p-1 shadow-md",
className className
)} )}
{...props} {...props}
@ -107,8 +107,7 @@ function ContextMenuItem({
data-inset={inset} data-inset={inset}
data-variant={variant} data-variant={variant}
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground", "focus:bg-accent focus:text-accent-foreground dark:focus:bg-neutral-700 relative flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
"data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive",
className className
)} )}
{...props} {...props}
@ -190,7 +189,7 @@ function ContextMenuSeparator({
return ( return (
<ContextMenuPrimitive.Separator <ContextMenuPrimitive.Separator
data-slot="context-menu-separator" data-slot="context-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)} className={cn("bg-border dark:bg-neutral-700 -mx-1 my-1 h-px", className)}
{...props} {...props}
/> />
); );

View file

@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
ref={ref} ref={ref}
className={cn( className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", "fixed inset-0 z-50 bg-black/50 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className className
)} )}
{...props} {...props}
@ -38,13 +38,13 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content <DialogPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 rounded-lg focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0", "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 bg-background dark:bg-neutral-900 p-6 shadow-2xl duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 rounded-xl focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0",
className className
)} )}
{...props} {...props}
> >
{children} {children}
<DialogPrimitive.Close className="absolute right-4 top-4 z-50 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"> <DialogPrimitive.Close className="absolute right-4 top-4 z-50 h-8 w-8 rounded-full inline-flex items-center justify-center text-muted-foreground transition-colors hover:text-foreground hover:bg-accent focus:outline-none disabled:pointer-events-none">
<X className="h-4 w-4" /> <X className="h-4 w-4" />
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
</DialogPrimitive.Close> </DialogPrimitive.Close>

View file

@ -33,7 +33,7 @@ function DropdownMenuContent({
data-slot="dropdown-menu-content" data-slot="dropdown-menu-content"
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md", "bg-muted text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border dark:border-neutral-700 p-1 shadow-md",
className className
)} )}
{...props} {...props}
@ -61,7 +61,7 @@ function DropdownMenuItem({
data-inset={inset} data-inset={inset}
data-variant={variant} data-variant={variant}
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground dark:focus:bg-neutral-700 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "focus:bg-neutral-200 focus:text-accent-foreground dark:focus:bg-neutral-700 [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className
)} )}
{...props} {...props}
@ -79,7 +79,7 @@ function DropdownMenuCheckboxItem({
<DropdownMenuPrimitive.CheckboxItem <DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item" data-slot="dropdown-menu-checkbox-item"
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground dark:focus:bg-neutral-700 relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "focus:bg-neutral-200 focus:text-accent-foreground dark:focus:bg-neutral-700 relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className
)} )}
checked={checked} checked={checked}
@ -110,7 +110,7 @@ function DropdownMenuRadioItem({
<DropdownMenuPrimitive.RadioItem <DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item" data-slot="dropdown-menu-radio-item"
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground dark:focus:bg-neutral-700 relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "focus:bg-neutral-200 focus:text-accent-foreground dark:focus:bg-neutral-700 relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className
)} )}
{...props} {...props}
@ -149,7 +149,7 @@ function DropdownMenuSeparator({
return ( return (
<DropdownMenuPrimitive.Separator <DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator" data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)} className={cn("bg-border dark:bg-neutral-700 -mx-1 my-1 h-px", className)}
{...props} {...props}
/> />
); );
@ -182,7 +182,7 @@ function DropdownMenuSubTrigger({
data-slot="dropdown-menu-sub-trigger" data-slot="dropdown-menu-sub-trigger"
data-inset={inset} data-inset={inset}
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground dark:focus:bg-neutral-700 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground dark:data-[state=open]:bg-neutral-700 [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "focus:bg-neutral-200 focus:text-accent-foreground dark:focus:bg-neutral-700 data-[state=open]:bg-neutral-200 data-[state=open]:text-accent-foreground dark:data-[state=open]:bg-neutral-700 [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className
)} )}
{...props} {...props}
@ -201,7 +201,7 @@ function DropdownMenuSubContent({
<DropdownMenuPrimitive.SubContent <DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content" data-slot="dropdown-menu-sub-content"
className={cn( className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg", "bg-muted text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border dark:border-neutral-700 p-1 shadow-lg",
className className
)} )}
{...props} {...props}

View file

@ -4,7 +4,11 @@ import { AnimatePresence, motion } from "motion/react";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
function ExpandedGifOverlay({ function isVideoSrc(src: string) {
return /\.(mp4|webm|ogg)(\?|$)/i.test(src);
}
function ExpandedMediaOverlay({
src, src,
alt, alt,
onClose, onClose,
@ -21,6 +25,31 @@ function ExpandedGifOverlay({
return () => document.removeEventListener("keydown", handleKey); return () => document.removeEventListener("keydown", handleKey);
}, [onClose]); }, [onClose]);
const mediaElement = isVideoSrc(src) ? (
<motion.video
initial={{ scale: 0.85, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.85, opacity: 0 }}
transition={{ duration: 0.25, ease: "easeOut" }}
src={src}
autoPlay
loop
muted
playsInline
className="max-h-[90vh] max-w-[90vw] cursor-pointer rounded-2xl shadow-2xl"
/>
) : (
<motion.img
initial={{ scale: 0.85, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.85, opacity: 0 }}
transition={{ duration: 0.25, ease: "easeOut" }}
src={src}
alt={alt}
className="max-h-[90vh] max-w-[90vw] cursor-pointer rounded-2xl shadow-2xl"
/>
);
return createPortal( return createPortal(
<motion.div <motion.div
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
@ -30,25 +59,22 @@ function ExpandedGifOverlay({
className="fixed inset-0 z-100 flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm sm:p-8" className="fixed inset-0 z-100 flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm sm:p-8"
onClick={onClose} onClick={onClose}
> >
<motion.img {mediaElement}
initial={{ scale: 0.85, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.85, opacity: 0 }}
transition={{ duration: 0.25, ease: "easeOut" }}
src={src}
alt={alt}
className="max-h-[90vh] max-w-[90vw] cursor-pointer rounded-2xl shadow-2xl"
/>
</motion.div>, </motion.div>,
document.body document.body
); );
} }
function useExpandedGif() { function useExpandedMedia() {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const open = useCallback(() => setExpanded(true), []); const open = useCallback(() => setExpanded(true), []);
const close = useCallback(() => setExpanded(false), []); const close = useCallback(() => setExpanded(false), []);
return { expanded, open, close }; return { expanded, open, close };
} }
export { ExpandedGifOverlay, useExpandedGif }; /** @deprecated Use ExpandedMediaOverlay instead */
const ExpandedGifOverlay = ExpandedMediaOverlay;
/** @deprecated Use useExpandedMedia instead */
const useExpandedGif = useExpandedMedia;
export { ExpandedMediaOverlay, useExpandedMedia, ExpandedGifOverlay, useExpandedGif };

View file

@ -13,9 +13,9 @@ import {
} from "lucide-react"; } from "lucide-react";
import { KEYS } from "platejs"; import { KEYS } from "platejs";
import { useEditorReadOnly, useEditorRef } from "platejs/react"; import { useEditorReadOnly, useEditorRef } from "platejs/react";
import * as React from "react";
import { useEditorSave } from "@/components/editor/editor-save-context"; import { useEditorSave } from "@/components/editor/editor-save-context";
import { ShortcutKbd } from "@/components/ui/shortcut-kbd";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { usePlatformShortcut } from "@/hooks/use-platform-shortcut"; import { usePlatformShortcut } from "@/hooks/use-platform-shortcut";
@ -26,11 +26,20 @@ import { ModeToolbarButton } from "./mode-toolbar-button";
import { ToolbarButton, ToolbarGroup } from "./toolbar"; import { ToolbarButton, ToolbarGroup } from "./toolbar";
import { TurnIntoToolbarButton } from "./turn-into-toolbar-button"; import { TurnIntoToolbarButton } from "./turn-into-toolbar-button";
function TooltipWithShortcut({ label, keys }: { label: string; keys: string[] }) {
return (
<span className="flex items-center">
{label}
<ShortcutKbd keys={keys} />
</span>
);
}
export function FixedToolbarButtons() { export function FixedToolbarButtons() {
const readOnly = useEditorReadOnly(); const readOnly = useEditorReadOnly();
const editor = useEditorRef(); const editor = useEditorRef();
const { onSave, hasUnsavedChanges, isSaving, canToggleMode } = useEditorSave(); const { onSave, hasUnsavedChanges, isSaving, canToggleMode } = useEditorSave();
const { shortcut } = usePlatformShortcut(); const { shortcutKeys } = usePlatformShortcut();
return ( return (
<div className="flex w-full items-center"> <div className="flex w-full items-center">
@ -40,7 +49,7 @@ export function FixedToolbarButtons() {
<> <>
<ToolbarGroup> <ToolbarGroup>
<ToolbarButton <ToolbarButton
tooltip={`Undo ${shortcut("Mod", "Z")}`} tooltip={<TooltipWithShortcut label="Undo" keys={shortcutKeys("Mod", "Z")} />}
onClick={() => { onClick={() => {
editor.undo(); editor.undo();
editor.tf.focus(); editor.tf.focus();
@ -50,7 +59,9 @@ export function FixedToolbarButtons() {
</ToolbarButton> </ToolbarButton>
<ToolbarButton <ToolbarButton
tooltip={`Redo ${shortcut("Mod", "Shift", "Z")}`} tooltip={
<TooltipWithShortcut label="Redo" keys={shortcutKeys("Mod", "Shift", "Z")} />
}
onClick={() => { onClick={() => {
editor.redo(); editor.redo();
editor.tf.focus(); editor.tf.focus();
@ -66,35 +77,51 @@ export function FixedToolbarButtons() {
</ToolbarGroup> </ToolbarGroup>
<ToolbarGroup> <ToolbarGroup>
<MarkToolbarButton nodeType={KEYS.bold} tooltip={`Bold ${shortcut("Mod", "B")}`}> <MarkToolbarButton
nodeType={KEYS.bold}
tooltip={<TooltipWithShortcut label="Bold" keys={shortcutKeys("Mod", "B")} />}
>
<BoldIcon /> <BoldIcon />
</MarkToolbarButton> </MarkToolbarButton>
<MarkToolbarButton nodeType={KEYS.italic} tooltip={`Italic ${shortcut("Mod", "I")}`}> <MarkToolbarButton
nodeType={KEYS.italic}
tooltip={<TooltipWithShortcut label="Italic" keys={shortcutKeys("Mod", "I")} />}
>
<ItalicIcon /> <ItalicIcon />
</MarkToolbarButton> </MarkToolbarButton>
<MarkToolbarButton <MarkToolbarButton
nodeType={KEYS.underline} nodeType={KEYS.underline}
tooltip={`Underline ${shortcut("Mod", "U")}`} tooltip={<TooltipWithShortcut label="Underline" keys={shortcutKeys("Mod", "U")} />}
> >
<UnderlineIcon /> <UnderlineIcon />
</MarkToolbarButton> </MarkToolbarButton>
<MarkToolbarButton <MarkToolbarButton
nodeType={KEYS.strikethrough} nodeType={KEYS.strikethrough}
tooltip={`Strikethrough ${shortcut("Mod", "Shift", "X")}`} tooltip={
<TooltipWithShortcut
label="Strikethrough"
keys={shortcutKeys("Mod", "Shift", "X")}
/>
}
> >
<StrikethroughIcon /> <StrikethroughIcon />
</MarkToolbarButton> </MarkToolbarButton>
<MarkToolbarButton nodeType={KEYS.code} tooltip={`Code ${shortcut("Mod", "E")}`}> <MarkToolbarButton
nodeType={KEYS.code}
tooltip={<TooltipWithShortcut label="Code" keys={shortcutKeys("Mod", "E")} />}
>
<Code2Icon /> <Code2Icon />
</MarkToolbarButton> </MarkToolbarButton>
<MarkToolbarButton <MarkToolbarButton
nodeType={KEYS.highlight} nodeType={KEYS.highlight}
tooltip={`Highlight ${shortcut("Mod", "Shift", "H")}`} tooltip={
<TooltipWithShortcut label="Highlight" keys={shortcutKeys("Mod", "Shift", "H")} />
}
> >
<HighlighterIcon /> <HighlighterIcon />
</MarkToolbarButton> </MarkToolbarButton>
@ -113,7 +140,13 @@ export function FixedToolbarButtons() {
{!readOnly && onSave && hasUnsavedChanges && ( {!readOnly && onSave && hasUnsavedChanges && (
<ToolbarGroup> <ToolbarGroup>
<ToolbarButton <ToolbarButton
tooltip={isSaving ? "Saving..." : `Save ${shortcut("Mod", "S")}`} tooltip={
isSaving ? (
"Saving..."
) : (
<TooltipWithShortcut label="Save" keys={shortcutKeys("Mod", "S")} />
)
}
onClick={onSave} onClick={onSave}
disabled={isSaving} disabled={isSaving}
className="bg-primary text-primary-foreground hover:bg-primary/90" className="bg-primary text-primary-foreground hover:bg-primary/90"

View file

@ -14,7 +14,7 @@ export function FixedToolbar({
return ( return (
<Toolbar <Toolbar
className={cn( className={cn(
"scrollbar-hide sticky top-0 left-0 z-50 w-full justify-between overflow-x-auto rounded-t-lg border-b bg-background/95 p-1 backdrop-blur supports-backdrop-filter:bg-background/60", "scrollbar-hide sticky top-0 left-0 z-10 w-full justify-between overflow-x-auto rounded-t-lg border-b bg-background/95 p-1 backdrop-blur supports-backdrop-filter:bg-background/60",
className className
)} )}
{...props} {...props}

View file

@ -65,7 +65,7 @@ export function FloatingToolbar({
{...rootProps} {...rootProps}
ref={ref} ref={ref}
className={cn( className={cn(
"scrollbar-hide absolute z-50 overflow-x-auto whitespace-nowrap rounded-md border bg-popover p-1 opacity-100 shadow-md print:hidden dark:bg-neutral-800 dark:border-neutral-700", "scrollbar-hide absolute z-50 overflow-x-auto whitespace-nowrap rounded-md border bg-popover p-1 opacity-100 shadow-md print:hidden dark:bg-neutral-900 dark:border-white/5",
"max-w-[80vw]", "max-w-[80vw]",
className className
)} )}

View file

@ -9,59 +9,57 @@ const carouselItems = [
title: "Connect & Sync", title: "Connect & Sync",
description: description:
"Connect data sources like Notion, Drive and Gmail. Automatically sync to keep them updated.", "Connect data sources like Notion, Drive and Gmail. Automatically sync to keep them updated.",
src: "/homepage/hero_tutorial/ConnectorFlowGif.gif", src: "/homepage/hero_tutorial/ConnectorFlowGif.mp4",
}, },
{ {
title: "Upload Documents", title: "Upload Documents",
description: "Upload documents directly, from images to massive PDFs.", description: "Upload documents directly, from images to massive PDFs.",
src: "/homepage/hero_tutorial/DocUploadGif.gif", src: "/homepage/hero_tutorial/DocUploadGif.mp4",
}, },
{ {
title: "Search & Citation", title: "Search & Citation",
description: "Ask questions and get cited responses from your knowledge base.", description: "Ask questions and get cited responses from your knowledge base.",
src: "/homepage/hero_tutorial/BSNCGif.gif", src: "/homepage/hero_tutorial/BSNCGif.mp4",
}, },
{ {
title: "Targeted Document Q&A", title: "Targeted Document Q&A",
description: "Mention specific documents in chat for targeted answers.", description: "Mention specific documents in chat for targeted answers.",
src: "/homepage/hero_tutorial/BQnaGif_compressed.gif", src: "/homepage/hero_tutorial/BQnaGif_compressed.mp4",
}, },
{ {
title: "Produce Reports Instantly", title: "Produce Reports Instantly",
description: "Generate reports from your sources in many formats.", description: "Generate reports from your sources in many formats.",
src: "/homepage/hero_tutorial/ReportGenGif_compressed.gif", src: "/homepage/hero_tutorial/ReportGenGif_compressed.mp4",
}, },
{ {
title: "Create Podcasts", title: "Create Podcasts",
description: "Turn anything into a podcast in under 20 seconds.", description: "Turn anything into a podcast in under 20 seconds.",
src: "/homepage/hero_tutorial/PodcastGenGif.gif", src: "/homepage/hero_tutorial/PodcastGenGif.mp4",
}, },
{ {
title: "Image Generation", title: "Image Generation",
description: "Generate high-quality images easily from your conversations.", description: "Generate high-quality images easily from your conversations.",
src: "/homepage/hero_tutorial/ImageGenGif.gif", src: "/homepage/hero_tutorial/ImageGenGif.mp4",
}, },
{ {
title: "Collaborative AI Chat", title: "Collaborative AI Chat",
description: "Collaborate on AI-powered conversations in realtime with your team.", description: "Collaborate on AI-powered conversations in realtime with your team.",
src: "/homepage/hero_realtime/RealTimeChatGif.gif", src: "/homepage/hero_realtime/RealTimeChatGif.mp4",
}, },
{ {
title: "Realtime Comments", title: "Realtime Comments",
description: "Add comments and tag teammates on any message.", description: "Add comments and tag teammates on any message.",
src: "/homepage/hero_realtime/RealTimeCommentsFlow.gif", src: "/homepage/hero_realtime/RealTimeCommentsFlow.mp4",
}, },
]; ];
function HeroCarouselCard({ function HeroCarouselCard({
index,
title, title,
description, description,
src, src,
isActive, isActive,
onExpandedChange, onExpandedChange,
}: { }: {
index: number;
title: string; title: string;
description: string; description: string;
src: string; src: string;
@ -69,53 +67,50 @@ function HeroCarouselCard({
onExpandedChange?: (expanded: boolean) => void; onExpandedChange?: (expanded: boolean) => void;
}) { }) {
const { expanded, open, close } = useExpandedGif(); const { expanded, open, close } = useExpandedGif();
const videoRef = useRef<HTMLVideoElement>(null);
const [frozenFrame, setFrozenFrame] = useState<string | null>(null);
const [hasLoaded, setHasLoaded] = useState(false);
useEffect(() => { useEffect(() => {
onExpandedChange?.(expanded); onExpandedChange?.(expanded);
}, [expanded, onExpandedChange]); }, [expanded, onExpandedChange]);
const imgRef = useRef<HTMLImageElement>(null);
const [frozenFrame, setFrozenFrame] = useState<string | null>(null);
const [playKey, setPlayKey] = useState(0);
const captureFrame = useCallback((img: HTMLImageElement) => { const captureFrame = useCallback((video: HTMLVideoElement) => {
try { try {
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
canvas.width = img.naturalWidth; canvas.width = video.videoWidth;
canvas.height = img.naturalHeight; canvas.height = video.videoHeight;
canvas.getContext("2d")?.drawImage(img, 0, 0); canvas.getContext("2d")?.drawImage(video, 0, 0);
setFrozenFrame(canvas.toDataURL()); setFrozenFrame(canvas.toDataURL("image/jpeg", 0.85));
} catch { } catch {
/* cross-origin or other issue */ /* tainted canvas */
} }
}, []); }, []);
useEffect(() => { useEffect(() => {
const video = videoRef.current;
if (isActive) { if (isActive) {
setPlayKey((k) => k + 1); setHasLoaded(false);
setFrozenFrame(null); if (video) {
video.currentTime = 0;
video.play().catch(() => {});
}
} else { } else {
const img = imgRef.current; if (video) {
if (img && img.complete && img.naturalWidth > 0) { if (video.readyState >= 2) captureFrame(video);
captureFrame(img); video.pause();
} }
} }
}, [isActive, captureFrame]); }, [isActive, captureFrame]);
useEffect(() => { const handleCanPlay = useCallback(() => {
if (!isActive && !frozenFrame) { setHasLoaded(true);
const img = new Image(); }, []);
img.onload = () => captureFrame(img);
img.src = src;
}
}, [isActive, frozenFrame, src, captureFrame]);
return ( return (
<> <>
<div className="rounded-2xl border border-neutral-200/60 bg-white shadow-xl sm:rounded-3xl dark:border-neutral-700/60 dark:bg-neutral-900"> <div className="rounded-2xl border border-neutral-200/60 bg-white shadow-xl sm:rounded-3xl dark:border-neutral-700/60 dark:bg-neutral-900">
<div className="flex items-center gap-3 border-b border-neutral-200/60 px-4 py-3 sm:px-6 sm:py-4 dark:border-neutral-700/60"> <div className="flex items-center gap-3 border-b border-neutral-200/60 px-4 py-3 sm:px-6 sm:py-4 dark:border-neutral-700/60">
{/* <span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-xs font-semibold text-white sm:h-8 sm:w-8 sm:text-sm dark:bg-white dark:text-neutral-900">
{index + 1}
</span> */}
<div className="min-w-0"> <div className="min-w-0">
<h3 className="truncate text-base font-semibold text-neutral-900 sm:text-xl dark:text-white"> <h3 className="truncate text-base font-semibold text-neutral-900 sm:text-xl dark:text-white">
{title} {title}
@ -130,13 +125,28 @@ function HeroCarouselCard({
onClick={isActive ? open : undefined} onClick={isActive ? open : undefined}
> >
{isActive ? ( {isActive ? (
<img <div className="relative">
ref={imgRef} <video
key={`gif_${index}_${playKey}`} ref={videoRef}
src={src} src={src}
alt={title} autoPlay
className="w-full rounded-lg sm:rounded-xl" loop
/> muted
playsInline
onCanPlay={handleCanPlay}
className="w-full rounded-lg sm:rounded-xl"
/>
{!hasLoaded && frozenFrame && (
<img
src={frozenFrame}
alt={title}
className="absolute inset-0 w-full rounded-lg sm:rounded-xl"
/>
)}
{!hasLoaded && !frozenFrame && (
<div className="aspect-video w-full animate-pulse rounded-lg bg-neutral-100 sm:rounded-xl dark:bg-neutral-800" />
)}
</div>
) : frozenFrame ? ( ) : frozenFrame ? (
<img src={frozenFrame} alt={title} className="w-full rounded-lg sm:rounded-xl" /> <img src={frozenFrame} alt={title} className="w-full rounded-lg sm:rounded-xl" />
) : ( ) : (
@ -284,7 +294,6 @@ function HeroCarousel() {
transition={{ duration: 0.7, ease: [0.32, 0.72, 0, 1] }} transition={{ duration: 0.7, ease: [0.32, 0.72, 0, 1] }}
> >
<HeroCarouselCard <HeroCarouselCard
index={i}
title={item.title} title={item.title}
description={item.description} description={item.description}
src={item.src} src={item.src}

Some files were not shown because too many files have changed in this diff Show more