Merge remote-tracking branch 'upstream/dev' into feat/replace-logs

This commit is contained in:
Anish Sarkar 2026-01-13 11:52:46 +05:30
commit 7a92ecc1ab
56 changed files with 2451 additions and 808 deletions

View file

@ -8,6 +8,8 @@ import { Button } from "@/components/ui/button";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { LogActiveTask } from "@/contracts/types/log.types";
import { cn } from "@/lib/utils";
import { useConnectorStatus } from "../hooks/use-connector-status";
import { ConnectorStatusBadge } from "./connector-status-badge";
interface ConnectorCardProps {
id: string;
@ -104,6 +106,15 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
onConnect,
onManage,
}) => {
// Get connector status
const { getConnectorStatus, isConnectorEnabled, getConnectorStatusMessage, shouldShowWarnings } =
useConnectorStatus();
const status = getConnectorStatus(connectorType);
const isEnabled = isConnectorEnabled(connectorType);
const statusMessage = getConnectorStatusMessage(connectorType);
const showWarnings = shouldShowWarnings();
// Extract count from active task message during indexing
const indexingCount = extractIndexedCount(activeTask?.message);
@ -139,9 +150,23 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
return description;
};
return (
<div className="group relative flex items-center gap-4 p-4 rounded-xl text-left transition-all duration-200 w-full border border-border bg-slate-400/5 dark:bg-white/5 hover:bg-slate-400/10 dark:hover:bg-white/10">
<div className="flex h-12 w-12 items-center justify-center rounded-lg transition-colors shrink-0 bg-slate-400/5 dark:bg-white/5 border border-slate-400/5 dark:border-white/5">
const cardContent = (
<div
className={cn(
"group relative flex items-center gap-4 p-4 rounded-xl text-left transition-all duration-200 w-full border",
status.status === "warning"
? "border-yellow-500/30 bg-slate-400/5 dark:bg-white/5 hover:bg-slate-400/10 dark:hover:bg-white/10"
: "border-border bg-slate-400/5 dark:bg-white/5 hover:bg-slate-400/10 dark:hover:bg-white/10"
)}
>
<div
className={cn(
"flex h-12 w-12 items-center justify-center rounded-lg transition-colors shrink-0 border",
status.status === "warning"
? "bg-yellow-500/10 border-yellow-500/20 bg-slate-400/5 dark:bg-white/5 border-slate-400/5 dark:border-white/5"
: "bg-slate-400/5 dark:bg-white/5 border-slate-400/5 dark:border-white/5"
)}
>
{connectorType ? (
getConnectorIcon(connectorType, "size-6")
) : id === "youtube-crawler" ? (
@ -151,8 +176,15 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5">
<span className="text-[14px] font-semibold leading-tight truncate">{title}</span>
{showWarnings && status.status !== "active" && (
<ConnectorStatusBadge
status={status.status}
statusMessage={statusMessage}
className="flex-shrink-0"
/>
)}
</div>
<div className="text-[10px] text-muted-foreground mt-1">{getStatusContent()}</div>
{isConnected && documentCount !== undefined && (
@ -179,10 +211,12 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
!isConnected && "shadow-xs"
)}
onClick={isConnected ? onManage : onConnect}
disabled={isConnecting}
disabled={isConnecting || !isEnabled}
>
{isConnecting ? (
<Loader2 className="size-3 animate-spin" />
) : !isEnabled ? (
"Unavailable"
) : isConnected ? (
"Manage"
) : id === "youtube-crawler" ? (
@ -195,4 +229,6 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
</Button>
</div>
);
return cardContent;
};

View file

@ -0,0 +1,92 @@
"use client";
import { AlertTriangle, Ban, Wrench } from "lucide-react";
import type { FC } from "react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import type { ConnectorStatus } from "../config/connector-status-config";
interface ConnectorStatusBadgeProps {
status: ConnectorStatus;
statusMessage?: string | null;
className?: string;
}
export const ConnectorStatusBadge: FC<ConnectorStatusBadgeProps> = ({
status,
statusMessage,
className,
}) => {
if (status === "active") {
return null;
}
const getBadgeConfig = () => {
switch (status) {
case "warning":
return {
icon: AlertTriangle,
className: "text-yellow-500 dark:text-yellow-400",
defaultTitle: "Warning",
};
case "disabled":
return {
icon: Ban,
className: "text-red-500 dark:text-red-400",
defaultTitle: "Disabled",
};
case "maintenance":
return {
icon: Wrench,
className: "text-orange-500 dark:text-orange-400",
defaultTitle: "Maintenance",
};
case "deprecated":
return {
icon: AlertTriangle,
className: "ext-slate-500 dark:text-slate-400",
defaultTitle: "Deprecated",
};
default:
return null;
}
};
const config = getBadgeConfig();
if (!config) return null;
const Icon = config.icon;
// Show statusMessage in tooltip for warning, deprecated, disabled, and maintenance statuses
const shouldUseTooltip =
(status === "warning" ||
status === "deprecated" ||
status === "disabled" ||
status === "maintenance") &&
statusMessage;
const tooltipTitle = shouldUseTooltip ? statusMessage : config.defaultTitle;
// Use Tooltip component for statuses with statusMessage, native title for others
if (shouldUseTooltip) {
return (
<Tooltip>
<TooltipTrigger asChild>
<span className={cn("inline-flex items-center justify-center shrink-0", className)}>
<Icon className={cn("size-3.5", config.className)} />
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
{statusMessage}
</TooltipContent>
</Tooltip>
);
}
return (
<span
className={cn("inline-flex items-center justify-center shrink-0", className)}
title={tooltipTitle}
>
<Icon className={cn("size-3.5", config.className)} />
</span>
);
};

View file

@ -0,0 +1,56 @@
"use client";
import { AlertTriangle, X } from "lucide-react";
import type { FC } from "react";
import { useState } from "react";
import { cn } from "@/lib/utils";
interface ConnectorWarningBannerProps {
warning: string;
statusMessage?: string | null;
onDismiss?: () => void;
className?: string;
}
export const ConnectorWarningBanner: FC<ConnectorWarningBannerProps> = ({
warning,
statusMessage,
onDismiss,
className,
}) => {
const [isDismissed, setIsDismissed] = useState(false);
if (isDismissed) return null;
const handleDismiss = () => {
setIsDismissed(true);
onDismiss?.();
};
return (
<div
className={cn(
"flex items-start gap-3 p-3 rounded-lg border border-yellow-500/30 bg-yellow-500/10 dark:bg-yellow-500/5 mb-4",
className
)}
>
<AlertTriangle className="size-4 text-yellow-600 dark:text-yellow-500 shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="text-[12px] font-medium text-yellow-900 dark:text-yellow-200">{warning}</p>
{statusMessage && (
<p className="text-[11px] text-yellow-700 dark:text-yellow-300 mt-1">{statusMessage}</p>
)}
</div>
{onDismiss && (
<button
type="button"
onClick={handleDismiss}
className="shrink-0 p-0.5 rounded hover:bg-yellow-500/20 transition-colors"
aria-label="Dismiss warning"
>
<X className="size-3.5 text-yellow-700 dark:text-yellow-300" />
</button>
)}
</div>
);
};

View file

@ -0,0 +1,28 @@
{
"connectorStatuses": {
"SLACK_CONNECTOR": {
"enabled": false,
"status": "disabled",
"statusMessage": "Unavailable due to API changes"
},
"NOTION_CONNECTOR": {
"enabled": true,
"status": "warning",
"statusMessage": "Rate limits may apply"
},
"TEAMS_CONNECTOR": {
"enabled": false,
"status": "maintenance",
"statusMessage": "Temporarily unavailable for maintenance"
},
"JIRA_CONNECTOR": {
"enabled": false,
"status": "deprecated",
"statusMessage": "Deprecated"
}
},
"globalSettings": {
"showWarnings": true,
"allowManualOverride": false
}
}

View file

@ -0,0 +1,38 @@
{
"connectorStatuses": {
"GOOGLE_DRIVE_CONNECTOR": {
"enabled": true,
"status": "warning",
"statusMessage": "Our Google OAuth app is not verified. You may see a 'non-verified app' warning during sign-in."
},
"GOOGLE_GMAIL_CONNECTOR": {
"enabled": true,
"status": "warning",
"statusMessage": "Our Google OAuth app is not verified. You may see a 'non-verified app' warning during sign-in."
},
"GOOGLE_CALENDAR_CONNECTOR": {
"enabled": true,
"status": "warning",
"statusMessage": "Our Google OAuth app is not verified. You may see a 'non-verified app' warning during sign-in."
},
"YOUTUBE_CONNECTOR": {
"enabled": true,
"status": "warning",
"statusMessage": "Doesn't work on cloud version due to YouTube blocks. Will be fixed soon."
},
"WEBCRAWLER_CONNECTOR": {
"enabled": true,
"status": "warning",
"statusMessage": "Some requests may be blocked if not using Firecrawl."
},
"GITHUB_CONNECTOR": {
"enabled": false,
"status": "maintenance",
"statusMessage": "Rework in progress."
}
},
"globalSettings": {
"showWarnings": true,
"allowManualOverride": false
}
}

View file

@ -0,0 +1,74 @@
/**
* Connector Status Configuration
*
* Manages connector statuses (disable/enable, status messages). Edit connector-status-config.json to configure.
* Valid status values: "active", "warning", "disabled", "deprecated", "maintenance".
* Unlisted connectors default to "active" and enabled. See connector-status-config.example.json for reference.
*/
import { z } from "zod";
import rawConnectorStatusConfigData from "./connector-status-config.json";
// Zod schemas for runtime validation and type safety
export const connectorStatusSchema = z.enum([
"active",
"warning",
"disabled",
"deprecated",
"maintenance",
]);
export const connectorStatusConfigSchema = z.object({
enabled: z.boolean(),
status: connectorStatusSchema,
statusMessage: z.string().nullable().optional(),
});
export const connectorStatusMapSchema = z.record(z.string(), connectorStatusConfigSchema);
export const connectorStatusConfigFileSchema = z.object({
connectorStatuses: connectorStatusMapSchema,
globalSettings: z.object({
showWarnings: z.boolean(),
allowManualOverride: z.boolean(),
}),
});
// TypeScript types inferred from Zod schemas
export type ConnectorStatus = z.infer<typeof connectorStatusSchema>;
export type ConnectorStatusConfig = z.infer<typeof connectorStatusConfigSchema>;
export type ConnectorStatusMap = z.infer<typeof connectorStatusMapSchema>;
export type ConnectorStatusConfigFile = z.infer<typeof connectorStatusConfigFileSchema>;
/**
* Validated at runtime via Zod schema; invalid JSON throws at module load time.
*/
export const connectorStatusConfig: ConnectorStatusConfigFile =
connectorStatusConfigFileSchema.parse(rawConnectorStatusConfigData);
/**
* Get default status config for a connector (when not in config file)
* Returns a validated default config
*/
export function getDefaultConnectorStatus(): ConnectorStatusConfig {
return connectorStatusConfigSchema.parse({
enabled: true,
status: "active",
statusMessage: null,
});
}
/**
* Validate a connector status config object
* Useful for validating config loaded from external sources
*/
export function validateConnectorStatusConfig(config: unknown): ConnectorStatusConfigFile {
return connectorStatusConfigFileSchema.parse(config);
}
/**
* Validate a single connector status config
*/
export function validateSingleConnectorStatus(config: unknown): ConnectorStatusConfig {
return connectorStatusConfigSchema.parse(config);
}

View file

@ -0,0 +1,55 @@
"use client";
import { useMemo } from "react";
import {
type ConnectorStatusConfig,
connectorStatusConfig,
getDefaultConnectorStatus,
} from "../config/connector-status-config";
/**
* Hook to get connector status information
*/
export function useConnectorStatus() {
/**
* Get status configuration for a specific connector type
*/
const getConnectorStatus = (connectorType: string | undefined): ConnectorStatusConfig => {
if (!connectorType) {
return getDefaultConnectorStatus();
}
return connectorStatusConfig.connectorStatuses[connectorType] || getDefaultConnectorStatus();
};
/**
* Check if a connector is enabled
*/
const isConnectorEnabled = (connectorType: string | undefined): boolean => {
return getConnectorStatus(connectorType).enabled;
};
/**
* Get status message for a connector
*/
const getConnectorStatusMessage = (connectorType: string | undefined): string | null => {
return getConnectorStatus(connectorType).statusMessage || null;
};
/**
* Check if warnings should be shown globally
*/
const shouldShowWarnings = (): boolean => {
return connectorStatusConfig.globalSettings.showWarnings;
};
return useMemo(
() => ({
getConnectorStatus,
isConnectorEnabled,
getConnectorStatusMessage,
shouldShowWarnings,
}),
[]
);
}

View file

@ -8,6 +8,7 @@ import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import type { LogActiveTask, LogSummary } from "@/contracts/types/log.types";
import { cn } from "@/lib/utils";
import { useConnectorStatus } from "../hooks/use-connector-status";
import { getConnectorDisplayName } from "../tabs/all-connectors-tab";
interface ConnectorAccountsListViewProps {
@ -65,13 +66,19 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
onAddAccount,
isConnecting = false,
}) => {
// Get connector status
const { isConnectorEnabled, getConnectorStatusMessage } = useConnectorStatus();
const isEnabled = isConnectorEnabled(connectorType);
const statusMessage = getConnectorStatusMessage(connectorType);
// Filter connectors to only show those of this type
const typeConnectors = connectors.filter((c) => c.connector_type === connectorType);
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="px-6 sm:px-12 pt-8 sm:pt-10 pb-4 border-b border-border/50 bg-muted">
<div className="px-6 sm:px-12 pt-8 sm:pt-10 pb-1 sm:pb-4 border-b border-border/50 bg-muted">
{/* Back button */}
<button
type="button"
@ -93,7 +100,7 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
{connectorTitle}
</h2>
<p className="text-xs sm:text-base text-muted-foreground mt-1">
Manage your connector settings and sync configuration
{statusMessage || "Manage your connector settings and sync configuration"}
</p>
</div>
</div>
@ -101,21 +108,23 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
<button
type="button"
onClick={onAddAccount}
disabled={isConnecting}
disabled={isConnecting || !isEnabled}
className={cn(
"flex items-center gap-2 px-3 py-2 rounded-lg border-2 border-dashed border-border/70 text-left transition-all duration-200 shrink-0 self-center sm:self-auto sm:w-auto",
"border-primary/50 hover:bg-primary/5",
"flex items-center gap-1.5 sm:gap-2 px-2 sm:px-3 py-1.5 sm:py-2 rounded-lg border-2 border-dashed text-left transition-all duration-200 shrink-0 self-center sm:self-auto sm:w-auto",
!isEnabled
? "border-border/30 opacity-50 cursor-not-allowed"
: "border-primary/50 hover:bg-primary/5",
isConnecting && "opacity-50 cursor-not-allowed"
)}
>
<div className="flex h-6 w-6 items-center justify-center rounded-md bg-primary/10 shrink-0">
<div className="flex h-5 w-5 sm:h-6 sm:w-6 items-center justify-center rounded-md bg-primary/10 shrink-0">
{isConnecting ? (
<Loader2 className="size-3.5 animate-spin text-primary" />
<Loader2 className="size-3 sm:size-3.5 animate-spin text-primary" />
) : (
<Plus className="size-3.5 text-primary" />
<Plus className="size-3 sm:size-3.5 text-primary" />
)}
</div>
<span className="text-[12px] font-medium">
<span className="text-[11px] sm:text-[12px] font-medium">
{isConnecting ? "Connecting..." : "Add Account"}
</span>
</button>
@ -123,7 +132,7 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-6 sm:px-12 py-6 sm:py-8">
<div className="flex-1 overflow-y-auto px-6 sm:px-12 pt-0 sm:pt-6 pb-6 sm:pb-8">
{/* Connected Accounts Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{typeConnectors.map((connector) => {

View file

@ -7,13 +7,19 @@ import { SourceDetailPanel } from "@/components/new-chat/source-detail-panel";
interface InlineCitationProps {
chunkId: number;
citationNumber: number;
isDocsChunk?: boolean;
}
/**
* Inline citation component for the new chat.
* Renders a clickable numbered badge that opens the SourceDetailPanel with document chunk details.
* Supports both regular knowledge base chunks and Surfsense documentation chunks.
*/
export const InlineCitation: FC<InlineCitationProps> = ({ chunkId, citationNumber }) => {
export const InlineCitation: FC<InlineCitationProps> = ({
chunkId,
citationNumber,
isDocsChunk = false,
}) => {
const [isOpen, setIsOpen] = useState(false);
return (
@ -21,10 +27,11 @@ export const InlineCitation: FC<InlineCitationProps> = ({ chunkId, citationNumbe
open={isOpen}
onOpenChange={setIsOpen}
chunkId={chunkId}
sourceType=""
title="Source"
sourceType={isDocsChunk ? "SURFSENSE_DOCS" : ""}
title={isDocsChunk ? "Surfsense Documentation" : "Source"}
description=""
url=""
isDocsChunk={isDocsChunk}
>
<span
onClick={() => setIsOpen(true)}

View file

@ -15,12 +15,13 @@ import { InlineCitation } from "@/components/assistant-ui/inline-citation";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { cn } from "@/lib/utils";
// Citation pattern: [citation:CHUNK_ID]
const CITATION_REGEX = /\[citation:(\d+)\]/g;
// Citation pattern: [citation:CHUNK_ID] or [citation:doc-CHUNK_ID]
const CITATION_REGEX = /\[citation:(doc-)?(\d+)\]/g;
// Track chunk IDs to citation numbers mapping for consistent numbering
// This map is reset when a new message starts rendering
let chunkIdToCitationNumber: Map<number, number> = new Map();
// Uses string keys to differentiate between doc and regular chunks (e.g., "doc-123" vs "123")
let chunkIdToCitationNumber: Map<string, number> = new Map();
let nextCitationNumber = 1;
/**
@ -33,16 +34,20 @@ export function resetCitationCounter() {
/**
* Gets or assigns a citation number for a chunk ID
* Uses string key to differentiate between doc and regular chunks
*/
function getCitationNumber(chunkId: number): number {
if (!chunkIdToCitationNumber.has(chunkId)) {
chunkIdToCitationNumber.set(chunkId, nextCitationNumber++);
function getCitationNumber(chunkId: number, isDocsChunk: boolean): number {
const key = isDocsChunk ? `doc-${chunkId}` : String(chunkId);
const existingNumber = chunkIdToCitationNumber.get(key);
if (existingNumber === undefined) {
chunkIdToCitationNumber.set(key, nextCitationNumber++);
}
return chunkIdToCitationNumber.get(chunkId)!;
return chunkIdToCitationNumber.get(key)!;
}
/**
* Parses text and replaces [citation:XXX] patterns with InlineCitation components
* Supports both regular chunks [citation:123] and docs chunks [citation:doc-123]
*/
function parseTextWithCitations(text: string): ReactNode[] {
const parts: ReactNode[] = [];
@ -59,14 +64,16 @@ function parseTextWithCitations(text: string): ReactNode[] {
parts.push(text.substring(lastIndex, match.index));
}
// Add the citation component
const chunkId = Number.parseInt(match[1], 10);
const citationNumber = getCitationNumber(chunkId);
// Check if this is a docs chunk (has "doc-" prefix)
const isDocsChunk = match[1] === "doc-";
const chunkId = Number.parseInt(match[2], 10);
const citationNumber = getCitationNumber(chunkId, isDocsChunk);
parts.push(
<InlineCitation
key={`citation-${chunkId}-${instanceIndex}`}
key={`citation-${isDocsChunk ? "doc-" : ""}${chunkId}-${instanceIndex}`}
chunkId={chunkId}
citationNumber={citationNumber}
isDocsChunk={isDocsChunk}
/>
);

View file

@ -6,12 +6,14 @@ export type {
NavItem,
NoteItem,
PageUsage,
SearchSpace,
SidebarSectionProps,
User,
Workspace,
} from "./types/layout.types";
export {
AllSearchSpacesSheet,
ChatListItem,
CreateSearchSpaceDialog,
Header,
IconRail,
LayoutShell,
@ -21,10 +23,10 @@ export {
NavSection,
NoteListItem,
PageUsageDisplay,
SearchSpaceAvatar,
Sidebar,
SidebarCollapseButton,
SidebarHeader,
SidebarSection,
SidebarUserProfile,
WorkspaceAvatar,
} from "./ui";

View file

@ -8,6 +8,7 @@ import { useTranslations } from "next-intl";
import { useTheme } from "next-themes";
import { useCallback, useMemo, useState } from "react";
import { hasUnsavedEditorChangesAtom, pendingEditorNavigationAtom } from "@/atoms/editor/ui.atoms";
import { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { Button } from "@/components/ui/button";
@ -25,7 +26,9 @@ import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { deleteThread, fetchThreads } from "@/lib/chat/thread-persistence";
import { resetUser, trackLogout } from "@/lib/posthog/events";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import type { ChatItem, NavItem, NoteItem, Workspace } from "../types/layout.types";
import type { ChatItem, NavItem, NoteItem, SearchSpace } from "../types/layout.types";
import { CreateSearchSpaceDialog } from "../ui/dialogs";
import { AllSearchSpacesSheet } from "../ui/sheets";
import { LayoutShell } from "../ui/shell";
import { AllChatsSidebar } from "../ui/sidebar/AllChatsSidebar";
import { AllNotesSidebar } from "../ui/sidebar/AllNotesSidebar";
@ -53,7 +56,8 @@ export function LayoutDataProvider({
// Atoms
const { data: user } = useAtomValue(currentUserAtom);
const { data: searchSpacesData } = useAtomValue(searchSpacesAtom);
const { data: searchSpacesData, refetch: refetchSearchSpaces } = useAtomValue(searchSpacesAtom);
const { mutateAsync: deleteSearchSpace } = useAtomValue(deleteSearchSpaceMutationAtom);
const hasUnsavedEditorChanges = useAtomValue(hasUnsavedEditorChangesAtom);
const setPendingNavigation = useSetAtom(pendingEditorNavigationAtom);
@ -110,6 +114,10 @@ export function LayoutDataProvider({
const [isAllChatsSidebarOpen, setIsAllChatsSidebarOpen] = useState(false);
const [isAllNotesSidebarOpen, setIsAllNotesSidebarOpen] = useState(false);
// Search space sheet and dialog state
const [isAllSearchSpacesSheetOpen, setIsAllSearchSpacesSheetOpen] = useState(false);
const [isCreateSearchSpaceDialogOpen, setIsCreateSearchSpaceDialogOpen] = useState(false);
// Delete dialogs state
const [showDeleteChatDialog, setShowDeleteChatDialog] = useState(false);
const [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null);
@ -123,8 +131,7 @@ export function LayoutDataProvider({
} | null>(null);
const [isDeletingNote, setIsDeletingNote] = useState(false);
// Transform workspaces (API returns array directly, not { items: [...] })
const workspaces: Workspace[] = useMemo(() => {
const searchSpaces: SearchSpace[] = useMemo(() => {
if (!searchSpacesData || !Array.isArray(searchSpacesData)) return [];
return searchSpacesData.map((space) => ({
id: space.id,
@ -132,19 +139,15 @@ export function LayoutDataProvider({
description: space.description,
isOwner: space.is_owner,
memberCount: space.member_count || 0,
createdAt: space.created_at,
}));
}, [searchSpacesData]);
// Use searchSpace query result for current workspace (more reliable than finding in list)
const activeWorkspace: Workspace | null = searchSpace
? {
id: searchSpace.id,
name: searchSpace.name,
description: searchSpace.description,
isOwner: searchSpace.is_owner,
memberCount: searchSpace.member_count || 0,
}
: null;
// Find active search space from list (has is_owner and member_count)
const activeSearchSpace: SearchSpace | null = useMemo(() => {
if (!searchSpaceId || !searchSpaces.length) return null;
return searchSpaces.find((s) => s.id === Number(searchSpaceId)) ?? null;
}, [searchSpaceId, searchSpaces]);
// Transform chats
const chats: ChatItem[] = useMemo(() => {
@ -196,20 +199,47 @@ export function LayoutDataProvider({
);
// Handlers
const handleWorkspaceSelect = useCallback(
const handleSearchSpaceSelect = useCallback(
(id: number) => {
router.push(`/dashboard/${id}/new-chat`);
},
[router]
);
const handleAddWorkspace = useCallback(() => {
router.push("/dashboard/searchspaces");
const handleAddSearchSpace = useCallback(() => {
setIsCreateSearchSpaceDialogOpen(true);
}, []);
const handleSeeAllSearchSpaces = useCallback(() => {
setIsAllSearchSpacesSheetOpen(true);
}, []);
const handleUserSettings = useCallback(() => {
router.push("/dashboard/user/settings");
}, [router]);
const handleSeeAllWorkspaces = useCallback(() => {
router.push("/dashboard");
}, [router]);
const handleSearchSpaceSettings = useCallback(
(id: number) => {
router.push(`/dashboard/${id}/settings`);
},
[router]
);
const handleDeleteSearchSpace = useCallback(
async (id: number) => {
await deleteSearchSpace({ id });
refetchSearchSpaces();
if (Number(searchSpaceId) === id && searchSpaces.length > 1) {
const remaining = searchSpaces.filter((s) => s.id !== id);
if (remaining.length > 0) {
router.push(`/dashboard/${remaining[0].id}/new-chat`);
}
} else if (searchSpaces.length === 1) {
router.push("/dashboard");
}
},
[deleteSearchSpace, refetchSearchSpaces, searchSpaceId, searchSpaces, router]
);
const handleNavItemClick = useCallback(
(item: NavItem) => {
@ -266,7 +296,7 @@ export function LayoutDataProvider({
router.push(`/dashboard/${searchSpaceId}/settings`);
}, [router, searchSpaceId]);
const handleInviteMembers = useCallback(() => {
const handleManageMembers = useCallback(() => {
router.push(`/dashboard/${searchSpaceId}/team`);
}, [router, searchSpaceId]);
@ -347,11 +377,11 @@ export function LayoutDataProvider({
return (
<>
<LayoutShell
workspaces={workspaces}
activeWorkspaceId={Number(searchSpaceId)}
onWorkspaceSelect={handleWorkspaceSelect}
onAddWorkspace={handleAddWorkspace}
workspace={activeWorkspace}
searchSpaces={searchSpaces}
activeSearchSpaceId={Number(searchSpaceId)}
onSearchSpaceSelect={handleSearchSpaceSelect}
onAddSearchSpace={handleAddSearchSpace}
searchSpace={activeSearchSpace}
navItems={navItems}
onNavItemClick={handleNavItemClick}
chats={chats}
@ -368,8 +398,9 @@ export function LayoutDataProvider({
onViewAllNotes={handleViewAllNotes}
user={{ email: user?.email || "", name: user?.email?.split("@")[0] }}
onSettings={handleSettings}
onInviteMembers={handleInviteMembers}
onSeeAllWorkspaces={handleSeeAllWorkspaces}
onManageMembers={handleManageMembers}
onSeeAllSearchSpaces={handleSeeAllSearchSpaces}
onUserSettings={handleUserSettings}
onLogout={handleLogout}
pageUsage={pageUsage}
breadcrumb={breadcrumb}
@ -439,6 +470,26 @@ export function LayoutDataProvider({
onAddNote={handleAddNote}
/>
{/* All Search Spaces Sheet */}
<AllSearchSpacesSheet
open={isAllSearchSpacesSheetOpen}
onOpenChange={setIsAllSearchSpacesSheetOpen}
searchSpaces={searchSpaces}
onSearchSpaceSelect={handleSearchSpaceSelect}
onCreateNew={() => {
setIsAllSearchSpacesSheetOpen(false);
setIsCreateSearchSpaceDialogOpen(true);
}}
onSettings={handleSearchSpaceSettings}
onDelete={handleDeleteSearchSpace}
/>
{/* Create Search Space Dialog */}
<CreateSearchSpaceDialog
open={isCreateSearchSpaceDialogOpen}
onOpenChange={setIsCreateSearchSpaceDialogOpen}
/>
{/* Delete Note Dialog */}
<Dialog open={showDeleteNoteDialog} onOpenChange={setShowDeleteNoteDialog}>
<DialogContent className="sm:max-w-md">

View file

@ -1,11 +1,12 @@
import type { LucideIcon } from "lucide-react";
export interface Workspace {
export interface SearchSpace {
id: number;
name: string;
description?: string | null;
isOwner: boolean;
memberCount: number;
createdAt?: string;
}
export interface User {
@ -42,15 +43,15 @@ export interface PageUsage {
}
export interface IconRailProps {
workspaces: Workspace[];
activeWorkspaceId: number | null;
onWorkspaceSelect: (id: number) => void;
onAddWorkspace: () => void;
searchSpaces: SearchSpace[];
activeSearchSpaceId: number | null;
onSearchSpaceSelect: (id: number) => void;
onAddSearchSpace: () => void;
className?: string;
}
export interface SidebarHeaderProps {
workspace: Workspace | null;
searchSpace: SearchSpace | null;
onSettings?: () => void;
}
@ -94,15 +95,15 @@ export interface SidebarUserProfileProps {
user: User;
searchSpaceId?: string;
onSettings?: () => void;
onInviteMembers?: () => void;
onSwitchWorkspace?: () => void;
onManageMembers?: () => void;
onSwitchSearchSpace?: () => void;
onToggleTheme?: () => void;
onLogout?: () => void;
theme?: string;
}
export interface SidebarProps {
workspace: Workspace | null;
searchSpace: SearchSpace | null;
searchSpaceId?: string;
navItems: NavItem[];
chats: ChatItem[];
@ -120,8 +121,8 @@ export interface SidebarProps {
user: User;
theme?: string;
onSettings?: () => void;
onInviteMembers?: () => void;
onSwitchWorkspace?: () => void;
onManageMembers?: () => void;
onSeeAllSearchSpaces?: () => void;
onToggleTheme?: () => void;
onLogout?: () => void;
pageUsage?: PageUsage;
@ -129,10 +130,10 @@ export interface SidebarProps {
}
export interface LayoutShellProps {
workspaces: Workspace[];
activeWorkspaceId: number | null;
onWorkspaceSelect: (id: number) => void;
onAddWorkspace: () => void;
searchSpaces: SearchSpace[];
activeSearchSpaceId: number | null;
onSearchSpaceSelect: (id: number) => void;
onAddSearchSpace: () => void;
sidebarProps: Omit<SidebarProps, "className">;
children: React.ReactNode;
className?: string;

View file

@ -0,0 +1,161 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useAtomValue } from "jotai";
import { Loader2, Plus, Search } from "lucide-react";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { useForm } from "react-hook-form";
import * as z from "zod";
import { createSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { trackSearchSpaceCreated } from "@/lib/posthog/events";
const formSchema = z.object({
name: z.string().min(1, "Name is required"),
description: z.string().optional(),
});
type FormValues = z.infer<typeof formSchema>;
interface CreateSearchSpaceDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function CreateSearchSpaceDialog({ open, onOpenChange }: CreateSearchSpaceDialogProps) {
const t = useTranslations("searchSpace");
const tCommon = useTranslations("common");
const [isSubmitting, setIsSubmitting] = useState(false);
const { mutateAsync: createSearchSpace } = useAtomValue(createSearchSpaceMutationAtom);
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
description: "",
},
});
const handleSubmit = async (values: FormValues) => {
setIsSubmitting(true);
try {
const result = await createSearchSpace({
name: values.name,
description: values.description || "",
});
trackSearchSpaceCreated(result.id, values.name);
// Hard redirect to ensure fresh state
window.location.href = `/dashboard/${result.id}/onboard`;
} catch (error) {
console.error("Failed to create search space:", error);
setIsSubmitting(false);
}
};
const handleOpenChange = (newOpen: boolean) => {
if (!newOpen) {
form.reset();
}
onOpenChange(newOpen);
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Search className="h-5 w-5 text-primary" />
</div>
<div>
<DialogTitle>{t("create_title")}</DialogTitle>
<DialogDescription>{t("create_description")}</DialogDescription>
</div>
</div>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="flex flex-col gap-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("name_label")}</FormLabel>
<FormControl>
<Input placeholder={t("name_placeholder")} {...field} autoFocus />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("description_label")}{" "}
<span className="text-muted-foreground font-normal">
({tCommon("optional")})
</span>
</FormLabel>
<FormControl>
<Input placeholder={t("description_placeholder")} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className="flex gap-2 pt-2">
<Button
type="button"
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={isSubmitting}
>
{tCommon("cancel")}
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t("creating")}
</>
) : (
<>
<Plus className="mr-2 h-4 w-4" />
{t("create_button")}
</>
)}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1 @@
export { CreateSearchSpaceDialog } from "./CreateSearchSpaceDialog";

View file

@ -25,7 +25,7 @@ export function Header({
{/* Left side - Mobile menu trigger + Breadcrumb */}
<div className="flex flex-1 items-center gap-2 min-w-0">
{mobileMenuTrigger}
{breadcrumb}
<div className="hidden md:block">{breadcrumb}</div>
</div>
{/* Right side - Actions */}

View file

@ -5,34 +5,34 @@ import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import type { Workspace } from "../../types/layout.types";
import { WorkspaceAvatar } from "./WorkspaceAvatar";
import type { SearchSpace } from "../../types/layout.types";
import { SearchSpaceAvatar } from "./SearchSpaceAvatar";
interface IconRailProps {
workspaces: Workspace[];
activeWorkspaceId: number | null;
onWorkspaceSelect: (id: number) => void;
onAddWorkspace: () => void;
searchSpaces: SearchSpace[];
activeSearchSpaceId: number | null;
onSearchSpaceSelect: (id: number) => void;
onAddSearchSpace: () => void;
className?: string;
}
export function IconRail({
workspaces,
activeWorkspaceId,
onWorkspaceSelect,
onAddWorkspace,
searchSpaces,
activeSearchSpaceId,
onSearchSpaceSelect,
onAddSearchSpace,
className,
}: IconRailProps) {
return (
<div className={cn("flex h-full w-14 flex-col items-center", className)}>
<ScrollArea className="w-full">
<div className="flex flex-col items-center gap-2 px-1.5 py-3">
{workspaces.map((workspace) => (
<WorkspaceAvatar
key={workspace.id}
name={workspace.name}
isActive={workspace.id === activeWorkspaceId}
onClick={() => onWorkspaceSelect(workspace.id)}
{searchSpaces.map((searchSpace) => (
<SearchSpaceAvatar
key={searchSpace.id}
name={searchSpace.name}
isActive={searchSpace.id === activeSearchSpaceId}
onClick={() => onSearchSpaceSelect(searchSpace.id)}
size="md"
/>
))}
@ -42,15 +42,15 @@ export function IconRail({
<Button
variant="ghost"
size="icon"
onClick={onAddWorkspace}
onClick={onAddSearchSpace}
className="h-10 w-10 rounded-lg border-2 border-dashed border-muted-foreground/30 hover:border-muted-foreground/50"
>
<Plus className="h-5 w-5 text-muted-foreground" />
<span className="sr-only">Add workspace</span>
<span className="sr-only">Add search space</span>
</Button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
Add workspace
Add search space
</TooltipContent>
</Tooltip>
</div>

View file

@ -3,7 +3,7 @@
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
interface WorkspaceAvatarProps {
interface SearchSpaceAvatarProps {
name: string;
isActive?: boolean;
onClick?: () => void;
@ -11,7 +11,7 @@ interface WorkspaceAvatarProps {
}
/**
* Generates a consistent color based on workspace name
* Generates a consistent color based on search space name
*/
function stringToColor(str: string): string {
let hash = 0;
@ -32,7 +32,7 @@ function stringToColor(str: string): string {
}
/**
* Gets initials from workspace name (max 2 chars)
* Gets initials from search space name (max 2 chars)
*/
function getInitials(name: string): string {
const words = name.trim().split(/\s+/);
@ -42,7 +42,12 @@ function getInitials(name: string): string {
return name.slice(0, 2).toUpperCase();
}
export function WorkspaceAvatar({ name, isActive, onClick, size = "md" }: WorkspaceAvatarProps) {
export function SearchSpaceAvatar({
name,
isActive,
onClick,
size = "md",
}: SearchSpaceAvatarProps) {
const bgColor = stringToColor(name);
const initials = getInitials(name);
const sizeClasses = size === "sm" ? "h-8 w-8 text-xs" : "h-10 w-10 text-sm";

View file

@ -1,3 +1,3 @@
export { IconRail } from "./IconRail";
export { NavIcon } from "./NavIcon";
export { WorkspaceAvatar } from "./WorkspaceAvatar";
export { SearchSpaceAvatar } from "./SearchSpaceAvatar";

View file

@ -1,5 +1,7 @@
export { CreateSearchSpaceDialog } from "./dialogs";
export { Header } from "./header";
export { IconRail, NavIcon, WorkspaceAvatar } from "./icon-rail";
export { IconRail, NavIcon, SearchSpaceAvatar } from "./icon-rail";
export { AllSearchSpacesSheet } from "./sheets";
export { LayoutShell } from "./shell";
export {
ChatListItem,

View file

@ -0,0 +1,241 @@
"use client";
import {
Calendar,
MoreHorizontal,
Search,
Settings,
Share2,
Trash2,
UserCheck,
Users,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useState } from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import type { SearchSpace } from "../../types/layout.types";
function formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
}
interface AllSearchSpacesSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
searchSpaces: SearchSpace[];
onSearchSpaceSelect: (id: number) => void;
onCreateNew?: () => void;
onSettings?: (id: number) => void;
onDelete?: (id: number) => void;
}
export function AllSearchSpacesSheet({
open,
onOpenChange,
searchSpaces,
onSearchSpaceSelect,
onCreateNew,
onSettings,
onDelete,
}: AllSearchSpacesSheetProps) {
const t = useTranslations("searchSpace");
const tCommon = useTranslations("common");
const [spaceToDelete, setSpaceToDelete] = useState<SearchSpace | null>(null);
const handleSelect = (id: number) => {
onSearchSpaceSelect(id);
onOpenChange(false);
};
const handleSettings = (e: React.MouseEvent, space: SearchSpace) => {
e.stopPropagation();
onOpenChange(false);
onSettings?.(space.id);
};
const handleDeleteClick = (e: React.MouseEvent, space: SearchSpace) => {
e.stopPropagation();
setSpaceToDelete(space);
};
const confirmDelete = () => {
if (spaceToDelete) {
onDelete?.(spaceToDelete.id);
setSpaceToDelete(null);
}
};
return (
<>
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full sm:max-w-md">
<SheetHeader>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<Search className="h-5 w-5 text-primary" />
</div>
<div className="flex flex-col gap-0.5">
<SheetTitle>{t("all_search_spaces")}</SheetTitle>
<SheetDescription>
{t("search_spaces_count", { count: searchSpaces.length })}
</SheetDescription>
</div>
</div>
</SheetHeader>
<div className="flex flex-1 flex-col gap-3 overflow-y-auto px-4 pb-4">
{searchSpaces.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center gap-4 py-12 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted">
<Search className="h-8 w-8 text-muted-foreground" />
</div>
<div className="flex flex-col gap-1">
<p className="font-medium">{t("no_search_spaces")}</p>
<p className="text-sm text-muted-foreground">{t("create_first_search_space")}</p>
</div>
{onCreateNew && (
<Button onClick={onCreateNew} className="mt-2">
{t("create_button")}
</Button>
)}
</div>
) : (
searchSpaces.map((space) => (
<button
key={space.id}
type="button"
onClick={() => handleSelect(space.id)}
className="flex w-full flex-col gap-2 rounded-lg border p-4 text-left transition-colors hover:bg-accent hover:border-accent-foreground/20"
>
<div className="flex items-start justify-between gap-2">
<div className="flex flex-1 flex-col gap-1">
<span className="font-medium leading-tight">{space.name}</span>
{space.description && (
<span className="text-sm text-muted-foreground line-clamp-2">
{space.description}
</span>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
{space.memberCount > 1 && (
<Badge variant="outline" className="shrink-0">
<Share2 className="mr-1 h-3 w-3" />
{tCommon("shared")}
</Badge>
)}
{space.isOwner && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={(e) => handleSettings(e, space)}>
<Settings className="mr-2 h-4 w-4" />
{tCommon("settings")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => handleDeleteClick(e, space)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
{tCommon("delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
{space.isOwner ? (
<UserCheck className="h-3.5 w-3.5" />
) : (
<Users className="h-3.5 w-3.5" />
)}
{t("members_count", { count: space.memberCount })}
</span>
{space.createdAt && (
<span className="flex items-center gap-1">
<Calendar className="h-3.5 w-3.5" />
{formatDate(space.createdAt)}
</span>
)}
</div>
</button>
))
)}
</div>
{searchSpaces.length > 0 && onCreateNew && (
<div className="border-t p-4">
<Button onClick={onCreateNew} variant="outline" className="w-full">
{t("create_new_search_space")}
</Button>
</div>
)}
</SheetContent>
</Sheet>
<AlertDialog open={!!spaceToDelete} onOpenChange={(open) => !open && setSpaceToDelete(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("delete_title")}</AlertDialogTitle>
<AlertDialogDescription>
{t("delete_confirm", { name: spaceToDelete?.name ?? "" })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{tCommon("delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View file

@ -0,0 +1 @@
export { AllSearchSpacesSheet } from "./AllSearchSpacesSheet";

View file

@ -10,19 +10,19 @@ import type {
NavItem,
NoteItem,
PageUsage,
SearchSpace,
User,
Workspace,
} from "../../types/layout.types";
import { Header } from "../header";
import { IconRail } from "../icon-rail";
import { MobileSidebar, MobileSidebarTrigger, Sidebar } from "../sidebar";
interface LayoutShellProps {
workspaces: Workspace[];
activeWorkspaceId: number | null;
onWorkspaceSelect: (id: number) => void;
onAddWorkspace: () => void;
workspace: Workspace | null;
searchSpaces: SearchSpace[];
activeSearchSpaceId: number | null;
onSearchSpaceSelect: (id: number) => void;
onAddSearchSpace: () => void;
searchSpace: SearchSpace | null;
navItems: NavItem[];
onNavItemClick?: (item: NavItem) => void;
chats: ChatItem[];
@ -39,8 +39,9 @@ interface LayoutShellProps {
onViewAllNotes?: () => void;
user: User;
onSettings?: () => void;
onInviteMembers?: () => void;
onSeeAllWorkspaces?: () => void;
onManageMembers?: () => void;
onSeeAllSearchSpaces?: () => void;
onUserSettings?: () => void;
onLogout?: () => void;
pageUsage?: PageUsage;
breadcrumb?: React.ReactNode;
@ -54,11 +55,11 @@ interface LayoutShellProps {
}
export function LayoutShell({
workspaces,
activeWorkspaceId,
onWorkspaceSelect,
onAddWorkspace,
workspace,
searchSpaces,
activeSearchSpaceId,
onSearchSpaceSelect,
onAddSearchSpace,
searchSpace,
navItems,
onNavItemClick,
chats,
@ -75,8 +76,9 @@ export function LayoutShell({
onViewAllNotes,
user,
onSettings,
onInviteMembers,
onSeeAllWorkspaces,
onManageMembers,
onSeeAllSearchSpaces,
onUserSettings,
onLogout,
pageUsage,
breadcrumb,
@ -108,11 +110,11 @@ export function LayoutShell({
<MobileSidebar
isOpen={mobileMenuOpen}
onOpenChange={setMobileMenuOpen}
workspaces={workspaces}
activeWorkspaceId={activeWorkspaceId}
onWorkspaceSelect={onWorkspaceSelect}
onAddWorkspace={onAddWorkspace}
workspace={workspace}
searchSpaces={searchSpaces}
activeSearchSpaceId={activeSearchSpaceId}
onSearchSpaceSelect={onSearchSpaceSelect}
onAddSearchSpace={onAddSearchSpace}
searchSpace={searchSpace}
navItems={navItems}
onNavItemClick={onNavItemClick}
chats={chats}
@ -129,8 +131,9 @@ export function LayoutShell({
onViewAllNotes={onViewAllNotes}
user={user}
onSettings={onSettings}
onInviteMembers={onInviteMembers}
onSeeAllWorkspaces={onSeeAllWorkspaces}
onManageMembers={onManageMembers}
onSeeAllSearchSpaces={onSeeAllSearchSpaces}
onUserSettings={onUserSettings}
onLogout={onLogout}
pageUsage={pageUsage}
/>
@ -149,16 +152,16 @@ export function LayoutShell({
<div className={cn("flex h-screen w-full gap-2 p-2 overflow-hidden bg-muted/40", className)}>
<div className="hidden md:flex overflow-hidden">
<IconRail
workspaces={workspaces}
activeWorkspaceId={activeWorkspaceId}
onWorkspaceSelect={onWorkspaceSelect}
onAddWorkspace={onAddWorkspace}
searchSpaces={searchSpaces}
activeSearchSpaceId={activeSearchSpaceId}
onSearchSpaceSelect={onSearchSpaceSelect}
onAddSearchSpace={onAddSearchSpace}
/>
</div>
<div className="flex flex-1 rounded-xl border bg-background overflow-hidden">
<Sidebar
workspace={workspace}
searchSpace={searchSpace}
isCollapsed={isCollapsed}
onToggleCollapse={toggleCollapsed}
navItems={navItems}
@ -177,8 +180,9 @@ export function LayoutShell({
onViewAllNotes={onViewAllNotes}
user={user}
onSettings={onSettings}
onInviteMembers={onInviteMembers}
onSeeAllWorkspaces={onSeeAllWorkspaces}
onManageMembers={onManageMembers}
onSeeAllSearchSpaces={onSeeAllSearchSpaces}
onUserSettings={onUserSettings}
onLogout={onLogout}
pageUsage={pageUsage}
className="hidden md:flex border-r shrink-0"

View file

@ -9,8 +9,8 @@ import type {
NavItem,
NoteItem,
PageUsage,
SearchSpace,
User,
Workspace,
} from "../../types/layout.types";
import { IconRail } from "../icon-rail";
import { Sidebar } from "./Sidebar";
@ -18,11 +18,11 @@ import { Sidebar } from "./Sidebar";
interface MobileSidebarProps {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
workspaces: Workspace[];
activeWorkspaceId: number | null;
onWorkspaceSelect: (id: number) => void;
onAddWorkspace: () => void;
workspace: Workspace | null;
searchSpaces: SearchSpace[];
activeSearchSpaceId: number | null;
onSearchSpaceSelect: (id: number) => void;
onAddSearchSpace: () => void;
searchSpace: SearchSpace | null;
navItems: NavItem[];
onNavItemClick?: (item: NavItem) => void;
chats: ChatItem[];
@ -39,8 +39,9 @@ interface MobileSidebarProps {
onViewAllNotes?: () => void;
user: User;
onSettings?: () => void;
onInviteMembers?: () => void;
onSeeAllWorkspaces?: () => void;
onManageMembers?: () => void;
onSeeAllSearchSpaces?: () => void;
onUserSettings?: () => void;
onLogout?: () => void;
pageUsage?: PageUsage;
}
@ -57,11 +58,11 @@ export function MobileSidebarTrigger({ onClick }: { onClick: () => void }) {
export function MobileSidebar({
isOpen,
onOpenChange,
workspaces,
activeWorkspaceId,
onWorkspaceSelect,
onAddWorkspace,
workspace,
searchSpaces,
activeSearchSpaceId,
onSearchSpaceSelect,
onAddSearchSpace,
searchSpace,
navItems,
onNavItemClick,
chats,
@ -78,13 +79,14 @@ export function MobileSidebar({
onViewAllNotes,
user,
onSettings,
onInviteMembers,
onSeeAllWorkspaces,
onManageMembers,
onSeeAllSearchSpaces,
onUserSettings,
onLogout,
pageUsage,
}: MobileSidebarProps) {
const handleWorkspaceSelect = (id: number) => {
onWorkspaceSelect(id);
const handleSearchSpaceSelect = (id: number) => {
onSearchSpaceSelect(id);
};
const handleNavItemClick = (item: NavItem) => {
@ -110,17 +112,17 @@ export function MobileSidebar({
<div className="shrink-0 border-r bg-muted/40">
<ScrollArea className="h-full">
<IconRail
workspaces={workspaces}
activeWorkspaceId={activeWorkspaceId}
onWorkspaceSelect={handleWorkspaceSelect}
onAddWorkspace={onAddWorkspace}
searchSpaces={searchSpaces}
activeSearchSpaceId={activeSearchSpaceId}
onSearchSpaceSelect={handleSearchSpaceSelect}
onAddSearchSpace={onAddSearchSpace}
/>
</ScrollArea>
</div>
<div className="flex-1 overflow-hidden">
<Sidebar
workspace={workspace}
searchSpace={searchSpace}
isCollapsed={false}
navItems={navItems}
onNavItemClick={handleNavItemClick}
@ -141,8 +143,9 @@ export function MobileSidebar({
onViewAllNotes={onViewAllNotes}
user={user}
onSettings={onSettings}
onInviteMembers={onInviteMembers}
onSeeAllWorkspaces={onSeeAllWorkspaces}
onManageMembers={onManageMembers}
onSeeAllSearchSpaces={onSeeAllSearchSpaces}
onUserSettings={onUserSettings}
onLogout={onLogout}
pageUsage={pageUsage}
className="w-full border-none"

View file

@ -11,8 +11,8 @@ import type {
NavItem,
NoteItem,
PageUsage,
SearchSpace,
User,
Workspace,
} from "../../types/layout.types";
import { ChatListItem } from "./ChatListItem";
import { NavSection } from "./NavSection";
@ -24,7 +24,7 @@ import { SidebarSection } from "./SidebarSection";
import { SidebarUserProfile } from "./SidebarUserProfile";
interface SidebarProps {
workspace: Workspace | null;
searchSpace: SearchSpace | null;
isCollapsed?: boolean;
onToggleCollapse?: () => void;
navItems: NavItem[];
@ -43,15 +43,16 @@ interface SidebarProps {
onViewAllNotes?: () => void;
user: User;
onSettings?: () => void;
onInviteMembers?: () => void;
onSeeAllWorkspaces?: () => void;
onManageMembers?: () => void;
onSeeAllSearchSpaces?: () => void;
onUserSettings?: () => void;
onLogout?: () => void;
pageUsage?: PageUsage;
className?: string;
}
export function Sidebar({
workspace,
searchSpace,
isCollapsed = false,
onToggleCollapse,
navItems,
@ -70,8 +71,9 @@ export function Sidebar({
onViewAllNotes,
user,
onSettings,
onInviteMembers,
onSeeAllWorkspaces,
onManageMembers,
onSeeAllSearchSpaces,
onUserSettings,
onLogout,
pageUsage,
className,
@ -86,7 +88,7 @@ export function Sidebar({
className
)}
>
{/* Header - workspace name or collapse button when collapsed */}
{/* Header - search space name or collapse button when collapsed */}
{isCollapsed ? (
<div className="flex h-14 shrink-0 items-center justify-center border-b">
<SidebarCollapseButton
@ -97,11 +99,11 @@ export function Sidebar({
) : (
<div className="flex h-14 shrink-0 items-center justify-between px-1 border-b">
<SidebarHeader
workspace={workspace}
searchSpace={searchSpace}
isCollapsed={isCollapsed}
onSettings={onSettings}
onInviteMembers={onInviteMembers}
onSeeAllWorkspaces={onSeeAllWorkspaces}
onManageMembers={onManageMembers}
onSeeAllSearchSpaces={onSeeAllSearchSpaces}
/>
<div className="">
<SidebarCollapseButton
@ -287,7 +289,12 @@ export function Sidebar({
<PageUsageDisplay pagesUsed={pageUsage.pagesUsed} pagesLimit={pageUsage.pagesLimit} />
)}
<SidebarUserProfile user={user} onLogout={onLogout} isCollapsed={isCollapsed} />
<SidebarUserProfile
user={user}
onUserSettings={onUserSettings}
onLogout={onLogout}
isCollapsed={isCollapsed}
/>
</div>
</div>
);

View file

@ -1,6 +1,6 @@
"use client";
import { ChevronsUpDown, LayoutGrid, Settings, UserPlus } from "lucide-react";
import { ChevronsUpDown, LayoutGrid, Settings, Users } from "lucide-react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import {
@ -11,23 +11,23 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import type { Workspace } from "../../types/layout.types";
import type { SearchSpace } from "../../types/layout.types";
interface SidebarHeaderProps {
workspace: Workspace | null;
searchSpace: SearchSpace | null;
isCollapsed?: boolean;
onSettings?: () => void;
onInviteMembers?: () => void;
onSeeAllWorkspaces?: () => void;
onManageMembers?: () => void;
onSeeAllSearchSpaces?: () => void;
className?: string;
}
export function SidebarHeader({
workspace,
searchSpace,
isCollapsed,
onSettings,
onInviteMembers,
onSeeAllWorkspaces,
onManageMembers,
onSeeAllSearchSpaces,
className,
}: SidebarHeaderProps) {
const t = useTranslations("sidebar");
@ -43,24 +43,26 @@ export function SidebarHeader({
isCollapsed ? "w-10" : "w-50"
)}
>
<span className="truncate text-base">{workspace?.name ?? t("select_workspace")}</span>
<span className="truncate text-base">
{searchSpace?.name ?? t("select_search_space")}
</span>
<ChevronsUpDown className="h-4 w-4 shrink-0 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
<DropdownMenuItem onClick={onInviteMembers}>
<UserPlus className="mr-2 h-4 w-4" />
{t("invite_members")}
<DropdownMenuItem onClick={onManageMembers}>
<Users className="mr-2 h-4 w-4" />
{t("manage_members")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onSettings}>
<Settings className="mr-2 h-4 w-4" />
{t("workspace_settings")}
{t("search_space_settings")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onSeeAllWorkspaces}>
<DropdownMenuItem onClick={onSeeAllSearchSpaces}>
<LayoutGrid className="mr-2 h-4 w-4" />
{t("see_all_workspaces")}
{t("see_all_search_spaces")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View file

@ -1,6 +1,6 @@
"use client";
import { ChevronUp, LogOut } from "lucide-react";
import { ChevronUp, LogOut, Settings } from "lucide-react";
import { useTranslations } from "next-intl";
import {
DropdownMenu,
@ -16,6 +16,7 @@ import type { User } from "../../types/layout.types";
interface SidebarUserProfileProps {
user: User;
onUserSettings?: () => void;
onLogout?: () => void;
isCollapsed?: boolean;
}
@ -62,6 +63,7 @@ function getInitials(email: string): string {
export function SidebarUserProfile({
user,
onUserSettings,
onLogout,
isCollapsed = false,
}: SidebarUserProfileProps) {
@ -117,6 +119,13 @@ export function SidebarUserProfile({
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onUserSettings}>
<Settings className="mr-2 h-4 w-4" />
{t("user_settings")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onLogout}>
<LogOut className="mr-2 h-4 w-4" />
{t("logout")}
@ -177,6 +186,13 @@ export function SidebarUserProfile({
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onUserSettings}>
<Settings className="mr-2 h-4 w-4" />
{t("user_settings")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onLogout}>
<LogOut className="mr-2 h-4 w-4" />
{t("logout")}

View file

@ -21,10 +21,16 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { ScrollArea } from "@/components/ui/scroll-area";
import type {
GetDocumentByChunkResponse,
GetSurfsenseDocsByChunkResponse,
} from "@/contracts/types/document.types";
import { documentsApiService } from "@/lib/apis/documents-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { cn } from "@/lib/utils";
type DocumentData = GetDocumentByChunkResponse | GetSurfsenseDocsByChunkResponse;
interface SourceDetailPanelProps {
open: boolean;
onOpenChange: (open: boolean) => void;
@ -34,6 +40,7 @@ interface SourceDetailPanelProps {
description?: string;
url?: string;
children?: ReactNode;
isDocsChunk?: boolean;
}
const formatDocumentType = (type: string) => {
@ -114,6 +121,7 @@ export function SourceDetailPanel({
description,
url,
children,
isDocsChunk = false,
}: SourceDetailPanelProps) {
const scrollAreaRef = useRef<HTMLDivElement>(null);
const hasScrolledRef = useRef(false); // Use ref to avoid stale closures
@ -131,9 +139,16 @@ export function SourceDetailPanel({
data: documentData,
isLoading: isDocumentByChunkFetching,
error: documentByChunkFetchingError,
} = useQuery({
queryKey: cacheKeys.documents.byChunk(chunkId.toString()),
queryFn: () => documentsApiService.getDocumentByChunk({ chunk_id: chunkId }),
} = useQuery<DocumentData>({
queryKey: isDocsChunk
? cacheKeys.documents.byChunk(`doc-${chunkId}`)
: cacheKeys.documents.byChunk(chunkId.toString()),
queryFn: async () => {
if (isDocsChunk) {
return documentsApiService.getSurfsenseDocByChunk(chunkId);
}
return documentsApiService.getDocumentByChunk({ chunk_id: chunkId });
},
enabled: !!chunkId && open,
staleTime: 5 * 60 * 1000,
});
@ -325,7 +340,7 @@ export function SourceDetailPanel({
{documentData?.title || title || "Source Document"}
</h2>
<p className="text-sm text-muted-foreground mt-0.5">
{documentData
{documentData && "document_type" in documentData
? formatDocumentType(documentData.document_type)
: sourceType && formatDocumentType(sourceType)}
{documentData?.chunks && (
@ -491,7 +506,8 @@ export function SourceDetailPanel({
<ScrollArea className="flex-1" ref={scrollAreaRef}>
<div className="p-6 lg:p-8 max-w-4xl mx-auto space-y-6">
{/* Document Metadata */}
{documentData.document_metadata &&
{"document_metadata" in documentData &&
documentData.document_metadata &&
Object.keys(documentData.document_metadata).length > 0 && (
<motion.div
initial={{ opacity: 0, y: 10 }}