Merge pull request #1659 from AnishSarkar22/fix/document-ui-changes
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:
Anish Sarkar 2026-08-05 02:57:01 +05:30 • committed by GitHub
commit 79417d9c50
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 3081 additions and 1642 deletions

View file

@ -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,

View file

@ -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)

View file

@ -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>

View file

@ -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 />;
}

View file

@ -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>

View file

@ -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.
*/

View file

@ -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[]>([]);

View file

@ -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 }
);

View file

@ -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>

View file

@ -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;

View 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>
);
}

View file

@ -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>
);

View file

@ -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>
);
}

View 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}
/>
);
}

View file

@ -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;

View file

@ -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;

View file

@ -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>

View 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)}
</>
);
}

View file

@ -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>

View file

@ -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

View file

@ -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 />;
}

View file

@ -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

View file

@ -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

View file

@ -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}

View file

@ -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>
);
}

View file

@ -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";

View 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]
);
}

View 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),
};
}

View 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";

View 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 };
}

View file

@ -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",

View file

@ -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",

View file

@ -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": "चैट सफलतापूर्वक हटाया गया",

View file

@ -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": "채팅이 성공적으로 삭제되었습니다",

View file

@ -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",

View file

@ -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": "对话删除成功",

View file

@ -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();
}