mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-10-18 23:29:57 +00:00
Merge pull request #1659 from AnishSarkar22/fix/document-ui-changes
Some checks failed
Obsidian Plugin Lint / lint (push) Has been cancelled
Some checks failed
Obsidian Plugin Lint / lint (push) Has been cancelled
refactor(documents): move documents into a searchable right panel
This commit is contained in:
commit
79417d9c50
39 changed files with 3081 additions and 1642 deletions
|
|
@ -1045,6 +1045,7 @@ async def commit_staged_filesystem_state(
|
|||
"documentType": DocumentType.NOTE.value,
|
||||
"workspaceId": workspace_id,
|
||||
"folderId": new_doc.folder_id,
|
||||
"createdAt": int(new_doc.created_at.timestamp() * 1000),
|
||||
"createdById": str(created_by_id)
|
||||
if created_by_id
|
||||
else None,
|
||||
|
|
|
|||
|
|
@ -184,6 +184,16 @@ async def _save_export_document(
|
|||
workspace_id=workspace_id,
|
||||
created_by_id=None,
|
||||
)
|
||||
document_event = {
|
||||
"id": doc.id,
|
||||
"title": doc.title,
|
||||
"documentType": doc.document_type.value,
|
||||
"workspaceId": workspace_id,
|
||||
"folderId": doc.folder_id,
|
||||
"createdById": None,
|
||||
"createdAt": int(doc.created_at.timestamp() * 1000),
|
||||
"virtualPath": path,
|
||||
}
|
||||
await session.commit()
|
||||
doc_id = doc.id
|
||||
except ValueError as exc:
|
||||
|
|
@ -198,7 +208,7 @@ async def _save_export_document(
|
|||
|
||||
await adispatch_custom_event(
|
||||
"document_created",
|
||||
{"id": doc_id, "title": path.rsplit("/", 1)[-1], "virtualPath": path},
|
||||
document_event,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("export_run: document_created dispatch failed", exc_info=True)
|
||||
|
|
|
|||
|
|
@ -20,10 +20,12 @@ export function DashboardClientLayout({
|
|||
children,
|
||||
workspaceId,
|
||||
initialPlaygroundSidebarCollapsed,
|
||||
initialRightPanelCollapsed,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
workspaceId: string;
|
||||
initialPlaygroundSidebarCollapsed: boolean;
|
||||
initialRightPanelCollapsed: boolean;
|
||||
}) {
|
||||
const t = useTranslations("dashboard");
|
||||
const router = useRouter();
|
||||
|
|
@ -166,6 +168,7 @@ export function DashboardClientLayout({
|
|||
<LayoutDataProvider
|
||||
workspaceId={workspaceId}
|
||||
initialPlaygroundSidebarCollapsed={initialPlaygroundSidebarCollapsed}
|
||||
initialRightPanelCollapsed={initialRightPanelCollapsed}
|
||||
>
|
||||
{children}
|
||||
</LayoutDataProvider>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
"use client";
|
||||
|
||||
import { useSetAtom } from "jotai";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms";
|
||||
import { rightPanelCollapsedAtom } from "@/atoms/layout/right-panel.atom";
|
||||
import { MobileDocumentsWorkspaceView } from "@/components/layout/ui/right-panel/MobileDocumentsWorkspaceView";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { getWorkspaceIdNumber } from "@/lib/route-params";
|
||||
|
||||
export default function DocumentsPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const isMobile = useIsMobile();
|
||||
const [isHydrated, setIsHydrated] = useState(false);
|
||||
const setDocumentsOpen = useSetAtom(documentsSidebarOpenAtom);
|
||||
const setRightPanelCollapsed = useSetAtom(rightPanelCollapsedAtom);
|
||||
const workspaceId = getWorkspaceIdNumber(params);
|
||||
|
||||
useEffect(() => setIsHydrated(true), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isHydrated || isMobile || workspaceId === undefined) return;
|
||||
|
||||
setDocumentsOpen(true);
|
||||
setRightPanelCollapsed(false);
|
||||
router.replace(`/dashboard/${workspaceId}/new-chat`);
|
||||
}, [isHydrated, isMobile, router, setDocumentsOpen, setRightPanelCollapsed, workspaceId]);
|
||||
|
||||
if (!isHydrated || !isMobile || workspaceId === undefined) return null;
|
||||
|
||||
return <MobileDocumentsWorkspaceView />;
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import type React from "react";
|
|||
import { DashboardClientLayout } from "./client-layout";
|
||||
|
||||
const PLAYGROUND_SIDEBAR_COLLAPSED_COOKIE = "surfsense_playground_sidebar_collapsed";
|
||||
const RIGHT_PANEL_COLLAPSED_COOKIE = "surfsense_right_panel_collapsed";
|
||||
|
||||
export default async function DashboardLayout({
|
||||
params,
|
||||
|
|
@ -16,11 +17,14 @@ export default async function DashboardLayout({
|
|||
const [{ workspace_id }, cookieStore] = await Promise.all([params, cookies()]);
|
||||
const initialPlaygroundSidebarCollapsed =
|
||||
cookieStore.get(PLAYGROUND_SIDEBAR_COLLAPSED_COOKIE)?.value === "true";
|
||||
const initialRightPanelCollapsed =
|
||||
cookieStore.get(RIGHT_PANEL_COLLAPSED_COOKIE)?.value === "true";
|
||||
|
||||
return (
|
||||
<DashboardClientLayout
|
||||
workspaceId={workspace_id}
|
||||
initialPlaygroundSidebarCollapsed={initialPlaygroundSidebarCollapsed}
|
||||
initialRightPanelCollapsed={initialRightPanelCollapsed}
|
||||
>
|
||||
{children}
|
||||
</DashboardClientLayout>
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ export const localExpandedFolderKeysAtom = atomWithStorage<Record<number, string
|
|||
export const renamingFolderIdAtom = atom<number | null>(null);
|
||||
|
||||
/**
|
||||
* Bumped whenever a folder is (un)watched outside DocumentsSidebar (e.g. the
|
||||
* Bumped whenever a folder is (un)watched outside DocumentRightPanel (e.g. the
|
||||
* sidebar-footer "Watch Local Folder" button) so the tree re-reads Electron's
|
||||
* watched-folder list and shows the sync badge without a reload.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -6,6 +6,12 @@ export const globalDocumentsQueryParamsAtom = atom<GetDocumentsRequest["queryPar
|
|||
page: 0,
|
||||
});
|
||||
|
||||
/**
|
||||
* Whether the Documents panel is open. Shared so the Composer can toggle the
|
||||
* same surface the sidebar's Documents button controls.
|
||||
*/
|
||||
export const documentsSidebarOpenAtom = atom(true);
|
||||
|
||||
export interface AgentCreatedDocument {
|
||||
id: number;
|
||||
title: string;
|
||||
|
|
@ -13,6 +19,7 @@ export interface AgentCreatedDocument {
|
|||
workspaceId: number;
|
||||
folderId: number | null;
|
||||
createdById: string | null;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export const agentCreatedDocumentsAtom = atom<AgentCreatedDocument[]>([]);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,22 @@
|
|||
import { atom } from "jotai";
|
||||
import { atomWithStorage } from "jotai/utils";
|
||||
|
||||
const RIGHT_PANEL_COLLAPSED_COOKIE = "surfsense_right_panel_collapsed";
|
||||
const RIGHT_PANEL_COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
|
||||
|
||||
const rightPanelCookieStorage = {
|
||||
getItem: (_key: string, initialValue: boolean) => {
|
||||
if (typeof document === "undefined") return initialValue;
|
||||
const match = document.cookie.match(/(?:^|; )surfsense_right_panel_collapsed=([^;]+)/);
|
||||
return match ? match[1] === "true" : initialValue;
|
||||
},
|
||||
setItem: (_key: string, value: boolean) => {
|
||||
document.cookie = `${RIGHT_PANEL_COLLAPSED_COOKIE}=${value}; path=/; max-age=${RIGHT_PANEL_COOKIE_MAX_AGE}; samesite=lax`;
|
||||
},
|
||||
removeItem: () => {
|
||||
document.cookie = `${RIGHT_PANEL_COLLAPSED_COOKIE}=; path=/; max-age=0; samesite=lax`;
|
||||
},
|
||||
};
|
||||
|
||||
export type RightPanelTab =
|
||||
| "sources"
|
||||
|
|
@ -11,4 +29,9 @@ export type RightPanelTab =
|
|||
export const rightPanelTabAtom = atom<RightPanelTab>("sources");
|
||||
|
||||
/** Whether the right panel is collapsed (hidden but state preserved) */
|
||||
export const rightPanelCollapsedAtom = atom(false);
|
||||
export const rightPanelCollapsedAtom = atomWithStorage(
|
||||
RIGHT_PANEL_COLLAPSED_COOKIE,
|
||||
false,
|
||||
rightPanelCookieStorage,
|
||||
{ getOnInit: true }
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
ChevronLeft,
|
||||
ChevronRight,
|
||||
LayoutGrid,
|
||||
LibraryBig,
|
||||
Settings2,
|
||||
TriangleAlert,
|
||||
Unplug,
|
||||
|
|
@ -11,6 +12,7 @@ import {
|
|||
Wrench,
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { type ReactNode, useCallback, useRef, useState } from "react";
|
||||
import type { ConnectorRow } from "@/components/assistant-ui/connector-popup/hooks/use-connector-rows";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -32,6 +34,7 @@ import {
|
|||
getToolDisplayName,
|
||||
getToolIcon,
|
||||
} from "@/contracts/enums/toolIcons";
|
||||
import { getWorkspaceIdNumber } from "@/lib/route-params";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Minimal shape of a grouped agent-tool section (mirrors thread.tsx). */
|
||||
|
|
@ -91,6 +94,9 @@ export function ComposerAddMenuDrawer({
|
|||
onToggleToolGroup,
|
||||
toolsLoading,
|
||||
}: ComposerAddMenuDrawerProps) {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const workspaceId = getWorkspaceIdNumber(params);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [stack, setStack] = useState<Screen[]>([{ kind: "root" }]);
|
||||
// Slide direction: forward on push, back on pop — drives the enter animation.
|
||||
|
|
@ -157,6 +163,19 @@ export function ComposerAddMenuDrawer({
|
|||
<Upload className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 text-left">Upload Files</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={ROW}
|
||||
onClick={() => {
|
||||
if (workspaceId !== undefined) {
|
||||
router.push(`/dashboard/${workspaceId}/documents`);
|
||||
}
|
||||
close();
|
||||
}}
|
||||
>
|
||||
<LibraryBig className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 text-left">Documents</span>
|
||||
</button>
|
||||
<button type="button" className={ROW} onClick={() => push({ kind: "connectors" })}>
|
||||
<Unplug className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 text-left">MCP Connectors</span>
|
||||
|
|
|
|||
|
|
@ -38,19 +38,12 @@ import { Spinner } from "@/components/ui/spinner";
|
|||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import type { DocumentNodeDoc } from "@/lib/documents/document-tree-types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SidebarListItem } from "../layout/ui/sidebar/SidebarListItem";
|
||||
import { DND_TYPES } from "./FolderNode";
|
||||
import { isVersionableType } from "./version-history";
|
||||
|
||||
export interface DocumentNodeDoc {
|
||||
id: number;
|
||||
title: string;
|
||||
document_type: string;
|
||||
folderId: number | null;
|
||||
status?: { state: string; reason?: string | null };
|
||||
}
|
||||
|
||||
interface DocumentNodeProps {
|
||||
doc: DocumentNodeDoc;
|
||||
depth: number;
|
||||
|
|
|
|||
44
surfsense_web/components/documents/DocumentsEmptyState.tsx
Normal file
44
surfsense_web/components/documents/DocumentsEmptyState.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import type { DocumentsEmptyReason } from "@/lib/documents/documents-view-model";
|
||||
|
||||
export function DocumentsEmptyState({ reason }: { reason: DocumentsEmptyReason }) {
|
||||
const t = useTranslations("documents");
|
||||
|
||||
if (reason.kind === "loading") {
|
||||
return (
|
||||
<div className="space-y-1 px-4 py-2" aria-busy="true" aria-label={t("loading")}>
|
||||
{["loading-1", "loading-2", "loading-3", "loading-4", "loading-5"].map(
|
||||
(key, index) => (
|
||||
<div key={key} className="flex h-8 items-center gap-2">
|
||||
<Skeleton className="size-4 rounded-sm" />
|
||||
<Skeleton className={index % 2 === 0 ? "h-4 w-44" : "h-4 w-32"} />
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const title =
|
||||
reason.kind === "workspace_empty" ? t("no_documents") : t("empty_no_matching_documents");
|
||||
const description =
|
||||
reason.kind === "workspace_empty"
|
||||
? t("empty_upload_files")
|
||||
: reason.kind === "no_search_results"
|
||||
? t("empty_try_different_search")
|
||||
: t("empty_adjust_filters");
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-1 select-none flex-col items-center justify-center gap-1 px-4 py-12 text-center text-muted-foreground"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<p className="text-sm font-medium">{title}</p>
|
||||
<p className="text-xs text-muted-foreground/70">{description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,12 +1,14 @@
|
|||
"use client";
|
||||
|
||||
import { FolderPlus, ListFilter, Search, X } from "lucide-react";
|
||||
import { FolderPlus, ListFilter, Search, Upload, X } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
|
||||
|
|
@ -20,6 +22,8 @@ export function DocumentsFilters({
|
|||
onToggleType,
|
||||
activeTypes,
|
||||
onCreateFolder,
|
||||
onUploadClick,
|
||||
isUploading = false,
|
||||
}: {
|
||||
typeCounts: Partial<Record<DocumentTypeEnum, number>>;
|
||||
onSearch: (v: string) => void;
|
||||
|
|
@ -27,11 +31,16 @@ export function DocumentsFilters({
|
|||
onToggleType: (type: DocumentTypeEnum, checked: boolean) => void;
|
||||
activeTypes: DocumentTypeEnum[];
|
||||
onCreateFolder?: () => void;
|
||||
onUploadClick?: () => void;
|
||||
isUploading?: boolean;
|
||||
}) {
|
||||
const t = useTranslations("documents");
|
||||
const id = React.useId();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { openDialog: openUploadDialog } = useDocumentUploadDialog();
|
||||
const handleUpload = onUploadClick ?? openUploadDialog;
|
||||
|
||||
const [typeSearchQuery, setTypeSearchQuery] = useState("");
|
||||
const [scrollPos, setScrollPos] = useState<"top" | "middle" | "bottom">("top");
|
||||
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||||
|
|
@ -60,10 +69,64 @@ export function DocumentsFilters({
|
|||
}, [typeCountsRecord]);
|
||||
|
||||
return (
|
||||
<div className="flex select-none">
|
||||
<div className="flex select-none flex-col gap-2">
|
||||
{/* Search Input */}
|
||||
<div className="relative w-full">
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-muted-foreground">
|
||||
<Search size={13} aria-hidden="true" />
|
||||
</div>
|
||||
<Input
|
||||
id={`${id}-input`}
|
||||
ref={inputRef}
|
||||
className="h-9 w-full select-none border-0 bg-muted pl-8 pr-7 text-sm shadow-none focus:select-text [&::-webkit-search-cancel-button]:hidden"
|
||||
value={searchValue}
|
||||
onChange={(e) => onSearch(e.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape" && searchValue) {
|
||||
event.preventDefault();
|
||||
onSearch("");
|
||||
}
|
||||
}}
|
||||
placeholder={t("search_documents")}
|
||||
type="search"
|
||||
autoComplete="off"
|
||||
enterKeyHint="search"
|
||||
spellCheck={false}
|
||||
aria-label={t("search_documents")}
|
||||
/>
|
||||
{Boolean(searchValue) && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-2 top-1/2 h-5 w-5 -translate-y-1/2 rounded-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
aria-label="Clear filter"
|
||||
onClick={() => {
|
||||
onSearch("");
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<X size={14} strokeWidth={2} aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
{/* Upload Button */}
|
||||
<Button
|
||||
data-joyride="upload-button"
|
||||
onClick={handleUpload}
|
||||
disabled={isUploading}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 flex-1 gap-1.5 border-0 bg-white text-gray-700 shadow-none hover:bg-accent hover:text-accent-foreground dark:bg-white dark:text-gray-800"
|
||||
>
|
||||
{isUploading ? <Spinner size="xs" /> : <Upload size={13} />}
|
||||
<span>{isUploading ? t("uploading") : t("upload_files")}</span>
|
||||
</Button>
|
||||
|
||||
{/* New Folder + Filter Toggle Group */}
|
||||
<ToggleGroup type="multiple" value={[]} className="overflow-visible">
|
||||
<ToggleGroup type="multiple" value={[]} className="shrink-0 overflow-visible">
|
||||
{onCreateFolder && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
@ -169,38 +232,6 @@ export function DocumentsFilters({
|
|||
</PopoverContent>
|
||||
</Popover>
|
||||
</ToggleGroup>
|
||||
|
||||
{/* Search Input */}
|
||||
<div className="relative flex-1 min-w-0">
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-muted-foreground">
|
||||
<Search size={13} aria-hidden="true" />
|
||||
</div>
|
||||
<Input
|
||||
id={`${id}-input`}
|
||||
ref={inputRef}
|
||||
className="h-8 w-full select-none border-0 bg-muted pl-8 pr-7 text-sm shadow-none focus:select-text"
|
||||
value={searchValue}
|
||||
onChange={(e) => onSearch(e.target.value)}
|
||||
placeholder="Search docs"
|
||||
type="text"
|
||||
aria-label={t("filter_placeholder")}
|
||||
/>
|
||||
{Boolean(searchValue) && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-1 top-1/2 h-5 w-5 -translate-y-1/2 rounded-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
aria-label="Clear filter"
|
||||
onClick={() => {
|
||||
onSearch("");
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<X size={14} strokeWidth={2} aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,98 @@
|
|||
"use client";
|
||||
|
||||
import { CircleAlert, Dot, Folder, LoaderCircle } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { getDocumentTypeLabel } from "@/lib/documents/document-type-labels";
|
||||
import type { DocumentNodeDoc, FolderDisplay } from "@/lib/documents/document-tree-types";
|
||||
import type { DocumentSearchHit } from "@/lib/documents/documents-view-model";
|
||||
import { getDocumentTypeIcon } from "./DocumentTypeIcon";
|
||||
import { HighlightedText } from "./HighlightedText";
|
||||
|
||||
interface DocumentsSearchResultsProps {
|
||||
hits: DocumentSearchHit[];
|
||||
onOpenDocument: (document: DocumentNodeDoc) => void;
|
||||
onOpenFolder: (folder: FolderDisplay) => void;
|
||||
}
|
||||
|
||||
export function DocumentsSearchResults({
|
||||
hits,
|
||||
onOpenDocument,
|
||||
onOpenFolder,
|
||||
}: DocumentsSearchResultsProps) {
|
||||
const t = useTranslations("documents");
|
||||
|
||||
return (
|
||||
<div className="px-2 py-1">
|
||||
<p className="px-2 pb-1.5 text-[11px] font-medium text-muted-foreground" role="status">
|
||||
{t("search_results", { count: hits.length })}
|
||||
</p>
|
||||
<ul aria-label={t("search_results_label")}>
|
||||
{hits.map((hit) => {
|
||||
const title = hit.kind === "document" ? hit.document.title : hit.folder.name;
|
||||
const path = hit.path.length > 0 ? hit.path.join(" / ") : t("search_root");
|
||||
const isProcessing =
|
||||
hit.kind === "document" &&
|
||||
(hit.document.status?.state === "pending" ||
|
||||
hit.document.status?.state === "processing");
|
||||
const isUnavailable =
|
||||
isProcessing ||
|
||||
(hit.kind === "document" && hit.document.status?.state === "failed");
|
||||
const isFailed =
|
||||
hit.kind === "document" && hit.document.status?.state === "failed";
|
||||
|
||||
return (
|
||||
<li
|
||||
key={`${hit.kind}-${hit.kind === "document" ? hit.document.id : hit.folder.id}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isUnavailable}
|
||||
onClick={() =>
|
||||
hit.kind === "document"
|
||||
? onOpenDocument(hit.document)
|
||||
: onOpenFolder(hit.folder)
|
||||
}
|
||||
className="group flex w-full items-center gap-2 rounded-md px-2 py-2 text-left transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<span className="flex size-7 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
|
||||
{isProcessing ? (
|
||||
<span className="animate-spin">
|
||||
<LoaderCircle className="size-4" />
|
||||
</span>
|
||||
) : isFailed ? (
|
||||
<CircleAlert className="size-4 text-destructive" />
|
||||
) : hit.kind === "document" ? (
|
||||
getDocumentTypeIcon(hit.document.document_type, "size-4")
|
||||
) : (
|
||||
<Folder className="size-4" />
|
||||
)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium">
|
||||
<HighlightedText text={title} ranges={hit.match.ranges} />
|
||||
</span>
|
||||
<span className="flex items-center text-[11px] text-muted-foreground">
|
||||
{isFailed
|
||||
? (hit.kind === "document" && hit.document.status?.reason) ||
|
||||
t("search_document_failed")
|
||||
: (
|
||||
<>
|
||||
<span className="truncate">{path}</span>
|
||||
<Dot className="size-3 shrink-0" aria-hidden="true" />
|
||||
<span className="shrink-0">
|
||||
{hit.kind === "document"
|
||||
? getDocumentTypeLabel(hit.document.document_type)
|
||||
: t("search_folder")}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
surfsense_web/components/documents/DocumentsView.tsx
Normal file
41
surfsense_web/components/documents/DocumentsView.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
"use client";
|
||||
|
||||
import type { FolderDisplay } from "@/lib/documents/document-tree-types";
|
||||
import type { DocumentsViewModel } from "@/lib/documents/documents-view-model";
|
||||
import { DocumentsEmptyState } from "./DocumentsEmptyState";
|
||||
import { DocumentsSearchResults } from "./DocumentsSearchResults";
|
||||
import { FolderTreeView, type FolderTreeViewProps } from "./FolderTreeView";
|
||||
|
||||
interface DocumentsViewProps
|
||||
extends Omit<FolderTreeViewProps, "folders" | "documents"> {
|
||||
viewModel: DocumentsViewModel;
|
||||
onOpenFolder: (folder: FolderDisplay) => void;
|
||||
}
|
||||
|
||||
export function DocumentsView({
|
||||
viewModel,
|
||||
onOpenFolder,
|
||||
...treeProps
|
||||
}: DocumentsViewProps) {
|
||||
if (viewModel.mode === "empty") {
|
||||
return <DocumentsEmptyState reason={viewModel.reason} />;
|
||||
}
|
||||
|
||||
if (viewModel.mode === "search") {
|
||||
return (
|
||||
<DocumentsSearchResults
|
||||
hits={viewModel.hits}
|
||||
onOpenDocument={treeProps.onPreviewDocument}
|
||||
onOpenFolder={onOpenFolder}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FolderTreeView
|
||||
{...treeProps}
|
||||
folders={viewModel.folders}
|
||||
documents={viewModel.documents}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -35,8 +35,8 @@ import {
|
|||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import type { FolderDisplay, FolderSelectionState } from "@/lib/documents/document-tree-types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { FolderSelectionState } from "./FolderTreeView";
|
||||
|
||||
export const DND_TYPES = {
|
||||
FOLDER: "FOLDER",
|
||||
|
|
@ -45,15 +45,6 @@ export const DND_TYPES = {
|
|||
|
||||
type DropZone = "top" | "middle" | "bottom";
|
||||
|
||||
export interface FolderDisplay {
|
||||
id: number;
|
||||
name: string;
|
||||
position: string;
|
||||
parentId: number | null;
|
||||
workspaceId: number;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
interface FolderNodeProps {
|
||||
folder: FolderDisplay;
|
||||
depth: number;
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import {
|
|||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { FolderDisplay } from "./FolderNode";
|
||||
import type { FolderDisplay } from "@/lib/documents/document-tree-types";
|
||||
|
||||
interface FolderPickerDialogProps {
|
||||
open: boolean;
|
||||
|
|
|
|||
|
|
@ -1,19 +1,20 @@
|
|||
"use client";
|
||||
|
||||
import { useAtom } from "jotai";
|
||||
import { Search } from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { DndProvider } from "react-dnd";
|
||||
import { HTML5Backend } from "react-dnd-html5-backend";
|
||||
import { renamingFolderIdAtom } from "@/atoms/documents/folder.atoms";
|
||||
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
|
||||
import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
|
||||
import { DocumentNode, type DocumentNodeDoc } from "./DocumentNode";
|
||||
import { type FolderDisplay, FolderNode } from "./FolderNode";
|
||||
import type {
|
||||
DocumentNodeDoc,
|
||||
FolderDisplay,
|
||||
FolderSelectionState,
|
||||
} from "@/lib/documents/document-tree-types";
|
||||
import { DocumentNode } from "./DocumentNode";
|
||||
import { FolderNode } from "./FolderNode";
|
||||
|
||||
export type FolderSelectionState = "all" | "some" | "none";
|
||||
|
||||
interface FolderTreeViewProps {
|
||||
export interface FolderTreeViewProps {
|
||||
folders: FolderDisplay[];
|
||||
documents: DocumentNodeDoc[];
|
||||
expandedIds: Set<number>;
|
||||
|
|
@ -34,8 +35,6 @@ interface FolderTreeViewProps {
|
|||
onResetDocument?: (doc: DocumentNodeDoc) => void;
|
||||
onExportDocument?: (doc: DocumentNodeDoc, format: string) => void;
|
||||
onVersionHistory?: (doc: DocumentNodeDoc) => void;
|
||||
activeTypes: DocumentTypeEnum[];
|
||||
searchQuery?: string;
|
||||
onDropIntoFolder?: (
|
||||
itemType: "folder" | "document",
|
||||
itemId: number,
|
||||
|
|
@ -76,8 +75,6 @@ export function FolderTreeView({
|
|||
onResetDocument,
|
||||
onExportDocument,
|
||||
onVersionHistory,
|
||||
activeTypes,
|
||||
searchQuery,
|
||||
onDropIntoFolder,
|
||||
onReorderFolder,
|
||||
watchedFolderIds,
|
||||
|
|
@ -99,53 +96,11 @@ export function FolderTreeView({
|
|||
);
|
||||
const handleCancelRename = useCallback(() => setRenamingFolderId(null), [setRenamingFolderId]);
|
||||
|
||||
const effectiveActiveTypes = useMemo(() => {
|
||||
if (
|
||||
activeTypes.includes("FILE" as DocumentTypeEnum) &&
|
||||
!activeTypes.includes("LOCAL_FOLDER_FILE" as DocumentTypeEnum)
|
||||
) {
|
||||
return [...activeTypes, "LOCAL_FOLDER_FILE" as DocumentTypeEnum];
|
||||
}
|
||||
return activeTypes;
|
||||
}, [activeTypes]);
|
||||
|
||||
const hasDescendantMatch = useMemo(() => {
|
||||
if (effectiveActiveTypes.length === 0 && !searchQuery) return null;
|
||||
const match: Record<number, boolean> = {};
|
||||
|
||||
function check(folderId: number): boolean {
|
||||
if (match[folderId] !== undefined) return match[folderId];
|
||||
const childDocs = (docsByFolder[folderId] ?? []).some(
|
||||
(d) =>
|
||||
effectiveActiveTypes.length === 0 ||
|
||||
effectiveActiveTypes.includes(d.document_type as DocumentTypeEnum)
|
||||
);
|
||||
if (childDocs) {
|
||||
match[folderId] = true;
|
||||
return true;
|
||||
}
|
||||
const childFolders = foldersByParent[folderId] ?? [];
|
||||
for (const cf of childFolders) {
|
||||
if (check(cf.id)) {
|
||||
match[folderId] = true;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
match[folderId] = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const f of folders) {
|
||||
check(f.id);
|
||||
}
|
||||
return match;
|
||||
}, [folders, docsByFolder, foldersByParent, effectiveActiveTypes, searchQuery]);
|
||||
|
||||
const folderSelectionStates = useMemo(() => {
|
||||
// One folder = one chip. The checkbox now reflects whether the
|
||||
// folder itself is mentioned, not whether every nested doc is —
|
||||
// that reverses the old subtree-fanout semantics in
|
||||
// ``DocumentsSidebar.handleToggleFolderSelect``. We keep the
|
||||
// ``DocumentRightPanel.handleToggleFolderSelect``. We keep the
|
||||
// ``"all" | "some" | "none"`` tri-state on the type so the
|
||||
// existing ``FolderNode`` UI (which renders an indeterminate
|
||||
// glyph for ``"some"``) stays compatible, but only ``"all"``
|
||||
|
|
@ -244,26 +199,18 @@ export function FolderTreeView({
|
|||
const childFolders = (foldersByParent[key] ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => a.position.localeCompare(b.position));
|
||||
const visibleFolders = hasDescendantMatch
|
||||
? childFolders.filter((f) => hasDescendantMatch[f.id])
|
||||
: childFolders;
|
||||
const childDocs = (docsByFolder[key] ?? []).filter(
|
||||
(d) =>
|
||||
effectiveActiveTypes.length === 0 ||
|
||||
effectiveActiveTypes.includes(d.document_type as DocumentTypeEnum)
|
||||
);
|
||||
const childDocs = docsByFolder[key] ?? [];
|
||||
|
||||
const nodes: React.ReactNode[] = [];
|
||||
|
||||
for (let i = 0; i < visibleFolders.length; i++) {
|
||||
const f = visibleFolders[i];
|
||||
for (let i = 0; i < childFolders.length; i++) {
|
||||
const f = childFolders[i];
|
||||
const siblingPositions = {
|
||||
before: i > 0 ? visibleFolders[i - 1].position : null,
|
||||
after: i < visibleFolders.length - 1 ? visibleFolders[i + 1].position : null,
|
||||
before: i > 0 ? childFolders[i - 1].position : null,
|
||||
after: i < childFolders.length - 1 ? childFolders[i + 1].position : null,
|
||||
};
|
||||
|
||||
const isSearchAutoExpanded = !!searchQuery && !!hasDescendantMatch?.[f.id];
|
||||
const isExpanded = expandedIds.has(f.id) || isSearchAutoExpanded;
|
||||
const isExpanded = expandedIds.has(f.id);
|
||||
|
||||
nodes.push(
|
||||
<FolderNode
|
||||
|
|
@ -308,26 +255,6 @@ export function FolderTreeView({
|
|||
|
||||
const treeNodes = renderLevel(null, 0);
|
||||
|
||||
if (treeNodes.length === 0 && folders.length === 0 && documents.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-1 px-4 py-12 text-muted-foreground select-none">
|
||||
<p className="text-sm font-medium">No documents found</p>
|
||||
<p className="text-xs text-muted-foreground/70">
|
||||
Use the Import button above to add files, or the plus menu to manage connectors
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (treeNodes.length === 0 && (effectiveActiveTypes.length > 0 || searchQuery)) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-4 py-12 text-muted-foreground">
|
||||
<Search className="h-10 w-10" />
|
||||
<p className="text-sm text-muted-foreground">No matching documents</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<div className="px-2 py-1">{treeNodes}</div>
|
||||
|
|
|
|||
29
surfsense_web/components/documents/HighlightedText.tsx
Normal file
29
surfsense_web/components/documents/HighlightedText.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import type { MatchRange } from "@/lib/documents/document-search";
|
||||
|
||||
export function HighlightedText({
|
||||
text,
|
||||
ranges,
|
||||
}: {
|
||||
text: string;
|
||||
ranges: MatchRange[];
|
||||
}) {
|
||||
let cursor = 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
{ranges.map(([start, end]) => {
|
||||
const before = text.slice(cursor, start);
|
||||
const match = text.slice(start, end);
|
||||
cursor = end;
|
||||
|
||||
return (
|
||||
<span key={`${start}-${end}`}>
|
||||
{before}
|
||||
<mark className="rounded-sm bg-primary/15 text-inherit">{match}</mark>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{text.slice(cursor)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,13 +1,15 @@
|
|||
"use client";
|
||||
|
||||
import { LibraryBig } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { ReactNode } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useAnonymousMode } from "@/contexts/anonymous-mode";
|
||||
import { useLoginGate } from "@/contexts/login-gate";
|
||||
import { useAnnouncements } from "@/hooks/use-announcements";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { anonymousChatApiService } from "@/lib/apis/anonymous-chat-api.service";
|
||||
import type { ChatItem, PageUsage, Workspace } from "../types/layout.types";
|
||||
import type { ChatItem, NavItem, PageUsage, Workspace } from "../types/layout.types";
|
||||
import { LayoutShell } from "../ui/shell";
|
||||
|
||||
interface FreeLayoutDataProviderProps {
|
||||
|
|
@ -26,8 +28,15 @@ export function FreeLayoutDataProvider({ children }: FreeLayoutDataProviderProps
|
|||
const router = useRouter();
|
||||
const { gate } = useLoginGate();
|
||||
const anonMode = useAnonymousMode();
|
||||
const isMobile = useIsMobile();
|
||||
const { unreadCount: announcementUnreadCount } = useAnnouncements();
|
||||
const [quota, setQuota] = useState<{ used: number; limit: number } | null>(null);
|
||||
const [isDocsSidebarOpen, setIsDocsSidebarOpen] = useState(false);
|
||||
|
||||
// Keep the documents panel closed on mobile; auto-open only on desktop after hydration
|
||||
useEffect(() => {
|
||||
setIsDocsSidebarOpen(!isMobile);
|
||||
}, [isMobile]);
|
||||
|
||||
useEffect(() => {
|
||||
anonymousChatApiService
|
||||
|
|
@ -46,6 +55,26 @@ export function FreeLayoutDataProvider({ children }: FreeLayoutDataProviderProps
|
|||
|
||||
const gatedAction = useCallback((feature: string) => () => gate(feature), [gate]);
|
||||
|
||||
// Documents is only listed on mobile, where it opens the full-screen panel.
|
||||
const navItems: NavItem[] = useMemo(
|
||||
() =>
|
||||
isMobile
|
||||
? [
|
||||
{
|
||||
title: "Documents",
|
||||
url: "#documents",
|
||||
icon: LibraryBig,
|
||||
isActive: isDocsSidebarOpen,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
[isMobile, isDocsSidebarOpen]
|
||||
);
|
||||
|
||||
const handleNavItemClick = useCallback((item: NavItem) => {
|
||||
if (item.url === "#documents") setIsDocsSidebarOpen((open) => !open);
|
||||
}, []);
|
||||
|
||||
const pageUsage: PageUsage | undefined = quota
|
||||
? { pagesUsed: quota.used, pagesLimit: quota.limit }
|
||||
: undefined;
|
||||
|
|
@ -64,7 +93,8 @@ export function FreeLayoutDataProvider({ children }: FreeLayoutDataProviderProps
|
|||
onWorkspaceSettings={gatedAction("workspace settings")}
|
||||
onAddWorkspace={gatedAction("create workspaces")}
|
||||
workspace={GUEST_SPACE}
|
||||
navItems={[]}
|
||||
navItems={navItems}
|
||||
onNavItemClick={handleNavItemClick}
|
||||
chats={[]}
|
||||
activeChatId={null}
|
||||
onNewChat={resetChat}
|
||||
|
|
@ -87,6 +117,11 @@ export function FreeLayoutDataProvider({ children }: FreeLayoutDataProviderProps
|
|||
isChatPage
|
||||
showTabs={false}
|
||||
isLoadingChats={false}
|
||||
documentsPanel={{
|
||||
open: isDocsSidebarOpen,
|
||||
onOpenChange: setIsDocsSidebarOpen,
|
||||
}}
|
||||
mobileDocumentsWorkspaceFromState
|
||||
>
|
||||
{children}
|
||||
</LayoutShell>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,26 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { AlarmClock, AlertTriangle, Shapes, SquareTerminal, Unplug } from "lucide-react";
|
||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import { useHydrateAtoms } from "jotai/utils";
|
||||
import {
|
||||
AlarmClock,
|
||||
AlertTriangle,
|
||||
LibraryBig,
|
||||
Shapes,
|
||||
SquareTerminal,
|
||||
Unplug,
|
||||
} from "lucide-react";
|
||||
import { useParams, usePathname, useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { currentThreadAtom, resetCurrentThreadAtom } from "@/atoms/chat/current-thread.atom";
|
||||
import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms";
|
||||
import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom";
|
||||
import { announcementsDialogAtom } from "@/atoms/layout/dialogs.atom";
|
||||
import { rightPanelCollapsedAtom } from "@/atoms/layout/right-panel.atom";
|
||||
import { removeChatTabAtom, syncChatTabAtom } from "@/atoms/tabs/tabs.atom";
|
||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||
import { deleteWorkspaceMutationAtom } from "@/atoms/workspaces/workspace-mutation.atoms";
|
||||
|
|
@ -43,6 +53,7 @@ import { Spinner } from "@/components/ui/spinner";
|
|||
import { useActivateChatThread } from "@/hooks/use-activate-chat-thread";
|
||||
import { useAnnouncements } from "@/hooks/use-announcements";
|
||||
import { useInbox } from "@/hooks/use-inbox";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { getChatUrl, type ResolvedTab } from "@/hooks/use-resolved-tabs";
|
||||
import { useArchiveThread, useDeleteThread, useRenameThread } from "@/hooks/use-thread-mutations";
|
||||
import { notificationsApiService } from "@/lib/apis/notifications-api.service";
|
||||
|
|
@ -59,14 +70,17 @@ import { LayoutShell } from "../ui/shell";
|
|||
interface LayoutDataProviderProps {
|
||||
workspaceId: string;
|
||||
initialPlaygroundSidebarCollapsed: boolean;
|
||||
initialRightPanelCollapsed: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function LayoutDataProvider({
|
||||
workspaceId,
|
||||
initialPlaygroundSidebarCollapsed,
|
||||
initialRightPanelCollapsed,
|
||||
children,
|
||||
}: LayoutDataProviderProps) {
|
||||
useHydrateAtoms([[rightPanelCollapsedAtom, initialRightPanelCollapsed]]);
|
||||
const t = useTranslations("dashboard");
|
||||
const tCommon = useTranslations("common");
|
||||
const tSidebar = useTranslations("sidebar");
|
||||
|
|
@ -74,10 +88,15 @@ export function LayoutDataProvider({
|
|||
const params = useParams();
|
||||
const pathname = usePathname();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// Announcements
|
||||
const { unreadCount: announcementUnreadCount } = useAnnouncements();
|
||||
|
||||
// Desktop Documents panel state. Mobile uses the /documents workspace route.
|
||||
const [isDocumentsSidebarOpen, setIsDocumentsSidebarOpen] = useAtom(documentsSidebarOpenAtom);
|
||||
const setIsRightPanelCollapsed = useSetAtom(rightPanelCollapsedAtom);
|
||||
|
||||
// Atoms
|
||||
const { data: user } = useAtomValue(currentUserAtom);
|
||||
const {
|
||||
|
|
@ -294,13 +313,14 @@ export function LayoutDataProvider({
|
|||
}, [threadsData, workspaceId]);
|
||||
|
||||
// Navigation items
|
||||
// Automations and Artifacts are rendered explicitly below "New chat"
|
||||
// in the sidebar. Documents is embedded below Recents; notifications and
|
||||
// announcements live in the avatar rail/dropdown.
|
||||
// Automations, Artifacts and Documents are rendered explicitly below "New
|
||||
// chat" in the sidebar. Documents is a mobile workspace destination;
|
||||
// notifications and announcements live in the avatar rail/dropdown.
|
||||
const isAutomationsActive = pathname?.includes("/automations") === true;
|
||||
const isArtifactsActive = pathname?.endsWith("/artifacts") === true;
|
||||
const isPlaygroundRoute = pathname?.includes("/playground") === true;
|
||||
const isConnectorsRoute = pathname?.includes("/connectors") === true;
|
||||
const isDocumentsPage = pathname?.endsWith("/documents") === true;
|
||||
const navItems: NavItem[] = useMemo(
|
||||
() =>
|
||||
(
|
||||
|
|
@ -329,9 +349,25 @@ export function LayoutDataProvider({
|
|||
icon: SquareTerminal,
|
||||
isActive: isPlaygroundRoute,
|
||||
},
|
||||
isMobile
|
||||
? {
|
||||
title: "Documents",
|
||||
url: `/dashboard/${workspaceId}/documents`,
|
||||
icon: LibraryBig,
|
||||
isActive: isDocumentsPage,
|
||||
}
|
||||
: null,
|
||||
] as (NavItem | null)[]
|
||||
).filter((item): item is NavItem => item !== null),
|
||||
[workspaceId, isAutomationsActive, isArtifactsActive, isConnectorsRoute, isPlaygroundRoute]
|
||||
[
|
||||
workspaceId,
|
||||
isAutomationsActive,
|
||||
isArtifactsActive,
|
||||
isConnectorsRoute,
|
||||
isPlaygroundRoute,
|
||||
isMobile,
|
||||
isDocumentsPage,
|
||||
]
|
||||
);
|
||||
|
||||
// Handlers
|
||||
|
|
@ -472,9 +508,19 @@ export function LayoutDataProvider({
|
|||
|
||||
const handleNavItemClick = useCallback(
|
||||
(item: NavItem) => {
|
||||
if (item.url === "#documents") {
|
||||
if (isDocumentsSidebarOpen) {
|
||||
// Already the active surface — the button doubles as a collapse toggle.
|
||||
setIsRightPanelCollapsed((prev) => !prev);
|
||||
} else {
|
||||
setIsDocumentsSidebarOpen(true);
|
||||
setIsRightPanelCollapsed(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
router.push(item.url);
|
||||
},
|
||||
[router]
|
||||
[router, isDocumentsSidebarOpen, setIsDocumentsSidebarOpen, setIsRightPanelCollapsed]
|
||||
);
|
||||
|
||||
const handleNewChat = useCallback(() => {
|
||||
|
|
@ -659,6 +705,7 @@ export function LayoutDataProvider({
|
|||
isArtifactsPage ||
|
||||
isPlaygroundPage ||
|
||||
isConnectorsPage ||
|
||||
isDocumentsPage ||
|
||||
isAllChatsPage;
|
||||
|
||||
return (
|
||||
|
|
@ -712,12 +759,17 @@ export function LayoutDataProvider({
|
|||
isArtifactsPage ||
|
||||
isPlaygroundPage ||
|
||||
isConnectorsPage ||
|
||||
isDocumentsPage ||
|
||||
isAllChatsPage
|
||||
? "items-start justify-center px-6 py-8 md:px-10 md:pb-10 md:pt-16"
|
||||
: undefined
|
||||
}
|
||||
workspacePanelContentClassName={useWorkspacePanel ? "max-w-5xl select-none" : undefined}
|
||||
isLoadingChats={isLoadingThreads}
|
||||
documentsPanel={{
|
||||
open: isDocumentsSidebarOpen,
|
||||
onOpenChange: setIsDocumentsSidebarOpen,
|
||||
}}
|
||||
notifications={{
|
||||
totalUnreadCount,
|
||||
comments: {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,18 @@
|
|||
"use client";
|
||||
|
||||
import { DocumentRightPanel } from "./DocumentRightPanel";
|
||||
|
||||
interface MobileDocumentsWorkspaceViewProps {
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Page-style Documents destination used by the mobile workspace shell.
|
||||
* DocumentRightPanel keeps the browser state and behavior shared with desktop;
|
||||
* workspaceView swaps only the surrounding navigation and heading structure.
|
||||
*/
|
||||
export function MobileDocumentsWorkspaceView({
|
||||
onOpenChange = () => {},
|
||||
}: MobileDocumentsWorkspaceViewProps) {
|
||||
return <DocumentRightPanel open onOpenChange={onOpenChange} embedded workspaceView />;
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import dynamic from "next/dynamic";
|
|||
import { startTransition, useEffect } from "react";
|
||||
import { closeReportPanelAtom, reportPanelAtom } from "@/atoms/chat/report-panel.atom";
|
||||
import { citationPanelAtom, closeCitationPanelAtom } from "@/atoms/citation/citation-panel.atom";
|
||||
import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms";
|
||||
import { closeEditorPanelAtom, editorPanelAtom } from "@/atoms/editor/editor-panel.atom";
|
||||
import {
|
||||
type RightPanelTab,
|
||||
|
|
@ -18,6 +19,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
|
|||
import { artifactsPanelOpenAtom, closeArtifactsPanelAtom } from "@/features/chat-artifacts";
|
||||
import { closeHitlEditPanelAtom, hitlEditPanelAtom } from "@/features/chat-messages/hitl";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { DocumentRightPanel } from "./DocumentRightPanel";
|
||||
|
||||
const EditorPanelContent = dynamic(
|
||||
() =>
|
||||
|
|
@ -68,6 +70,11 @@ const ArtifactsPanelContent = dynamic(
|
|||
);
|
||||
|
||||
interface RightPanelProps {
|
||||
documentsPanel?: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
reserveDocumentToggleSpace?: boolean;
|
||||
showTopBorder?: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -75,14 +82,18 @@ interface RightPanelToggleButtonProps {
|
|||
className?: string;
|
||||
iconClassName?: string;
|
||||
disabled?: boolean;
|
||||
documentsOnly?: boolean;
|
||||
}
|
||||
|
||||
export function RightPanelToggleButton({
|
||||
className,
|
||||
iconClassName,
|
||||
disabled = false,
|
||||
documentsOnly = false,
|
||||
}: RightPanelToggleButtonProps) {
|
||||
const [collapsed, setCollapsed] = useAtom(rightPanelCollapsedAtom);
|
||||
const activeTab = useAtomValue(rightPanelTabAtom);
|
||||
const documentsOpen = useAtomValue(documentsSidebarOpenAtom);
|
||||
const reportState = useAtomValue(reportPanelAtom);
|
||||
const editorState = useAtomValue(editorPanelAtom);
|
||||
const hitlEditState = useAtomValue(hitlEditPanelAtom);
|
||||
|
|
@ -98,10 +109,19 @@ export function RightPanelToggleButton({
|
|||
: !!editorState.localFilePath);
|
||||
const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave;
|
||||
const citationOpen = citationState.isOpen && citationState.target != null;
|
||||
const hasContent = reportOpen || editorOpen || hitlEditOpen || citationOpen || artifactsOpen;
|
||||
const hasContent =
|
||||
documentsOpen || reportOpen || editorOpen || hitlEditOpen || citationOpen || artifactsOpen;
|
||||
const effectiveTab = resolveEffectiveTab(activeTab, {
|
||||
sources: documentsOpen,
|
||||
report: reportOpen,
|
||||
editor: editorOpen,
|
||||
"hitl-edit": hitlEditOpen,
|
||||
citation: citationOpen,
|
||||
artifacts: artifactsOpen,
|
||||
});
|
||||
const label = collapsed ? "Expand panel" : "Collapse panel";
|
||||
|
||||
if (!hasContent) return null;
|
||||
if (!hasContent || (documentsOnly && effectiveTab !== "sources")) return null;
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
|
|
@ -110,6 +130,7 @@ export function RightPanelToggleButton({
|
|||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={disabled}
|
||||
aria-expanded={!collapsed}
|
||||
onClick={() => {
|
||||
if (disabled) return;
|
||||
startTransition(() => setCollapsed((value) => !value));
|
||||
|
|
@ -128,39 +149,6 @@ export function RightPanelToggleButton({
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolutely positioned expand button — renders at top-right of the main
|
||||
* container so it occupies the same screen position as the collapse button
|
||||
* inside the Documents header.
|
||||
*/
|
||||
export function RightPanelExpandButton() {
|
||||
const [collapsed] = useAtom(rightPanelCollapsedAtom);
|
||||
const reportState = useAtomValue(reportPanelAtom);
|
||||
const editorState = useAtomValue(editorPanelAtom);
|
||||
const hitlEditState = useAtomValue(hitlEditPanelAtom);
|
||||
const citationState = useAtomValue(citationPanelAtom);
|
||||
const artifactsOpen = useAtomValue(artifactsPanelOpenAtom);
|
||||
const reportOpen = reportState.isOpen && !!reportState.reportId;
|
||||
const editorOpen =
|
||||
editorState.isOpen &&
|
||||
(editorState.kind === "document"
|
||||
? !!editorState.documentId
|
||||
: editorState.kind === "memory"
|
||||
? !!editorState.memoryScope
|
||||
: !!editorState.localFilePath);
|
||||
const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave;
|
||||
const citationOpen = citationState.isOpen && citationState.target != null;
|
||||
const hasContent = reportOpen || editorOpen || hitlEditOpen || citationOpen || artifactsOpen;
|
||||
|
||||
if (!collapsed || !hasContent) return null;
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 items-center px-0.5">
|
||||
<RightPanelToggleButton className="-m-0.5" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const PANEL_WIDTHS = {
|
||||
sources: 420,
|
||||
report: 640,
|
||||
|
|
@ -170,14 +158,20 @@ const PANEL_WIDTHS = {
|
|||
artifacts: 420,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Opacity finishes well before width so the panel is already invisible while the
|
||||
* remaining geometry settles — the width tween reflows the main content every
|
||||
* frame, and hiding it early is what keeps the collapse from reading as a fold.
|
||||
*/
|
||||
const PANEL_SLIDE_TRANSITION = {
|
||||
duration: 0.2,
|
||||
ease: [0.22, 1, 0.36, 1],
|
||||
width: { duration: 0.24, ease: [0.4, 0, 0.2, 1] },
|
||||
opacity: { duration: 0.15, ease: [0.4, 0, 0.2, 1] },
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Priority order used to fall back to another open surface when the active
|
||||
* tab's content closes. The neutral "sources" tab is kept as the closed state.
|
||||
* tab's content closes. Artifacts sit just above the always-available sources
|
||||
* tab.
|
||||
*/
|
||||
const TAB_FALLBACK_ORDER: RightPanelTab[] = [
|
||||
"hitl-edit",
|
||||
|
|
@ -196,7 +190,11 @@ function resolveEffectiveTab(
|
|||
return TAB_FALLBACK_ORDER.find((tab) => openByTab[tab]) ?? "sources";
|
||||
}
|
||||
|
||||
export function RightPanel({ showTopBorder = false }: RightPanelProps) {
|
||||
export function RightPanel({
|
||||
documentsPanel,
|
||||
reserveDocumentToggleSpace = true,
|
||||
showTopBorder = false,
|
||||
}: RightPanelProps) {
|
||||
const [activeTab] = useAtom(rightPanelTabAtom);
|
||||
const reportState = useAtomValue(reportPanelAtom);
|
||||
const closeReport = useSetAtom(closeReportPanelAtom);
|
||||
|
|
@ -208,9 +206,10 @@ export function RightPanel({ showTopBorder = false }: RightPanelProps) {
|
|||
const closeCitation = useSetAtom(closeCitationPanelAtom);
|
||||
const artifactsOpen = useAtomValue(artifactsPanelOpenAtom);
|
||||
const closeArtifacts = useSetAtom(closeArtifactsPanelAtom);
|
||||
const [collapsed] = useAtom(rightPanelCollapsedAtom);
|
||||
const collapsed = useAtomValue(rightPanelCollapsedAtom);
|
||||
const reduceMotion = useReducedMotion();
|
||||
|
||||
const documentsOpen = documentsPanel?.open ?? false;
|
||||
const reportOpen = reportState.isOpen && !!reportState.reportId;
|
||||
const editorOpen =
|
||||
editorState.isOpen &&
|
||||
|
|
@ -249,10 +248,11 @@ export function RightPanel({ showTopBorder = false }: RightPanelProps) {
|
|||
]);
|
||||
|
||||
const isVisible =
|
||||
(reportOpen || editorOpen || hitlEditOpen || citationOpen || artifactsOpen) && !collapsed;
|
||||
(documentsOpen || reportOpen || editorOpen || hitlEditOpen || citationOpen || artifactsOpen) &&
|
||||
!collapsed;
|
||||
|
||||
const effectiveTab = resolveEffectiveTab(activeTab, {
|
||||
sources: false,
|
||||
sources: documentsOpen,
|
||||
report: reportOpen,
|
||||
editor: editorOpen,
|
||||
"hitl-edit": hitlEditOpen,
|
||||
|
|
@ -267,10 +267,11 @@ export function RightPanel({ showTopBorder = false }: RightPanelProps) {
|
|||
{isVisible ? (
|
||||
<motion.aside
|
||||
key="right-panel"
|
||||
initial={reduceMotion ? { width: targetWidth } : { width: 0, x: 24, opacity: 0 }}
|
||||
animate={{ width: targetWidth, x: 0, opacity: 1 }}
|
||||
exit={reduceMotion ? { width: 0 } : { width: 0, x: 24, opacity: 0 }}
|
||||
initial={reduceMotion ? { width: targetWidth } : { width: 0, opacity: 0 }}
|
||||
animate={{ width: targetWidth, opacity: 1 }}
|
||||
exit={reduceMotion ? { width: 0 } : { width: 0, opacity: 0 }}
|
||||
transition={reduceMotion ? { duration: 0 } : PANEL_SLIDE_TRANSITION}
|
||||
style={reduceMotion ? undefined : { willChange: "width, opacity" }}
|
||||
className={cn(
|
||||
"flex h-full shrink-0 flex-col overflow-hidden border-l bg-panel text-sidebar-foreground",
|
||||
showTopBorder && "border-t"
|
||||
|
|
@ -278,6 +279,20 @@ export function RightPanel({ showTopBorder = false }: RightPanelProps) {
|
|||
>
|
||||
<div style={{ width: targetWidth }} className="flex h-full min-h-0 flex-col">
|
||||
<div className="relative flex-1 min-h-0 overflow-hidden">
|
||||
{effectiveTab === "sources" && documentsOpen && documentsPanel && (
|
||||
<div className="h-full">
|
||||
<DocumentRightPanel
|
||||
open={documentsPanel.open}
|
||||
onOpenChange={documentsPanel.onOpenChange}
|
||||
embedded
|
||||
headerAction={
|
||||
reserveDocumentToggleSpace ? (
|
||||
<div aria-hidden="true" className="h-8 w-8 shrink-0" />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{effectiveTab === "report" && reportOpen && (
|
||||
<div className="h-full flex flex-col">
|
||||
<ReportPanelContent
|
||||
|
|
|
|||
|
|
@ -20,11 +20,8 @@ import {
|
|||
import type { ChatItem, NavItem, PageUsage, User, Workspace } from "../../types/layout.types";
|
||||
import { Header } from "../header";
|
||||
import { IconRail } from "../icon-rail";
|
||||
import {
|
||||
RightPanel,
|
||||
RightPanelExpandButton,
|
||||
RightPanelToggleButton,
|
||||
} from "../right-panel/RightPanel";
|
||||
import { MobileDocumentsWorkspaceView } from "../right-panel/MobileDocumentsWorkspaceView";
|
||||
import { RightPanel, RightPanelToggleButton } from "../right-panel/RightPanel";
|
||||
import { MobileSidebar, MobileSidebarTrigger, Sidebar, SidebarCollapseButton } from "../sidebar";
|
||||
import type { NotificationsDropdownData } from "../sidebar/NotificationsDropdown";
|
||||
import { TabBar } from "../tabs/TabBar";
|
||||
|
|
@ -81,6 +78,7 @@ function MacDesktopTitleBar({
|
|||
<div className="ml-auto flex h-full items-center [app-region:no-drag] [-webkit-app-region:no-drag]">
|
||||
<RightPanelToggleButton
|
||||
disabled={disableRightPanelToggle}
|
||||
documentsOnly
|
||||
className="h-6 w-6 rounded-md"
|
||||
iconClassName="h-3.5 w-3.5"
|
||||
/>
|
||||
|
|
@ -132,6 +130,12 @@ interface LayoutShellProps {
|
|||
className?: string;
|
||||
notifications?: NotificationsDropdownData;
|
||||
isLoadingChats?: boolean;
|
||||
documentsPanel?: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
/** Anonymous mobile layouts still open Documents from local state instead of a dashboard route. */
|
||||
mobileDocumentsWorkspaceFromState?: boolean;
|
||||
onTabSwitch?: (tab: ResolvedTab) => void;
|
||||
onTabPrefetch?: (tab: ResolvedTab) => void;
|
||||
playgroundSidebar?: React.ReactNode;
|
||||
|
|
@ -144,7 +148,7 @@ function MainContentPanel({
|
|||
onTabPrefetch,
|
||||
onNewChat,
|
||||
showTabs = true,
|
||||
showRightPanelExpandButton = true,
|
||||
reserveRightPanelToggleSpace = true,
|
||||
showTopBorder = false,
|
||||
children,
|
||||
}: {
|
||||
|
|
@ -153,7 +157,7 @@ function MainContentPanel({
|
|||
onTabPrefetch?: (tab: ResolvedTab) => void;
|
||||
onNewChat?: () => void;
|
||||
showTabs?: boolean;
|
||||
showRightPanelExpandButton?: boolean;
|
||||
reserveRightPanelToggleSpace?: boolean;
|
||||
showTopBorder?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
|
|
@ -171,7 +175,7 @@ function MainContentPanel({
|
|||
onTabSwitch={onTabSwitch}
|
||||
onTabPrefetch={onTabPrefetch}
|
||||
onNewChat={onNewChat}
|
||||
showRightPanelExpandButton={showRightPanelExpandButton}
|
||||
reserveRightPanelToggleSpace={reserveRightPanelToggleSpace}
|
||||
showTopBorder={showTopBorder}
|
||||
>
|
||||
{children}
|
||||
|
|
@ -207,7 +211,7 @@ function TabbedMainContentPanel({
|
|||
onTabSwitch,
|
||||
onTabPrefetch,
|
||||
onNewChat,
|
||||
showRightPanelExpandButton,
|
||||
reserveRightPanelToggleSpace,
|
||||
showTopBorder,
|
||||
children,
|
||||
}: {
|
||||
|
|
@ -215,7 +219,7 @@ function TabbedMainContentPanel({
|
|||
onTabSwitch?: (tab: ResolvedTab) => void;
|
||||
onTabPrefetch?: (tab: ResolvedTab) => void;
|
||||
onNewChat?: () => void;
|
||||
showRightPanelExpandButton: boolean;
|
||||
reserveRightPanelToggleSpace: boolean;
|
||||
showTopBorder: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
|
|
@ -232,7 +236,11 @@ function TabbedMainContentPanel({
|
|||
onTabSwitch={onTabSwitch}
|
||||
onTabPrefetch={onTabPrefetch}
|
||||
onNewChat={onNewChat}
|
||||
rightActions={showRightPanelExpandButton ? <RightPanelExpandButton /> : null}
|
||||
rightActions={
|
||||
reserveRightPanelToggleSpace ? (
|
||||
<div aria-hidden="true" className="h-8 w-8 shrink-0" />
|
||||
) : null
|
||||
}
|
||||
className="min-w-0"
|
||||
/>
|
||||
<div className="relative flex flex-1 flex-col bg-panel overflow-hidden min-w-0">
|
||||
|
|
@ -258,7 +266,7 @@ function TabbedMainContentPanel({
|
|||
}
|
||||
|
||||
function DesktopWorkspaceRegion({ children }: { children: React.ReactNode }) {
|
||||
return <div className="flex h-full min-w-0 flex-1 -mr-2">{children}</div>;
|
||||
return <div className="relative flex h-full min-w-0 flex-1 -mr-2">{children}</div>;
|
||||
}
|
||||
|
||||
export function LayoutShell({
|
||||
|
|
@ -304,6 +312,8 @@ export function LayoutShell({
|
|||
className,
|
||||
notifications,
|
||||
isLoadingChats = false,
|
||||
documentsPanel,
|
||||
mobileDocumentsWorkspaceFromState = false,
|
||||
onTabSwitch,
|
||||
onTabPrefetch,
|
||||
playgroundSidebar,
|
||||
|
|
@ -335,6 +345,9 @@ export function LayoutShell({
|
|||
return nextCollapsed;
|
||||
});
|
||||
};
|
||||
const closeMobileDocuments = () => {
|
||||
if (mobileDocumentsWorkspaceFromState) documentsPanel?.onOpenChange(false);
|
||||
};
|
||||
|
||||
// Mobile layout
|
||||
if (isMobile) {
|
||||
|
|
@ -351,23 +364,49 @@ export function LayoutShell({
|
|||
onOpenChange={setMobileMenuOpen}
|
||||
workspaces={workspaces}
|
||||
activeWorkspaceId={activeWorkspaceId}
|
||||
onWorkspaceSelect={onWorkspaceSelect}
|
||||
onWorkspaceSelect={(id) => {
|
||||
closeMobileDocuments();
|
||||
onWorkspaceSelect(id);
|
||||
}}
|
||||
onAddWorkspace={onAddWorkspace}
|
||||
isAtWorkspaceLimit={isAtWorkspaceLimit}
|
||||
maxWorkspacesPerUser={maxWorkspacesPerUser}
|
||||
workspace={workspace}
|
||||
navItems={navItems}
|
||||
onNavItemClick={onNavItemClick}
|
||||
onNavItemClick={(item) => {
|
||||
if (item.url !== "#documents") closeMobileDocuments();
|
||||
onNavItemClick?.(item);
|
||||
}}
|
||||
chats={chats}
|
||||
activeChatId={activeChatId}
|
||||
onNewChat={onNewChat}
|
||||
onChatSelect={onChatSelect}
|
||||
onNewChat={() => {
|
||||
closeMobileDocuments();
|
||||
onNewChat();
|
||||
}}
|
||||
onChatSelect={(chat) => {
|
||||
closeMobileDocuments();
|
||||
onChatSelect(chat);
|
||||
}}
|
||||
onChatPrefetch={onChatPrefetch}
|
||||
onChatRename={onChatRename}
|
||||
onChatDelete={onChatDelete}
|
||||
onChatArchive={onChatArchive}
|
||||
onChatsClick={onChatsClick}
|
||||
onViewAllChats={onViewAllChats}
|
||||
onChatsClick={
|
||||
onChatsClick
|
||||
? () => {
|
||||
closeMobileDocuments();
|
||||
onChatsClick();
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onViewAllChats={
|
||||
onViewAllChats
|
||||
? () => {
|
||||
closeMobileDocuments();
|
||||
onViewAllChats();
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
isAllChatsActive={isAllChatsPage}
|
||||
user={user}
|
||||
onSettings={onSettings}
|
||||
|
|
@ -383,7 +422,14 @@ export function LayoutShell({
|
|||
isLoadingChats={isLoadingChats}
|
||||
/>
|
||||
|
||||
{useWorkspacePanel ? (
|
||||
{mobileDocumentsWorkspaceFromState && documentsPanel?.open ? (
|
||||
<WorkspacePanel
|
||||
viewportClassName="items-start justify-center overflow-hidden px-6 py-8"
|
||||
contentClassName="h-full max-w-5xl select-none"
|
||||
>
|
||||
<MobileDocumentsWorkspaceView onOpenChange={documentsPanel.onOpenChange} />
|
||||
</WorkspacePanel>
|
||||
) : useWorkspacePanel ? (
|
||||
<WorkspacePanel
|
||||
viewportClassName={workspacePanelViewportClassName}
|
||||
contentClassName={workspacePanelContentClassName}
|
||||
|
|
@ -546,14 +592,23 @@ export function LayoutShell({
|
|||
onTabPrefetch={onTabPrefetch}
|
||||
onNewChat={onNewChat}
|
||||
showTabs={showTabs}
|
||||
showRightPanelExpandButton={!isMacDesktop}
|
||||
reserveRightPanelToggleSpace={!isMacDesktop}
|
||||
showTopBorder={isMacDesktop}
|
||||
>
|
||||
{children}
|
||||
</MainContentPanel>
|
||||
|
||||
{/* Right panel — Report/Editor/Citations/Artifacts (desktop only) */}
|
||||
<RightPanel showTopBorder={isMacDesktop} />
|
||||
{/* Right panel — tabbed Documents/Report/Editor/Citations/Artifacts (desktop only) */}
|
||||
<RightPanel
|
||||
documentsPanel={documentsPanel}
|
||||
reserveDocumentToggleSpace={!isMacDesktop}
|
||||
showTopBorder={isMacDesktop}
|
||||
/>
|
||||
{!isMacDesktop && (
|
||||
<div className="absolute right-2 top-2 z-30">
|
||||
<RightPanelToggleButton documentsOnly />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DesktopWorkspaceRegion>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,13 +1,12 @@
|
|||
"use client";
|
||||
|
||||
import { useSetAtom } from "jotai";
|
||||
import { CreditCard, FolderSync, MessageCircleMore, SquarePen, Upload, Zap } from "lucide-react";
|
||||
import { CreditCard, FolderSync, MessageCircleMore, SquarePen, Zap } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { type ReactNode, useMemo, useState } from "react";
|
||||
import { watchedFoldersRefreshAtom } from "@/atoms/documents/folder.atoms";
|
||||
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
|
||||
import { ConnectAgentDialog } from "@/components/mcp/connect-agent-dialog";
|
||||
import { FolderWatchDialog } from "@/components/sources/FolderWatchDialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -22,7 +21,6 @@ import { SIDEBAR_MIN_WIDTH } from "../../hooks/useSidebarResize";
|
|||
import type { ChatItem, NavItem, PageUsage, User, Workspace } from "../../types/layout.types";
|
||||
import { ChatListItem } from "./ChatListItem";
|
||||
import { CreditBalanceDisplay } from "./CreditBalanceDisplay";
|
||||
import { DocumentsSidebar } from "./DocumentsSidebar";
|
||||
import { NavSection } from "./NavSection";
|
||||
import { SidebarButton, SidebarButtonBadge } from "./SidebarButton";
|
||||
import { SidebarCollapseButton } from "./SidebarCollapseButton";
|
||||
|
|
@ -137,7 +135,8 @@ export function Sidebar({
|
|||
|
||||
// Automations, Artifacts, and Playground are rendered explicitly right below
|
||||
// New Chat. Pull them out of the nav items list so they don't also appear
|
||||
// in the bottom NavSection. Documents is embedded below Recents.
|
||||
// in the bottom NavSection. Documents is only present in navItems on mobile,
|
||||
// where it opens the Documents workspace destination.
|
||||
const automationsItem = useMemo(
|
||||
() => navItems.find((item) => item.url.endsWith("/automations")),
|
||||
[navItems]
|
||||
|
|
@ -154,10 +153,16 @@ export function Sidebar({
|
|||
() => navItems.find((item) => item.url.endsWith("/playground")),
|
||||
[navItems]
|
||||
);
|
||||
const documentsItem = useMemo(
|
||||
() => navItems.find((item) => item.url === "#documents" || item.url.endsWith("/documents")),
|
||||
[navItems]
|
||||
);
|
||||
const footerNavItems = useMemo(
|
||||
() =>
|
||||
navItems.filter(
|
||||
(item) =>
|
||||
item.url !== "#documents" &&
|
||||
!item.url.endsWith("/documents") &&
|
||||
!item.url.endsWith("/automations") &&
|
||||
!item.url.endsWith("/artifacts") &&
|
||||
!item.url.endsWith("/connectors") &&
|
||||
|
|
@ -275,6 +280,16 @@ export function Sidebar({
|
|||
tooltipContent={isCollapsed ? connectorsItem.title : undefined}
|
||||
/>
|
||||
)}
|
||||
{documentsItem && (
|
||||
<SidebarButton
|
||||
icon={documentsItem.icon}
|
||||
label={documentsItem.title}
|
||||
onClick={() => onNavItemClick?.(documentsItem)}
|
||||
isCollapsed={isCollapsed}
|
||||
isActive={documentsItem.isActive}
|
||||
tooltipContent={isCollapsed ? documentsItem.title : undefined}
|
||||
/>
|
||||
)}
|
||||
{playgroundItem && (
|
||||
<SidebarButton
|
||||
icon={playgroundItem.icon}
|
||||
|
|
@ -296,6 +311,7 @@ export function Sidebar({
|
|||
<SidebarSection
|
||||
title={t("recents")}
|
||||
defaultOpen={true}
|
||||
fillHeight={true}
|
||||
alwaysShowAction={!disableTooltips && isAllChatsActive}
|
||||
action={
|
||||
onViewAllChats ? (
|
||||
|
|
@ -342,9 +358,6 @@ export function Sidebar({
|
|||
<p className="px-2 py-1 text-sm text-muted-foreground/60">{t("no_chats")}</p>
|
||||
)}
|
||||
</SidebarSection>
|
||||
<div className="min-h-0 flex flex-1 flex-col">
|
||||
<DocumentsSidebar embedded />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -391,18 +404,13 @@ export function Sidebar({
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sidebar-footer import actions rendered above "Connect your agent":
|
||||
* "Watch Local Folder" (desktop app only) and "Upload Files". Both reuse the
|
||||
* existing dialogs; in anonymous mode they open the login gate instead.
|
||||
*/
|
||||
/** Desktop-only local folder action rendered above "Connect your agent". */
|
||||
function SidebarImportActions() {
|
||||
const params = useParams();
|
||||
const workspaceId = getWorkspaceIdNumber(params) ?? 0;
|
||||
const { isDesktop } = usePlatform();
|
||||
const isAnonymous = useIsAnonymous();
|
||||
const { gate } = useLoginGate();
|
||||
const { openDialog: openUploadDialog } = useDocumentUploadDialog();
|
||||
const [folderWatchOpen, setFolderWatchOpen] = useState(false);
|
||||
const bumpWatchedFoldersRefresh = useSetAtom(watchedFoldersRefreshAtom);
|
||||
|
||||
|
|
@ -415,11 +423,6 @@ function SidebarImportActions() {
|
|||
onClick={() => (isAnonymous ? gate("watch local folders") : setFolderWatchOpen(true))}
|
||||
/>
|
||||
)}
|
||||
<SidebarButton
|
||||
icon={Upload}
|
||||
label="Upload Files"
|
||||
onClick={() => (isAnonymous ? gate("upload files") : openUploadDialog())}
|
||||
/>
|
||||
{isDesktop && !isAnonymous && (
|
||||
<FolderWatchDialog
|
||||
open={folderWatchOpen}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,115 @@
|
|||
"use client";
|
||||
|
||||
import { useSetAtom } from "jotai";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { slideoutOpenedTickAtom } from "@/lib/layout-events";
|
||||
|
||||
interface SidebarSlideOutPanelProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
ariaLabel: string;
|
||||
width?: number;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable slide-out panel that extends from the sidebar.
|
||||
*
|
||||
* Desktop: absolutely positioned at the sidebar's right edge, overlaying the main
|
||||
* content with a blur backdrop. Does not push/shrink the main content.
|
||||
*
|
||||
* Mobile: full-width absolute overlay (unchanged).
|
||||
*/
|
||||
export function SidebarSlideOutPanel({
|
||||
open,
|
||||
onOpenChange,
|
||||
ariaLabel,
|
||||
width = 360,
|
||||
children,
|
||||
}: SidebarSlideOutPanelProps) {
|
||||
const isMobile = useIsMobile();
|
||||
const bumpSlideoutOpenedTick = useSetAtom(slideoutOpenedTickAtom);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
bumpSlideoutOpenedTick((tick) => tick + 1);
|
||||
}
|
||||
}, [open, bumpSlideoutOpenedTick]);
|
||||
|
||||
const handleEscape = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onOpenChange(false);
|
||||
},
|
||||
[onOpenChange]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => document.removeEventListener("keydown", handleEscape);
|
||||
}, [open, handleEscape]);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<div className="absolute left-0 inset-y-0 z-30 w-full overflow-hidden pointer-events-none">
|
||||
<motion.div
|
||||
initial={{ x: "-100%" }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: "-100%" }}
|
||||
transition={{ type: "tween", duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
|
||||
className="h-full w-full bg-sidebar text-sidebar-foreground flex flex-col pointer-events-auto select-none"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence initial={false}>
|
||||
{open && (
|
||||
<>
|
||||
{/* Blur backdrop covering the main content area (right of sidebar) */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute z-10 bg-black/30 backdrop-blur-sm rounded-xl"
|
||||
style={{ top: -9, bottom: -9, left: "calc(100% + 1px)", width: "200vw" }}
|
||||
onClick={() => onOpenChange(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Panel extending from sidebar's right edge, flush with the wrapper border */}
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width }}
|
||||
exit={{ width: 0 }}
|
||||
transition={{ type: "tween", duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
|
||||
className="absolute z-20 overflow-hidden"
|
||||
style={{ left: "100%", top: -1, bottom: -1 }}
|
||||
>
|
||||
<div
|
||||
style={{ width }}
|
||||
className="h-full bg-sidebar text-sidebar-foreground flex flex-col select-none border shadow-xl"
|
||||
role="dialog"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
export { AllChatsWorkspaceContent } from "./AllChatsSidebar";
|
||||
export { ChatListItem } from "./ChatListItem";
|
||||
export { CreditBalanceDisplay } from "./CreditBalanceDisplay";
|
||||
export { DocumentsSidebar } from "./DocumentsSidebar";
|
||||
export { MobileSidebar, MobileSidebarTrigger } from "./MobileSidebar";
|
||||
export { NavSection } from "./NavSection";
|
||||
export { NotificationsDropdown } from "./NotificationsDropdown";
|
||||
|
|
|
|||
42
surfsense_web/hooks/use-documents-view-model.ts
Normal file
42
surfsense_web/hooks/use-documents-view-model.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
"use client";
|
||||
|
||||
import { useDeferredValue, useMemo } from "react";
|
||||
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
|
||||
import {
|
||||
buildDocumentsViewModel,
|
||||
type DocumentsViewModel,
|
||||
} from "@/lib/documents/documents-view-model";
|
||||
import type { DocumentNodeDoc, FolderDisplay } from "@/lib/documents/document-tree-types";
|
||||
|
||||
interface UseDocumentsViewModelInput {
|
||||
folders: FolderDisplay[];
|
||||
documents: DocumentNodeDoc[];
|
||||
pinnedDocuments?: DocumentNodeDoc[];
|
||||
query: string;
|
||||
activeTypes: DocumentTypeEnum[];
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function useDocumentsViewModel({
|
||||
folders,
|
||||
documents,
|
||||
pinnedDocuments,
|
||||
query,
|
||||
activeTypes,
|
||||
isLoading,
|
||||
}: UseDocumentsViewModelInput): DocumentsViewModel {
|
||||
const deferredQuery = useDeferredValue(query);
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
buildDocumentsViewModel({
|
||||
folders,
|
||||
documents,
|
||||
pinnedDocuments,
|
||||
query: deferredQuery,
|
||||
activeTypes,
|
||||
isLoading,
|
||||
}),
|
||||
[folders, documents, pinnedDocuments, deferredQuery, activeTypes, isLoading]
|
||||
);
|
||||
}
|
||||
94
surfsense_web/lib/documents/document-search.ts
Normal file
94
surfsense_web/lib/documents/document-search.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
export type MatchRange = readonly [start: number, end: number];
|
||||
|
||||
export interface TextMatch {
|
||||
score: number;
|
||||
ranges: MatchRange[];
|
||||
}
|
||||
|
||||
interface NormalizedText {
|
||||
value: string;
|
||||
starts: number[];
|
||||
ends: number[];
|
||||
}
|
||||
|
||||
const DIACRITICS = /\p{Diacritic}/gu;
|
||||
|
||||
function normalizeWithOffsets(text: string): NormalizedText {
|
||||
let value = "";
|
||||
const starts: number[] = [];
|
||||
const ends: number[] = [];
|
||||
let offset = 0;
|
||||
|
||||
for (const character of text) {
|
||||
const normalized = character.normalize("NFD").replace(DIACRITICS, "").toLowerCase();
|
||||
for (const normalizedCharacter of normalized) {
|
||||
value += normalizedCharacter;
|
||||
starts.push(offset);
|
||||
ends.push(offset + character.length);
|
||||
}
|
||||
offset += character.length;
|
||||
}
|
||||
|
||||
return { value, starts, ends };
|
||||
}
|
||||
|
||||
function mergeRanges(indices: number[], text: NormalizedText): MatchRange[] {
|
||||
const ranges: MatchRange[] = [];
|
||||
|
||||
for (const index of indices) {
|
||||
const start = text.starts[index];
|
||||
const end = text.ends[index];
|
||||
const previous = ranges.at(-1);
|
||||
|
||||
if (previous && start <= previous[1]) {
|
||||
ranges[ranges.length - 1] = [previous[0], Math.max(previous[1], end)];
|
||||
} else {
|
||||
ranges.push([start, end]);
|
||||
}
|
||||
}
|
||||
|
||||
return ranges;
|
||||
}
|
||||
|
||||
export function matchText(query: string, text: string): TextMatch | null {
|
||||
const normalizedQuery = normalizeWithOffsets(query.trim()).value;
|
||||
if (!normalizedQuery) return null;
|
||||
|
||||
const normalizedText = normalizeWithOffsets(text);
|
||||
const contiguousIndex = normalizedText.value.indexOf(normalizedQuery);
|
||||
|
||||
if (contiguousIndex >= 0) {
|
||||
const indices = Array.from(
|
||||
{ length: normalizedQuery.length },
|
||||
(_, index) => contiguousIndex + index
|
||||
);
|
||||
const isPrefix = contiguousIndex === 0;
|
||||
const isWordBoundary =
|
||||
isPrefix || /[\s/_.-]/.test(normalizedText.value[contiguousIndex - 1] ?? "");
|
||||
|
||||
return {
|
||||
score: (isPrefix ? 3000 : isWordBoundary ? 2500 : 2000) - contiguousIndex,
|
||||
ranges: mergeRanges(indices, normalizedText),
|
||||
};
|
||||
}
|
||||
|
||||
// ponytail: Greedy subsequence matching intentionally favors a compact,
|
||||
// predictable local search. Replace with uFuzzy if typo/transposition
|
||||
// tolerance becomes a product requirement.
|
||||
const indices: number[] = [];
|
||||
let queryIndex = 0;
|
||||
for (let textIndex = 0; textIndex < normalizedText.value.length; textIndex++) {
|
||||
if (normalizedText.value[textIndex] !== normalizedQuery[queryIndex]) continue;
|
||||
indices.push(textIndex);
|
||||
queryIndex++;
|
||||
if (queryIndex === normalizedQuery.length) break;
|
||||
}
|
||||
|
||||
if (queryIndex !== normalizedQuery.length) return null;
|
||||
|
||||
const span = indices[indices.length - 1] - indices[0] + 1;
|
||||
return {
|
||||
score: 1000 - span - indices[0],
|
||||
ranges: mergeRanges(indices, normalizedText),
|
||||
};
|
||||
}
|
||||
19
surfsense_web/lib/documents/document-tree-types.ts
Normal file
19
surfsense_web/lib/documents/document-tree-types.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
export interface DocumentNodeDoc {
|
||||
id: number;
|
||||
title: string;
|
||||
document_type: string;
|
||||
folderId: number | null;
|
||||
createdAt: number;
|
||||
status?: { state: string; reason?: string | null };
|
||||
}
|
||||
|
||||
export interface FolderDisplay {
|
||||
id: number;
|
||||
name: string;
|
||||
position: string;
|
||||
parentId: number | null;
|
||||
workspaceId: number;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export type FolderSelectionState = "all" | "some" | "none";
|
||||
194
surfsense_web/lib/documents/documents-view-model.ts
Normal file
194
surfsense_web/lib/documents/documents-view-model.ts
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
|
||||
import { matchText, type TextMatch } from "./document-search";
|
||||
import type { DocumentNodeDoc, FolderDisplay } from "./document-tree-types";
|
||||
|
||||
export type DocumentsEmptyReason =
|
||||
| { kind: "loading" }
|
||||
| { kind: "workspace_empty" }
|
||||
| { kind: "no_search_results"; query: string }
|
||||
| { kind: "no_filter_results" };
|
||||
|
||||
export type DocumentSearchHit =
|
||||
| {
|
||||
kind: "document";
|
||||
document: DocumentNodeDoc;
|
||||
match: TextMatch;
|
||||
path: string[];
|
||||
}
|
||||
| {
|
||||
kind: "folder";
|
||||
folder: FolderDisplay;
|
||||
match: TextMatch;
|
||||
path: string[];
|
||||
};
|
||||
|
||||
export type DocumentsViewModel =
|
||||
| { mode: "empty"; reason: DocumentsEmptyReason }
|
||||
| { mode: "browse"; folders: FolderDisplay[]; documents: DocumentNodeDoc[] }
|
||||
| { mode: "search"; hits: DocumentSearchHit[]; query: string };
|
||||
|
||||
interface BuildDocumentsViewModelInput {
|
||||
folders: FolderDisplay[];
|
||||
documents: DocumentNodeDoc[];
|
||||
pinnedDocuments?: DocumentNodeDoc[];
|
||||
query: string;
|
||||
activeTypes: DocumentTypeEnum[];
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
function effectiveDocumentTypes(activeTypes: DocumentTypeEnum[]): Set<string> {
|
||||
const types = new Set<string>(activeTypes);
|
||||
if (types.has("FILE")) types.add("LOCAL_FOLDER_FILE");
|
||||
return types;
|
||||
}
|
||||
|
||||
function documentOrder(document: DocumentNodeDoc) {
|
||||
if (document.document_type === "USER_MEMORY") return 0;
|
||||
if (document.document_type === "TEAM_MEMORY") return 1;
|
||||
return 2;
|
||||
}
|
||||
|
||||
function compareDocuments(left: DocumentNodeDoc, right: DocumentNodeDoc) {
|
||||
return (
|
||||
documentOrder(left) - documentOrder(right) ||
|
||||
right.createdAt - left.createdAt ||
|
||||
right.id - left.id
|
||||
);
|
||||
}
|
||||
|
||||
function buildFolderPaths(folders: FolderDisplay[]): Map<number, string[]> {
|
||||
const folderById = new Map(folders.map((folder) => [folder.id, folder]));
|
||||
const paths = new Map<number, string[]>();
|
||||
|
||||
function getPath(folderId: number, visiting = new Set<number>()): string[] {
|
||||
const cached = paths.get(folderId);
|
||||
if (cached) return cached;
|
||||
if (visiting.has(folderId)) return [];
|
||||
|
||||
const folder = folderById.get(folderId);
|
||||
if (!folder) return [];
|
||||
|
||||
visiting.add(folderId);
|
||||
const path =
|
||||
folder.parentId === null
|
||||
? []
|
||||
: [...getPath(folder.parentId, visiting), folderById.get(folder.parentId)?.name].filter(
|
||||
(name): name is string => Boolean(name)
|
||||
);
|
||||
visiting.delete(folderId);
|
||||
paths.set(folderId, path);
|
||||
return path;
|
||||
}
|
||||
|
||||
for (const folder of folders) getPath(folder.id);
|
||||
return paths;
|
||||
}
|
||||
|
||||
function foldersContainingDocuments(
|
||||
folders: FolderDisplay[],
|
||||
documents: DocumentNodeDoc[]
|
||||
): FolderDisplay[] {
|
||||
const folderById = new Map(folders.map((folder) => [folder.id, folder]));
|
||||
const visibleFolderIds = new Set<number>();
|
||||
|
||||
for (const document of documents) {
|
||||
let folderId = document.folderId;
|
||||
while (folderId !== null && !visibleFolderIds.has(folderId)) {
|
||||
visibleFolderIds.add(folderId);
|
||||
folderId = folderById.get(folderId)?.parentId ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
return folders.filter((folder) => visibleFolderIds.has(folder.id));
|
||||
}
|
||||
|
||||
export function buildDocumentsViewModel({
|
||||
folders,
|
||||
documents,
|
||||
pinnedDocuments = [],
|
||||
query,
|
||||
activeTypes,
|
||||
isLoading = false,
|
||||
}: BuildDocumentsViewModelInput): DocumentsViewModel {
|
||||
if (isLoading) return { mode: "empty", reason: { kind: "loading" } };
|
||||
|
||||
const trimmedQuery = query.trim();
|
||||
const types = effectiveDocumentTypes(activeTypes);
|
||||
const allDocuments = [...pinnedDocuments, ...documents];
|
||||
const filteredDocuments = (
|
||||
types.size === 0
|
||||
? allDocuments.slice()
|
||||
: allDocuments.filter((document) => types.has(document.document_type))
|
||||
).sort(compareDocuments);
|
||||
|
||||
if (allDocuments.length === 0 && folders.length === 0) {
|
||||
return { mode: "empty", reason: { kind: "workspace_empty" } };
|
||||
}
|
||||
|
||||
if (activeTypes.length > 0 && filteredDocuments.length === 0) {
|
||||
return { mode: "empty", reason: { kind: "no_filter_results" } };
|
||||
}
|
||||
|
||||
const visibleFolders =
|
||||
activeTypes.length === 0
|
||||
? folders
|
||||
: foldersContainingDocuments(folders, filteredDocuments);
|
||||
|
||||
if (!trimmedQuery) {
|
||||
return {
|
||||
mode: "browse",
|
||||
folders: visibleFolders,
|
||||
documents: filteredDocuments,
|
||||
};
|
||||
}
|
||||
|
||||
const folderPaths = buildFolderPaths(folders);
|
||||
const folderById = new Map(folders.map((folder) => [folder.id, folder]));
|
||||
const hits: DocumentSearchHit[] = [];
|
||||
|
||||
for (const document of filteredDocuments) {
|
||||
const match = matchText(trimmedQuery, document.title);
|
||||
if (!match) continue;
|
||||
hits.push({
|
||||
kind: "document",
|
||||
document,
|
||||
match,
|
||||
path:
|
||||
document.folderId === null
|
||||
? []
|
||||
: [
|
||||
...(folderPaths.get(document.folderId) ?? []),
|
||||
folderById.get(document.folderId)?.name,
|
||||
].filter((name): name is string => Boolean(name)),
|
||||
});
|
||||
}
|
||||
|
||||
for (const folder of visibleFolders) {
|
||||
const match = matchText(trimmedQuery, folder.name);
|
||||
if (!match) continue;
|
||||
hits.push({
|
||||
kind: "folder",
|
||||
folder,
|
||||
match,
|
||||
path: folderPaths.get(folder.id) ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
if (hits.length === 0) {
|
||||
return {
|
||||
mode: "empty",
|
||||
reason: { kind: "no_search_results", query: trimmedQuery },
|
||||
};
|
||||
}
|
||||
|
||||
hits.sort(
|
||||
(left, right) =>
|
||||
right.match.score - left.match.score ||
|
||||
(left.kind === right.kind ? 0 : left.kind === "document" ? -1 : 1) ||
|
||||
(left.kind === "document" ? left.document.title : left.folder.name).localeCompare(
|
||||
right.kind === "document" ? right.document.title : right.folder.name
|
||||
)
|
||||
);
|
||||
|
||||
return { mode: "search", hits, query: trimmedQuery };
|
||||
}
|
||||
|
|
@ -315,16 +315,26 @@
|
|||
"confirm_delete": "Confirm Delete",
|
||||
"confirm_delete_desc": "Are you sure you want to delete {count} document(s)? This action cannot be undone.",
|
||||
"uploading": "Uploading",
|
||||
"upload_files": "Upload files",
|
||||
"upload_success": "Document uploaded successfully",
|
||||
"upload_failed": "Failed to upload document",
|
||||
"loading": "Loading documents",
|
||||
"error_loading": "Error loading documents",
|
||||
"retry": "Retry",
|
||||
"no_documents": "No documents found",
|
||||
"empty_no_matching_documents": "No matching documents",
|
||||
"empty_upload_files": "Upload files to get started",
|
||||
"empty_try_different_search": "Try a different search term",
|
||||
"empty_adjust_filters": "Try adjusting your filters",
|
||||
"search_results_label": "Document search results",
|
||||
"search_results": "{count, plural, one {# result} other {# results}}",
|
||||
"search_root": "Documents",
|
||||
"search_folder": "Folder",
|
||||
"search_document_failed": "Document processing failed",
|
||||
"type": "Type",
|
||||
"content_summary": "Content Summary",
|
||||
"view_full": "View Summary",
|
||||
"filter_placeholder": "Filter by title...",
|
||||
"search_documents": "Search docs",
|
||||
"rows_per_page": "Rows per page",
|
||||
"refresh": "Refresh",
|
||||
"upload_documents": "Upload Documents",
|
||||
|
|
@ -658,7 +668,7 @@
|
|||
"view_all_chats": "View all chats",
|
||||
"show_all": "Show all",
|
||||
"hide": "Hide",
|
||||
"no_chats": "No chats",
|
||||
"no_chats": "Start a conversation to see it here",
|
||||
"start_new_chat_hint": "Start a new chat",
|
||||
"error_loading_chats": "Error loading chats",
|
||||
"chat_deleted": "Chat deleted successfully",
|
||||
|
|
|
|||
|
|
@ -315,16 +315,26 @@
|
|||
"confirm_delete": "Confirmar eliminación",
|
||||
"confirm_delete_desc": "¿Estás seguro de que deseas eliminar {count} documento(s)? Esta acción no se puede deshacer.",
|
||||
"uploading": "Subiendo",
|
||||
"upload_files": "Subir archivos",
|
||||
"upload_success": "Documento subido correctamente",
|
||||
"upload_failed": "Error al subir el documento",
|
||||
"loading": "Cargando documentos",
|
||||
"error_loading": "Error al cargar documentos",
|
||||
"retry": "Reintentar",
|
||||
"no_documents": "No se encontraron documentos",
|
||||
"empty_no_matching_documents": "No hay documentos coincidentes",
|
||||
"empty_upload_files": "Sube archivos para comenzar",
|
||||
"empty_try_different_search": "Prueba con otro término de búsqueda",
|
||||
"empty_adjust_filters": "Prueba a ajustar los filtros",
|
||||
"search_results_label": "Resultados de búsqueda de documentos",
|
||||
"search_results": "{count, plural, one {# resultado} other {# resultados}}",
|
||||
"search_root": "Documentos",
|
||||
"search_folder": "Carpeta",
|
||||
"search_document_failed": "Error al procesar el documento",
|
||||
"type": "Tipo",
|
||||
"content_summary": "Resumen del contenido",
|
||||
"view_full": "Ver resumen",
|
||||
"filter_placeholder": "Filtrar por título...",
|
||||
"search_documents": "Buscar documentos",
|
||||
"rows_per_page": "Filas por página",
|
||||
"refresh": "Actualizar",
|
||||
"upload_documents": "Subir documentos",
|
||||
|
|
@ -658,7 +668,7 @@
|
|||
"view_all_chats": "Ver todos los chats",
|
||||
"show_all": "Ver todo",
|
||||
"hide": "Ocultar",
|
||||
"no_chats": "Sin chats",
|
||||
"no_chats": "Inicia una conversación para verla aquí",
|
||||
"start_new_chat_hint": "Iniciar un nuevo chat",
|
||||
"error_loading_chats": "Error al cargar chats",
|
||||
"chat_deleted": "Chat eliminado correctamente",
|
||||
|
|
|
|||
|
|
@ -315,16 +315,26 @@
|
|||
"confirm_delete": "हटाने की पुष्टि करें",
|
||||
"confirm_delete_desc": "क्या आप वाकई {count} दस्तावेज़ हटाना चाहते हैं? यह क्रिया पूर्ववत नहीं की जा सकती।",
|
||||
"uploading": "अपलोड हो रहा है",
|
||||
"upload_files": "फ़ाइलें अपलोड करें",
|
||||
"upload_success": "दस्तावेज़ सफलतापूर्वक अपलोड किया गया",
|
||||
"upload_failed": "दस्तावेज़ अपलोड करने में विफल",
|
||||
"loading": "दस्तावेज़ लोड हो रहे हैं",
|
||||
"error_loading": "दस्तावेज़ लोड करने में त्रुटि",
|
||||
"retry": "पुनः प्रयास करें",
|
||||
"no_documents": "कोई दस्तावेज़ नहीं मिला",
|
||||
"empty_no_matching_documents": "कोई मेल खाता दस्तावेज़ नहीं मिला",
|
||||
"empty_upload_files": "शुरू करने के लिए फ़ाइलें अपलोड करें",
|
||||
"empty_try_different_search": "कोई दूसरा खोज शब्द आज़माएँ",
|
||||
"empty_adjust_filters": "फ़िल्टर बदलकर देखें",
|
||||
"search_results_label": "दस्तावेज़ खोज परिणाम",
|
||||
"search_results": "{count} परिणाम",
|
||||
"search_root": "दस्तावेज़",
|
||||
"search_folder": "फ़ोल्डर",
|
||||
"search_document_failed": "दस्तावेज़ प्रोसेस नहीं हो सका",
|
||||
"type": "प्रकार",
|
||||
"content_summary": "सामग्री सारांश",
|
||||
"view_full": "सारांश देखें",
|
||||
"filter_placeholder": "शीर्षक से फ़िल्टर करें...",
|
||||
"search_documents": "दस्तावेज़ खोजें",
|
||||
"rows_per_page": "प्रति पृष्ठ पंक्तियां",
|
||||
"refresh": "रीफ्रेश करें",
|
||||
"upload_documents": "दस्तावेज़ अपलोड करें",
|
||||
|
|
@ -658,7 +668,7 @@
|
|||
"view_all_chats": "सभी चैट देखें",
|
||||
"show_all": "सभी देखें",
|
||||
"hide": "छिपाएँ",
|
||||
"no_chats": "कोई चैट नहीं",
|
||||
"no_chats": "बातचीत शुरू करें, फिर वह यहाँ दिखाई देगी",
|
||||
"start_new_chat_hint": "नई चैट शुरू करें",
|
||||
"error_loading_chats": "चैट लोड करने में त्रुटि",
|
||||
"chat_deleted": "चैट सफलतापूर्वक हटाया गया",
|
||||
|
|
|
|||
|
|
@ -315,16 +315,26 @@
|
|||
"confirm_delete": "삭제 확인",
|
||||
"confirm_delete_desc": "{count}개의 문서를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
|
||||
"uploading": "업로드 중",
|
||||
"upload_files": "파일 업로드",
|
||||
"upload_success": "문서가 성공적으로 업로드되었습니다",
|
||||
"upload_failed": "문서 업로드에 실패했습니다",
|
||||
"loading": "문서 로딩 중",
|
||||
"error_loading": "문서를 불러오는 중 오류가 발생했습니다",
|
||||
"retry": "다시 시도",
|
||||
"no_documents": "문서를 찾을 수 없습니다",
|
||||
"empty_no_matching_documents": "일치하는 문서가 없습니다",
|
||||
"empty_upload_files": "시작하려면 파일을 업로드하세요",
|
||||
"empty_try_different_search": "다른 검색어를 사용해 보세요",
|
||||
"empty_adjust_filters": "필터를 조정해 보세요",
|
||||
"search_results_label": "문서 검색 결과",
|
||||
"search_results": "{count}개 결과",
|
||||
"search_root": "문서",
|
||||
"search_folder": "폴더",
|
||||
"search_document_failed": "문서 처리에 실패했습니다",
|
||||
"type": "유형",
|
||||
"content_summary": "콘텐츠 요약",
|
||||
"view_full": "요약 보기",
|
||||
"filter_placeholder": "제목으로 필터링...",
|
||||
"search_documents": "문서 검색",
|
||||
"rows_per_page": "페이지당 행 수",
|
||||
"refresh": "새로고침",
|
||||
"upload_documents": "문서 업로드",
|
||||
|
|
@ -658,7 +668,7 @@
|
|||
"view_all_chats": "모든 채팅 보기",
|
||||
"show_all": "모두 보기",
|
||||
"hide": "숨기기",
|
||||
"no_chats": "채팅이 없습니다",
|
||||
"no_chats": "대화를 시작하면 여기에 표시됩니다",
|
||||
"start_new_chat_hint": "새 채팅 시작하기",
|
||||
"error_loading_chats": "채팅을 불러오는 중 오류가 발생했습니다",
|
||||
"chat_deleted": "채팅이 성공적으로 삭제되었습니다",
|
||||
|
|
|
|||
|
|
@ -315,16 +315,26 @@
|
|||
"confirm_delete": "Confirmar exclusão",
|
||||
"confirm_delete_desc": "Tem certeza de que deseja excluir {count} documento(s)? Esta ação não pode ser desfeita.",
|
||||
"uploading": "Enviando",
|
||||
"upload_files": "Enviar arquivos",
|
||||
"upload_success": "Documento enviado com sucesso",
|
||||
"upload_failed": "Falha ao enviar documento",
|
||||
"loading": "Carregando documentos",
|
||||
"error_loading": "Erro ao carregar documentos",
|
||||
"retry": "Tentar novamente",
|
||||
"no_documents": "Nenhum documento encontrado",
|
||||
"empty_no_matching_documents": "Nenhum documento correspondente",
|
||||
"empty_upload_files": "Envie arquivos para começar",
|
||||
"empty_try_different_search": "Tente outro termo de busca",
|
||||
"empty_adjust_filters": "Tente ajustar os filtros",
|
||||
"search_results_label": "Resultados da busca de documentos",
|
||||
"search_results": "{count, plural, one {# resultado} other {# resultados}}",
|
||||
"search_root": "Documentos",
|
||||
"search_folder": "Pasta",
|
||||
"search_document_failed": "Falha ao processar o documento",
|
||||
"type": "Tipo",
|
||||
"content_summary": "Resumo do conteúdo",
|
||||
"view_full": "Ver resumo",
|
||||
"filter_placeholder": "Filtrar por título...",
|
||||
"search_documents": "Buscar documentos",
|
||||
"rows_per_page": "Linhas por página",
|
||||
"refresh": "Atualizar",
|
||||
"upload_documents": "Enviar documentos",
|
||||
|
|
@ -658,7 +668,7 @@
|
|||
"view_all_chats": "Ver todos os chats",
|
||||
"show_all": "Ver tudo",
|
||||
"hide": "Ocultar",
|
||||
"no_chats": "Nenhum chat",
|
||||
"no_chats": "Inicie uma conversa para vê-la aqui",
|
||||
"start_new_chat_hint": "Iniciar um novo chat",
|
||||
"error_loading_chats": "Erro ao carregar chats",
|
||||
"chat_deleted": "Chat excluído com sucesso",
|
||||
|
|
|
|||
|
|
@ -314,16 +314,26 @@
|
|||
"confirm_delete": "确认删除",
|
||||
"confirm_delete_desc": "您确定要删除 {count} 个文档吗?此操作无法撤销。",
|
||||
"uploading": "上传中...",
|
||||
"upload_files": "上传文件",
|
||||
"upload_success": "文档上传成功",
|
||||
"upload_failed": "上传文档失败",
|
||||
"loading": "正在加载文档...",
|
||||
"error_loading": "加载文档时出错",
|
||||
"retry": "重试",
|
||||
"no_documents": "未找到文档",
|
||||
"empty_no_matching_documents": "没有匹配的文档",
|
||||
"empty_upload_files": "上传文件以开始使用",
|
||||
"empty_try_different_search": "请尝试其他搜索词",
|
||||
"empty_adjust_filters": "请尝试调整筛选条件",
|
||||
"search_results_label": "文档搜索结果",
|
||||
"search_results": "{count} 个结果",
|
||||
"search_root": "文档",
|
||||
"search_folder": "文件夹",
|
||||
"search_document_failed": "文档处理失败",
|
||||
"type": "类型",
|
||||
"content_summary": "内容摘要",
|
||||
"view_full": "查看完整内容",
|
||||
"filter_placeholder": "按标题筛选...",
|
||||
"search_documents": "搜索文档",
|
||||
"rows_per_page": "每页行数",
|
||||
"refresh": "刷新",
|
||||
"upload_documents": "上传文档",
|
||||
|
|
@ -657,7 +667,7 @@
|
|||
"view_all_chats": "查看所有对话",
|
||||
"show_all": "查看全部",
|
||||
"hide": "隐藏",
|
||||
"no_chats": "无对话",
|
||||
"no_chats": "开始对话即可在此处查看",
|
||||
"start_new_chat_hint": "开始新对话",
|
||||
"error_loading_chats": "加载对话时出错",
|
||||
"chat_deleted": "对话删除成功",
|
||||
|
|
|
|||
|
|
@ -11,16 +11,24 @@ import { expect } from "@playwright/test";
|
|||
*/
|
||||
|
||||
export async function openConnectorPopup(page: Page): Promise<void> {
|
||||
// Label depends on whether the user already has connectors.
|
||||
const trigger = page
|
||||
.getByRole("button", { name: "Manage connectors" })
|
||||
.or(page.getByRole("button", { name: "Connect your connectors" }))
|
||||
.getByRole("button", { name: "Upload files, manage tools and more" })
|
||||
.first();
|
||||
|
||||
// Long timeout absorbs Next.js dev cold-compile of the new-chat route.
|
||||
await expect(trigger).toBeVisible({ timeout: 60_000 });
|
||||
await trigger.click();
|
||||
|
||||
// With no connectors yet this opens the dialog directly; once connectors
|
||||
// exist it expands into a submenu whose footer opens the dialog.
|
||||
const connectorsItem = page.getByRole("menuitem", { name: "MCP Connectors" }).first();
|
||||
const isSubmenu = (await connectorsItem.getAttribute("aria-haspopup")) === "menu";
|
||||
await connectorsItem.click();
|
||||
|
||||
if (isSubmenu) {
|
||||
await page.getByRole("menuitem", { name: "Manage connectors" }).click();
|
||||
}
|
||||
|
||||
await expect(page.getByRole("dialog", { name: "MCP Connectors" })).toBeVisible();
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue