chore: linting

This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-03-27 03:17:05 -07:00
parent 23b4f91754
commit 64c913baa3
47 changed files with 908 additions and 895 deletions

View file

@ -28,16 +28,14 @@ export const InlineCitation: FC<InlineCitationProps> = ({ chunkId, isDocsChunk =
url=""
isDocsChunk={isDocsChunk}
>
<span
<button
type="button"
onClick={() => setIsOpen(true)}
onKeyDown={(e) => e.key === "Enter" && setIsOpen(true)}
className="text-[10px] font-bold bg-primary/80 hover:bg-primary text-primary-foreground rounded-full min-w-4 h-4 px-1 inline-flex items-center justify-center align-super cursor-pointer transition-colors ml-0.5"
title={`View source chunk #${chunkId}`}
role="button"
tabIndex={0}
>
{chunkId}
</span>
</button>
</SourceDetailPanel>
);
};

View file

@ -225,17 +225,13 @@ function ThreadListItemComponent({
onDelete,
}: ThreadListItemComponentProps) {
return (
<div
<button
type="button"
className={cn(
"group flex items-center gap-2 rounded-lg px-3 py-2 transition-colors cursor-pointer",
"group flex w-full items-center gap-2 rounded-lg px-3 py-2 transition-colors cursor-pointer text-left",
isActive ? "bg-accent text-accent-foreground" : "hover:bg-muted/50"
)}
onClick={onClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") onClick();
}}
role="button"
tabIndex={0}
>
<MessageSquareIcon className="size-4 shrink-0 text-muted-foreground" />
<div className="flex-1 min-w-0">
@ -274,7 +270,7 @@ function ThreadListItemComponent({
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</button>
);
}

View file

@ -8,7 +8,14 @@ import { cn } from "@/lib/utils";
// Official Google "G" logo with brand colors
const GoogleLogo = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<svg
className={className}
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-label="Google logo"
>
<title>Google logo</title>
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
fill="#4285F4"

View file

@ -185,7 +185,20 @@ export const Grid = ({ pattern, size }: { pattern?: [number, number][]; size?: n
);
};
export function GridPattern({ width, height, x, y, squares, ...props }: React.ComponentProps<"svg"> & { width: number; height: number; x: string | number; y: string | number; squares?: [number, number][] }) {
export function GridPattern({
width,
height,
x,
y,
squares,
...props
}: React.ComponentProps<"svg"> & {
width: number;
height: number;
x: string | number;
y: string | number;
squares?: [number, number][];
}) {
const patternId = useId();
return (

View file

@ -45,7 +45,7 @@ export function CreateFolderDialog({
onConfirm(trimmed);
onOpenChange(false);
},
[name, onConfirm, onOpenChange],
[name, onConfirm, onOpenChange]
);
const isSubfolder = !!parentFolderName;

View file

@ -1,14 +1,9 @@
"use client";
import {
Eye,
MoreHorizontal,
Move,
Pencil,
Trash2,
} from "lucide-react";
import React, { useCallback, useRef } from "react";
import { Eye, MoreHorizontal, Move, Pencil, Trash2 } from "lucide-react";
import React, { useCallback } from "react";
import { useDrag } from "react-dnd";
import { getDocumentTypeIcon } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
@ -25,7 +20,6 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { getDocumentTypeIcon } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon";
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
import { cn } from "@/lib/utils";
import { DND_TYPES } from "./FolderNode";
@ -62,9 +56,7 @@ export const DocumentNode = React.memo(function DocumentNode({
const statusState = doc.status?.state ?? "ready";
const isSelectable = statusState !== "pending" && statusState !== "processing";
const isEditable =
doc.document_type === "NOTE" &&
statusState !== "pending" &&
statusState !== "processing";
doc.document_type === "NOTE" && statusState !== "pending" && statusState !== "processing";
const handleCheckChange = useCallback(() => {
if (isSelectable) {
@ -78,7 +70,7 @@ export const DocumentNode = React.memo(function DocumentNode({
item: { id: doc.id },
collect: (monitor) => ({ isDragging: monitor.isDragging() }),
}),
[doc.id],
[doc.id]
);
const isProcessing = statusState === "pending" || statusState === "processing";
@ -86,15 +78,24 @@ export const DocumentNode = React.memo(function DocumentNode({
return (
<ContextMenu>
<ContextMenuTrigger asChild>
{/* biome-ignore lint/a11y/useSemanticElements: div required for drag ref */}
<div
ref={drag}
role="button"
tabIndex={0}
className={cn(
"group flex h-8 items-center gap-1.5 rounded-md px-1 text-sm hover:bg-accent/50 cursor-pointer select-none",
isMentioned && "bg-accent/30",
isDragging && "opacity-40",
isDragging && "opacity-40"
)}
style={{ paddingLeft: `${depth * 16 + 4}px` }}
onClick={handleCheckChange}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleCheckChange();
}
}}
>
{isSelectable ? (
<Checkbox
@ -110,7 +111,7 @@ export const DocumentNode = React.memo(function DocumentNode({
"h-2 w-2 rounded-full",
statusState === "processing" && "animate-pulse bg-amber-500",
statusState === "pending" && "bg-muted-foreground/40",
statusState === "failed" && "bg-destructive",
statusState === "failed" && "bg-destructive"
)}
/>
</span>
@ -119,7 +120,10 @@ export const DocumentNode = React.memo(function DocumentNode({
<span className="flex-1 min-w-0 truncate">{doc.title}</span>
<span className="shrink-0">
{getDocumentTypeIcon(doc.document_type as DocumentTypeEnum, "h-3.5 w-3.5 text-muted-foreground")}
{getDocumentTypeIcon(
doc.document_type as DocumentTypeEnum,
"h-3.5 w-3.5 text-muted-foreground"
)}
</span>
<DropdownMenu>
@ -134,10 +138,10 @@ export const DocumentNode = React.memo(function DocumentNode({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem onClick={() => onPreview(doc)}>
<Eye className="mr-2 h-4 w-4" />
Open
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onPreview(doc)}>
<Eye className="mr-2 h-4 w-4" />
Open
</DropdownMenuItem>
{isEditable && (
<DropdownMenuItem onClick={() => onEdit(doc)}>
<Pencil className="mr-2 h-4 w-4" />
@ -163,10 +167,10 @@ export const DocumentNode = React.memo(function DocumentNode({
</ContextMenuTrigger>
<ContextMenuContent className="w-44">
<ContextMenuItem onClick={() => onPreview(doc)}>
<Eye className="mr-2 h-4 w-4" />
Open
</ContextMenuItem>
<ContextMenuItem onClick={() => onPreview(doc)}>
<Eye className="mr-2 h-4 w-4" />
Open
</ContextMenuItem>
{isEditable && (
<ContextMenuItem onClick={() => onEdit(doc)}>
<Pencil className="mr-2 h-4 w-4" />

View file

@ -58,13 +58,20 @@ interface FolderNodeProps {
onDelete: (folder: FolderDisplay) => void;
onMove: (folder: FolderDisplay) => void;
onCreateSubfolder: (parentId: number) => void;
onDropIntoFolder?: (itemType: "folder" | "document", itemId: number, targetFolderId: number) => void;
onDropIntoFolder?: (
itemType: "folder" | "document",
itemId: number,
targetFolderId: number
) => void;
onReorderFolder?: (folderId: number, beforePos: string | null, afterPos: string | null) => void;
siblingPositions?: { before: string | null; after: string | null };
disabledDropIds?: Set<number>;
}
function getDropZone(monitor: { getClientOffset: () => { y: number } | null }, element: HTMLElement): DropZone {
function getDropZone(
monitor: { getClientOffset: () => { y: number } | null },
element: HTMLElement
): DropZone {
const offset = monitor.getClientOffset();
if (!offset) return "middle";
const rect = element.getBoundingClientRect();
@ -104,7 +111,7 @@ export const FolderNode = React.memo(function FolderNode({
item: { id: folder.id, position: folder.position, parentId: folder.parentId },
collect: (monitor) => ({ isDragging: monitor.isDragging() }),
}),
[folder.id, folder.position, folder.parentId],
[folder.id, folder.position, folder.parentId]
);
const [{ isOver, canDrop }, drop] = useDrop(
@ -147,7 +154,14 @@ export const FolderNode = React.memo(function FolderNode({
canDrop: monitor.canDrop(),
}),
}),
[folder.id, folder.position, disabledDropIds, onDropIntoFolder, onReorderFolder, siblingPositions],
[
folder.id,
folder.position,
disabledDropIds,
onDropIntoFolder,
onReorderFolder,
siblingPositions,
]
);
useEffect(() => {
@ -159,7 +173,7 @@ export const FolderNode = React.memo(function FolderNode({
rowRef.current = node;
drag(drop(node));
},
[drag, drop],
[drag, drop]
);
useEffect(() => {
@ -188,7 +202,7 @@ export const FolderNode = React.memo(function FolderNode({
onCancelRename();
}
},
[handleRenameSubmit, folder.name, onCancelRename],
[handleRenameSubmit, folder.name, onCancelRename]
);
const startRename = useCallback(() => {
@ -201,8 +215,11 @@ export const FolderNode = React.memo(function FolderNode({
return (
<ContextMenu>
<ContextMenuTrigger asChild disabled={isRenaming}>
{/* biome-ignore lint/a11y/useSemanticElements: div required for drag/drop refs */}
<div
ref={attachRef}
role="button"
tabIndex={0}
className={cn(
"group relative flex h-8 items-center gap-1 rounded-md px-1 text-sm hover:bg-accent/50 cursor-pointer select-none",
isExpanded && "font-medium",
@ -210,10 +227,16 @@ export const FolderNode = React.memo(function FolderNode({
isOver && canDrop && dropZone === "middle" && "bg-accent ring-1 ring-primary/40",
isOver && canDrop && dropZone === "top" && "border-t-2 border-primary",
isOver && canDrop && dropZone === "bottom" && "border-b-2 border-primary",
isOver && !canDrop && "cursor-not-allowed",
isOver && !canDrop && "cursor-not-allowed"
)}
style={{ paddingLeft: `${depth * 16 + 4}px` }}
onClick={() => onToggleExpand(folder.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onToggleExpand(folder.id);
}
}}
onDoubleClick={(e) => {
e.stopPropagation();
startRename();
@ -322,7 +345,10 @@ export const FolderNode = React.memo(function FolderNode({
Move to...
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem className="text-destructive focus:text-destructive" onClick={() => onDelete(folder)}>
<ContextMenuItem
className="text-destructive focus:text-destructive"
onClick={() => onDelete(folder)}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</ContextMenuItem>

View file

@ -47,7 +47,8 @@ export function FolderPickerDialog({
const map: Record<string, FolderDisplay[]> = {};
for (const f of folders) {
const key = f.parentId ?? "root";
(map[key] ??= []).push(f);
if (!map[key]) map[key] = [];
map[key].push(f);
}
return map;
}, [folders]);
@ -88,7 +89,7 @@ export function FolderPickerDialog({
"flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-sm transition-colors",
isSelected && "bg-accent text-accent-foreground",
!isSelected && !isDisabled && "hover:bg-accent/50",
isDisabled && "cursor-not-allowed opacity-40",
isDisabled && "cursor-not-allowed opacity-40"
)}
style={{ paddingLeft: `${depth * 16 + 8}px` }}
onClick={() => {
@ -96,7 +97,8 @@ export function FolderPickerDialog({
}}
>
{hasChildren ? (
<span
<button
type="button"
className="flex h-4 w-4 shrink-0 items-center justify-center"
onClick={(e) => {
e.stopPropagation();
@ -108,7 +110,7 @@ export function FolderPickerDialog({
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
</span>
</button>
) : (
<span className="h-4 w-4 shrink-0" />
)}
@ -134,7 +136,7 @@ export function FolderPickerDialog({
className={cn(
"flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-sm transition-colors",
selectedId === null && "bg-accent text-accent-foreground",
selectedId !== null && "hover:bg-accent/50",
selectedId !== null && "hover:bg-accent/50"
)}
onClick={() => setSelectedId(null)}
>

View file

@ -8,7 +8,7 @@ import { HTML5Backend } from "react-dnd-html5-backend";
import { renamingFolderIdAtom } from "@/atoms/documents/folder.atoms";
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
import { DocumentNode, type DocumentNodeDoc } from "./DocumentNode";
import { FolderNode, type FolderDisplay } from "./FolderNode";
import { type FolderDisplay, FolderNode } from "./FolderNode";
interface FolderTreeViewProps {
folders: FolderDisplay[];
@ -16,7 +16,10 @@ interface FolderTreeViewProps {
expandedIds: Set<number>;
onToggleExpand: (folderId: number) => void;
mentionedDocIds: Set<number>;
onToggleChatMention: (doc: { id: number; title: string; document_type: string }, isMentioned: boolean) => void;
onToggleChatMention: (
doc: { id: number; title: string; document_type: string },
isMentioned: boolean
) => void;
onRenameFolder: (folder: FolderDisplay, newName: string) => void;
onDeleteFolder: (folder: FolderDisplay) => void;
onMoveFolder: (folder: FolderDisplay) => void;
@ -26,7 +29,11 @@ interface FolderTreeViewProps {
onDeleteDocument: (doc: DocumentNodeDoc) => void;
onMoveDocument: (doc: DocumentNodeDoc) => void;
activeTypes: DocumentTypeEnum[];
onDropIntoFolder?: (itemType: "folder" | "document", itemId: number, targetFolderId: number | null) => void;
onDropIntoFolder?: (
itemType: "folder" | "document",
itemId: number,
targetFolderId: number | null
) => void;
onReorderFolder?: (folderId: number, beforePos: string | null, afterPos: string | null) => void;
}
@ -34,7 +41,8 @@ function groupBy<T>(items: T[], keyFn: (item: T) => string | number): Record<str
const result: Record<string | number, T[]> = {};
for (const item of items) {
const key = keyFn(item);
(result[key] ??= []).push(item);
if (!result[key]) result[key] = [];
result[key].push(item);
}
return result;
}
@ -58,15 +66,9 @@ export function FolderTreeView({
onDropIntoFolder,
onReorderFolder,
}: FolderTreeViewProps) {
const foldersByParent = useMemo(
() => groupBy(folders, (f) => f.parentId ?? "root"),
[folders],
);
const foldersByParent = useMemo(() => groupBy(folders, (f) => f.parentId ?? "root"), [folders]);
const docsByFolder = useMemo(
() => groupBy(documents, (d) => d.folderId ?? "root"),
[documents],
);
const docsByFolder = useMemo(() => groupBy(documents, (d) => d.folderId ?? "root"), [documents]);
const folderChildCounts = useMemo(() => {
const counts: Record<number, number> = {};
@ -82,12 +84,9 @@ export function FolderTreeView({
const [renamingFolderId, setRenamingFolderId] = useAtom(renamingFolderIdAtom);
const handleStartRename = useCallback(
(folderId: number) => setRenamingFolderId(folderId),
[setRenamingFolderId],
);
const handleCancelRename = useCallback(
() => setRenamingFolderId(null),
[setRenamingFolderId],
[setRenamingFolderId]
);
const handleCancelRename = useCallback(() => setRenamingFolderId(null), [setRenamingFolderId]);
const hasDescendantMatch = useMemo(() => {
if (activeTypes.length === 0) return null;
@ -96,7 +95,7 @@ export function FolderTreeView({
function check(folderId: number): boolean {
if (match[folderId] !== undefined) return match[folderId];
const childDocs = (docsByFolder[folderId] ?? []).some((d) =>
activeTypes.includes(d.document_type as DocumentTypeEnum),
activeTypes.includes(d.document_type as DocumentTypeEnum)
);
if (childDocs) {
match[folderId] = true;
@ -127,10 +126,9 @@ export function FolderTreeView({
const visibleFolders = hasDescendantMatch
? childFolders.filter((f) => hasDescendantMatch[f.id])
: childFolders;
const childDocs = (docsByFolder[key] ?? [])
.filter(
(d) => activeTypes.length === 0 || activeTypes.includes(d.document_type as DocumentTypeEnum),
);
const childDocs = (docsByFolder[key] ?? []).filter(
(d) => activeTypes.length === 0 || activeTypes.includes(d.document_type as DocumentTypeEnum)
);
const nodes: React.ReactNode[] = [];
@ -159,7 +157,7 @@ export function FolderTreeView({
onDropIntoFolder={onDropIntoFolder}
onReorderFolder={onReorderFolder}
siblingPositions={siblingPositions}
/>,
/>
);
if (expandedIds.has(f.id)) {
@ -179,7 +177,7 @@ export function FolderTreeView({
onEdit={onEditDocument}
onDelete={onDeleteDocument}
onMove={onMoveDocument}
/>,
/>
);
}
@ -208,9 +206,7 @@ export function FolderTreeView({
return (
<DndProvider backend={HTML5Backend}>
<div className="flex-1 min-h-0 overflow-y-auto px-2 py-1">
{treeNodes}
</div>
<div className="flex-1 min-h-0 overflow-y-auto px-2 py-1">{treeNodes}</div>
</DndProvider>
);
}

View file

@ -3,8 +3,8 @@
import { IconBrandGithub } from "@tabler/icons-react";
import { motion, useMotionValue, useSpring } from "motion/react";
import * as React from "react";
import { cn } from "@/lib/utils";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
// ---------------------------------------------------------------------------
// Per-digit scrolling wheel

View file

@ -35,7 +35,14 @@ const HeroCarousel = dynamic(
// Official Google "G" logo with brand colors
const GoogleLogo = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<svg
className={className}
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-label="Google logo"
>
<title>Google logo</title>
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
fill="#4285F4"

View file

@ -63,9 +63,18 @@ function UseCaseCard({
transition={{ duration: 0.5, ease: "easeOut" }}
className={`group overflow-hidden rounded-2xl border border-neutral-200/60 bg-white shadow-sm transition-shadow duration-300 hover:shadow-xl dark:border-neutral-700/60 dark:bg-neutral-900 ${className ?? ""}`}
>
{/* biome-ignore lint/a11y/useSemanticElements: div wraps img, button would break layout */}
<div
role="button"
tabIndex={0}
className="cursor-pointer overflow-hidden bg-neutral-50 p-2 dark:bg-neutral-950"
onClick={open}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
open();
}
}}
>
<img
src={src}

View file

@ -14,13 +14,13 @@ import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom";
import { rightPanelCollapsedAtom } from "@/atoms/layout/right-panel.atom";
import { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { resetTabsAtom, syncChatTabAtom, type Tab } from "@/atoms/tabs/tabs.atom";
import {
morePagesDialogAtom,
searchSpaceSettingsDialogAtom,
teamDialogAtom,
userSettingsDialogAtom,
} from "@/atoms/settings/settings-dialog.atoms";
import { resetTabsAtom, syncChatTabAtom, type Tab } from "@/atoms/tabs/tabs.atom";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { MorePagesDialog } from "@/components/settings/more-pages-dialog";
import { SearchSpaceSettingsDialog } from "@/components/settings/search-space-settings-dialog";

View file

@ -497,14 +497,10 @@ export function LayoutShell({
/>
)}
{/* Main content panel */}
<MainContentPanel
isChatPage={isChatPage}
onTabSwitch={onTabSwitch}
onNewChat={onNewChat}
>
{children}
</MainContentPanel>
{/* Main content panel */}
<MainContentPanel isChatPage={isChatPage} onTabSwitch={onTabSwitch} onNewChat={onNewChat}>
{children}
</MainContentPanel>
{/* Right panel — tabbed Sources/Report (desktop only) */}
{documentsPanel && (

View file

@ -1,7 +1,7 @@
"use client";
import { useQuery } from "@rocicorp/zero/react";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { openDocumentTabAtom } from "@/atoms/tabs/tabs.atom";
import { ChevronLeft, ChevronRight, Unplug } from "lucide-react";
import { useParams } from "next/navigation";
import { useTranslations } from "next-intl";
@ -17,22 +17,22 @@ import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dial
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
import { expandedFolderIdsAtom } from "@/atoms/documents/folder.atoms";
import { openDocumentTabAtom } from "@/atoms/tabs/tabs.atom";
import { CreateFolderDialog } from "@/components/documents/CreateFolderDialog";
import type { DocumentNodeDoc } from "@/components/documents/DocumentNode";
import { FolderPickerDialog } from "@/components/documents/FolderPickerDialog";
import type { FolderDisplay } from "@/components/documents/FolderNode";
import { FolderPickerDialog } from "@/components/documents/FolderPickerDialog";
import { FolderTreeView } from "@/components/documents/FolderTreeView";
import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
import { foldersApiService } from "@/lib/apis/folders-api.service";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useDocumentSearch } from "@/hooks/use-document-search";
import { useDocuments } from "@/hooks/use-documents";
import { useMediaQuery } from "@/hooks/use-media-query";
import { useQuery } from "@rocicorp/zero/react";
import { foldersApiService } from "@/lib/apis/folders-api.service";
import { queries } from "@/zero/queries/index";
import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";
@ -91,7 +91,7 @@ export function DocumentsSidebar({
const [expandedFolderMap, setExpandedFolderMap] = useAtom(expandedFolderIdsAtom);
const expandedIds = useMemo(
() => new Set(expandedFolderMap[searchSpaceId] ?? []),
[expandedFolderMap, searchSpaceId],
[expandedFolderMap, searchSpaceId]
);
const toggleFolderExpand = useCallback(
(folderId: number) => {
@ -102,7 +102,7 @@ export function DocumentsSidebar({
return { ...prev, [searchSpaceId]: [...current] };
});
},
[searchSpaceId, setExpandedFolderMap],
[searchSpaceId, setExpandedFolderMap]
);
// Zero queries for tree data
@ -118,7 +118,7 @@ export function DocumentsSidebar({
parentId: f.parentId ?? null,
searchSpaceId: f.searchSpaceId,
})),
[zeroFolders],
[zeroFolders]
);
const treeDocuments: DocumentNodeDoc[] = useMemo(
@ -132,14 +132,15 @@ export function DocumentsSidebar({
folderId: (d as { folderId?: number | null }).folderId ?? null,
status: d.status as { state: string; reason?: string | null } | undefined,
})),
[zeroAllDocs],
[zeroAllDocs]
);
const foldersByParent = useMemo(() => {
const map: Record<string, FolderDisplay[]> = {};
for (const f of treeFolders) {
const key = String(f.parentId ?? "root");
(map[key] ??= []).push(f);
if (!map[key]) map[key] = [];
map[key].push(f);
}
return map;
}, [treeFolders]);
@ -161,13 +162,10 @@ export function DocumentsSidebar({
return treeFolders.find((f) => f.id === createFolderParentId)?.name ?? null;
}, [createFolderParentId, treeFolders]);
const handleCreateFolder = useCallback(
(parentId: number | null) => {
setCreateFolderParentId(parentId);
setCreateFolderOpen(true);
},
[],
);
const handleCreateFolder = useCallback((parentId: number | null) => {
setCreateFolderParentId(parentId);
setCreateFolderOpen(true);
}, []);
const handleCreateFolderConfirm = useCallback(
async (name: string) => {
@ -185,37 +183,31 @@ export function DocumentsSidebar({
return { ...prev, [searchSpaceId]: [...current] };
});
}
} catch (e: any) {
toast.error(e?.message || "Failed to create folder");
} catch (e: unknown) {
toast.error((e as Error)?.message || "Failed to create folder");
}
},
[createFolderParentId, searchSpaceId, setExpandedFolderMap],
[createFolderParentId, searchSpaceId, setExpandedFolderMap]
);
const handleRenameFolder = useCallback(
async (folder: FolderDisplay, newName: string) => {
try {
await foldersApiService.updateFolder(folder.id, { name: newName });
toast.success("Folder renamed");
} catch (e: any) {
toast.error(e?.message || "Failed to rename folder");
}
},
[],
);
const handleRenameFolder = useCallback(async (folder: FolderDisplay, newName: string) => {
try {
await foldersApiService.updateFolder(folder.id, { name: newName });
toast.success("Folder renamed");
} catch (e: unknown) {
toast.error((e as Error)?.message || "Failed to rename folder");
}
}, []);
const handleDeleteFolder = useCallback(
async (folder: FolderDisplay) => {
if (!confirm(`Delete folder "${folder.name}" and all its contents?`)) return;
try {
await foldersApiService.deleteFolder(folder.id);
toast.success("Folder deleted");
} catch (e: any) {
toast.error(e?.message || "Failed to delete folder");
}
},
[],
);
const handleDeleteFolder = useCallback(async (folder: FolderDisplay) => {
if (!confirm(`Delete folder "${folder.name}" and all its contents?`)) return;
try {
await foldersApiService.deleteFolder(folder.id);
toast.success("Folder deleted");
} catch (e: unknown) {
toast.error((e as Error)?.message || "Failed to delete folder");
}
}, []);
const handleMoveFolder = useCallback(
(folder: FolderDisplay) => {
@ -234,7 +226,7 @@ export function DocumentsSidebar({
});
setFolderPickerOpen(true);
},
[foldersByParent],
[foldersByParent]
);
const handleMoveDocument = useCallback((doc: DocumentNodeDoc) => {
@ -257,12 +249,12 @@ export function DocumentsSidebar({
});
toast.success("Document moved");
}
} catch (e: any) {
toast.error(e?.message || "Failed to move item");
} catch (e: unknown) {
toast.error((e as Error)?.message || "Failed to move item");
}
setFolderPickerTarget(null);
},
[folderPickerTarget],
[folderPickerTarget]
);
const handleDropIntoFolder = useCallback(
@ -279,11 +271,11 @@ export function DocumentsSidebar({
});
toast.success("Document moved");
}
} catch (e: any) {
toast.error(e?.message || "Failed to move item");
} catch (e: unknown) {
toast.error((e as Error)?.message || "Failed to move item");
}
},
[],
[]
);
const handleReorderFolder = useCallback(
@ -293,11 +285,11 @@ export function DocumentsSidebar({
before_position: beforePos,
after_position: afterPos,
});
} catch (e: any) {
toast.error(e?.message || "Failed to reorder folder");
} catch (e: unknown) {
toast.error((e as Error)?.message || "Failed to reorder folder");
}
},
[],
[]
);
const handleToggleChatMention = useCallback(
@ -598,20 +590,20 @@ export function DocumentsSidebar({
onDeleteFolder={handleDeleteFolder}
onMoveFolder={handleMoveFolder}
onCreateFolder={handleCreateFolder}
onPreviewDocument={(doc) => {
openDocumentTab({
documentId: doc.id,
searchSpaceId,
title: doc.title,
});
}}
onEditDocument={(doc) => {
openDocumentTab({
documentId: doc.id,
searchSpaceId,
title: doc.title,
});
}}
onPreviewDocument={(doc) => {
openDocumentTab({
documentId: doc.id,
searchSpaceId,
title: doc.title,
});
}}
onEditDocument={(doc) => {
openDocumentTab({
documentId: doc.id,
searchSpaceId,
title: doc.title,
});
}}
onDeleteDocument={(doc) => handleDeleteDocument(doc.id)}
onMoveDocument={handleMoveDocument}
activeTypes={activeTypes}
@ -625,11 +617,7 @@ export function DocumentsSidebar({
open={folderPickerOpen}
onOpenChange={setFolderPickerOpen}
folders={treeFolders}
title={
folderPickerTarget?.type === "folder"
? "Move folder to..."
: "Move document to..."
}
title={folderPickerTarget?.type === "folder" ? "Move folder to..." : "Move document to..."}
description="Select a destination folder, or choose Root to move to the top level."
disabledFolderIds={folderPickerTarget?.disabledIds}
onSelect={handleFolderPickerSelect}

View file

@ -176,9 +176,7 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen
<div className="flex flex-col h-full overflow-hidden">
<div className="flex items-center justify-between px-6 py-3 border-b shrink-0">
<div className="flex-1 min-w-0">
<h1 className="text-base font-semibold truncate">
{doc.title || title || "Untitled"}
</h1>
<h1 className="text-base font-semibold truncate">{doc.title || title || "Untitled"}</h1>
{editedMarkdown !== null && (
<p className="text-xs text-muted-foreground">Unsaved changes</p>
)}
@ -221,7 +219,12 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen
{doc.title || title || "Untitled"}
</h1>
{doc.document_type === "NOTE" && (
<Button variant="outline" size="sm" onClick={() => setIsEditing(true)} className="gap-1.5">
<Button
variant="outline"
size="sm"
onClick={() => setIsEditing(true)}
className="gap-1.5"
>
<Pencil className="size-3.5" />
Edit
</Button>

View file

@ -2,13 +2,13 @@
import { useAtomValue, useSetAtom } from "jotai";
import { FileText, MessageSquare, Plus, X } from "lucide-react";
import { useCallback, useRef, useEffect } from "react";
import { useCallback, useEffect, useRef } from "react";
import {
activeTabIdAtom,
closeTabAtom,
switchTabAtom,
tabsAtom,
type Tab,
tabsAtom,
} from "@/atoms/tabs/tabs.atom";
import { cn } from "@/lib/utils";
@ -58,16 +58,8 @@ export function TabBar({ onTabSwitch, onNewChat, className }: TabBarProps) {
if (tabs.length <= 1) return null;
return (
<div
className={cn(
"flex items-center shrink-0 border-b bg-main-panel",
className
)}
>
<div
ref={scrollRef}
className="flex items-center flex-1 overflow-x-auto scrollbar-none"
>
<div className={cn("flex items-center shrink-0 border-b bg-main-panel", className)}>
<div ref={scrollRef} className="flex items-center flex-1 overflow-x-auto scrollbar-none">
{tabs.map((tab) => {
const isActive = tab.id === activeTabId;
const Icon = tab.type === "document" ? FileText : MessageSquare;
@ -85,11 +77,10 @@ export function TabBar({ onTabSwitch, onNewChat, className }: TabBarProps) {
: "bg-muted/30 text-muted-foreground hover:bg-muted/60 hover:text-foreground"
)}
>
{isActive && (
<span className="absolute bottom-0 left-0 right-0 h-[2px] bg-primary" />
)}
{isActive && <span className="absolute bottom-0 left-0 right-0 h-[2px] bg-primary" />}
<Icon className="size-3.5 shrink-0" />
<span className="truncate">{tab.title}</span>
{/* biome-ignore lint/a11y/useSemanticElements: cannot nest button inside button */}
<span
role="button"
tabIndex={0}

View file

@ -9,13 +9,7 @@ import { toast } from "sonner";
import { updateSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
@ -24,175 +18,163 @@ import { cacheKeys } from "@/lib/query-client/cache-keys";
import { Spinner } from "../ui/spinner";
interface GeneralSettingsManagerProps {
searchSpaceId: number;
searchSpaceId: number;
}
export function GeneralSettingsManager({
searchSpaceId,
}: GeneralSettingsManagerProps) {
const t = useTranslations("searchSpaceSettings");
const tCommon = useTranslations("common");
const {
data: searchSpace,
isLoading: loading,
refetch: fetchSearchSpace,
} = useQuery({
queryKey: cacheKeys.searchSpaces.detail(searchSpaceId.toString()),
queryFn: () => searchSpacesApiService.getSearchSpace({ id: searchSpaceId }),
enabled: !!searchSpaceId,
});
export function GeneralSettingsManager({ searchSpaceId }: GeneralSettingsManagerProps) {
const t = useTranslations("searchSpaceSettings");
const tCommon = useTranslations("common");
const {
data: searchSpace,
isLoading: loading,
refetch: fetchSearchSpace,
} = useQuery({
queryKey: cacheKeys.searchSpaces.detail(searchSpaceId.toString()),
queryFn: () => searchSpacesApiService.getSearchSpace({ id: searchSpaceId }),
enabled: !!searchSpaceId,
});
const { mutateAsync: updateSearchSpace } = useAtomValue(
updateSearchSpaceMutationAtom,
);
const { mutateAsync: updateSearchSpace } = useAtomValue(updateSearchSpaceMutationAtom);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [saving, setSaving] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [saving, setSaving] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
// Initialize state from fetched search space
useEffect(() => {
if (searchSpace) {
setName(searchSpace.name || "");
setDescription(searchSpace.description || "");
setHasChanges(false);
}
}, [searchSpace]);
// Initialize state from fetched search space
useEffect(() => {
if (searchSpace) {
setName(searchSpace.name || "");
setDescription(searchSpace.description || "");
setHasChanges(false);
}
}, [searchSpace]);
// Track changes
useEffect(() => {
if (searchSpace) {
const currentName = searchSpace.name || "";
const currentDescription = searchSpace.description || "";
const changed =
currentName !== name || currentDescription !== description;
setHasChanges(changed);
}
}, [searchSpace, name, description]);
// Track changes
useEffect(() => {
if (searchSpace) {
const currentName = searchSpace.name || "";
const currentDescription = searchSpace.description || "";
const changed = currentName !== name || currentDescription !== description;
setHasChanges(changed);
}
}, [searchSpace, name, description]);
const handleSave = async () => {
try {
setSaving(true);
const handleSave = async () => {
try {
setSaving(true);
await updateSearchSpace({
id: searchSpaceId,
data: {
name: name.trim(),
description: description.trim() || undefined,
},
});
await updateSearchSpace({
id: searchSpaceId,
data: {
name: name.trim(),
description: description.trim() || undefined,
},
});
setHasChanges(false);
await fetchSearchSpace();
} catch (error: any) {
console.error("Error saving search space details:", error);
toast.error(error.message || "Failed to save search space details");
} finally {
setSaving(false);
}
};
setHasChanges(false);
await fetchSearchSpace();
} catch (error: any) {
console.error("Error saving search space details:", error);
toast.error(error.message || "Failed to save search space details");
} finally {
setSaving(false);
}
};
const onSubmit = (e: React.FormEvent) => {
e.preventDefault();
handleSave();
};
const onSubmit = (e: React.FormEvent) => {
e.preventDefault();
handleSave();
};
if (loading) {
return (
<div className="space-y-4 md:space-y-6">
<Card>
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">
<Skeleton className="h-5 md:h-6 w-36 md:w-48" />
<Skeleton className="h-3 md:h-4 w-full max-w-md mt-2" />
</CardHeader>
<CardContent className="space-y-3 md:space-y-4 px-3 md:px-6 pb-3 md:pb-6">
<Skeleton className="h-10 md:h-12 w-full" />
<Skeleton className="h-10 md:h-12 w-full" />
</CardContent>
</Card>
</div>
);
}
if (loading) {
return (
<div className="space-y-4 md:space-y-6">
<Card>
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">
<Skeleton className="h-5 md:h-6 w-36 md:w-48" />
<Skeleton className="h-3 md:h-4 w-full max-w-md mt-2" />
</CardHeader>
<CardContent className="space-y-3 md:space-y-4 px-3 md:px-6 pb-3 md:pb-6">
<Skeleton className="h-10 md:h-12 w-full" />
<Skeleton className="h-10 md:h-12 w-full" />
</CardContent>
</Card>
</div>
);
}
return (
<div className="space-y-4 md:space-y-6">
<Alert className="bg-muted/50 py-3 md:py-4">
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
<AlertDescription className="text-xs md:text-sm">
Update your search space name and description. These details help
identify and organize your workspace.
</AlertDescription>
</Alert>
return (
<div className="space-y-4 md:space-y-6">
<Alert className="bg-muted/50 py-3 md:py-4">
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
<AlertDescription className="text-xs md:text-sm">
Update your search space name and description. These details help identify and organize
your workspace.
</AlertDescription>
</Alert>
{/* Search Space Details Card */}
<form onSubmit={onSubmit} className="space-y-4 md:space-y-6">
<Card>
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">
<CardTitle className="text-base md:text-lg">
Search Space Details
</CardTitle>
<CardDescription className="text-xs md:text-sm">
Manage the basic information for this search space.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 md:space-y-5 px-3 md:px-6 pb-3 md:pb-6">
<div className="space-y-1.5 md:space-y-2">
<Label
htmlFor="search-space-name"
className="text-sm md:text-base font-medium"
>
{t("general_name_label")}
</Label>
<Input
id="search-space-name"
placeholder={t("general_name_placeholder")}
value={name}
onChange={(e) => setName(e.target.value)}
className="text-sm md:text-base h-9 md:h-10"
/>
<p className="text-[10px] md:text-xs text-muted-foreground">
{t("general_name_description")}
</p>
</div>
{/* Search Space Details Card */}
<form onSubmit={onSubmit} className="space-y-4 md:space-y-6">
<Card>
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">
<CardTitle className="text-base md:text-lg">Search Space Details</CardTitle>
<CardDescription className="text-xs md:text-sm">
Manage the basic information for this search space.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 md:space-y-5 px-3 md:px-6 pb-3 md:pb-6">
<div className="space-y-1.5 md:space-y-2">
<Label htmlFor="search-space-name" className="text-sm md:text-base font-medium">
{t("general_name_label")}
</Label>
<Input
id="search-space-name"
placeholder={t("general_name_placeholder")}
value={name}
onChange={(e) => setName(e.target.value)}
className="text-sm md:text-base h-9 md:h-10"
/>
<p className="text-[10px] md:text-xs text-muted-foreground">
{t("general_name_description")}
</p>
</div>
<div className="space-y-1.5 md:space-y-2">
<Label
htmlFor="search-space-description"
className="text-sm md:text-base font-medium"
>
{t("general_description_label")}{" "}
<span className="text-muted-foreground font-normal">
({tCommon("optional")})
</span>
</Label>
<Input
id="search-space-description"
placeholder={t("general_description_placeholder")}
value={description}
onChange={(e) => setDescription(e.target.value)}
className="text-sm md:text-base h-9 md:h-10"
/>
<p className="text-[10px] md:text-xs text-muted-foreground">
{t("general_description_description")}
</p>
</div>
</CardContent>
</Card>
<div className="space-y-1.5 md:space-y-2">
<Label
htmlFor="search-space-description"
className="text-sm md:text-base font-medium"
>
{t("general_description_label")}{" "}
<span className="text-muted-foreground font-normal">({tCommon("optional")})</span>
</Label>
<Input
id="search-space-description"
placeholder={t("general_description_placeholder")}
value={description}
onChange={(e) => setDescription(e.target.value)}
className="text-sm md:text-base h-9 md:h-10"
/>
<p className="text-[10px] md:text-xs text-muted-foreground">
{t("general_description_description")}
</p>
</div>
</CardContent>
</Card>
{/* Action Buttons */}
<div className="flex justify-end pt-3 md:pt-4">
<Button
type="submit"
variant="outline"
disabled={!hasChanges || saving || !name.trim()}
className="gap-2 bg-white text-black hover:bg-neutral-100 dark:bg-white dark:text-black dark:hover:bg-neutral-200"
>
{saving ? <Spinner size="sm" /> : null}
{saving ? t("general_saving") : t("general_save")}
</Button>
</div>
</form>
</div>
);
{/* Action Buttons */}
<div className="flex justify-end pt-3 md:pt-4">
<Button
type="submit"
variant="outline"
disabled={!hasChanges || saving || !name.trim()}
className="gap-2 bg-white text-black hover:bg-neutral-100 dark:bg-white dark:text-black dark:hover:bg-neutral-200"
>
{saving ? <Spinner size="sm" /> : null}
{saving ? t("general_saving") : t("general_save")}
</Button>
</div>
</form>
</div>
);
}

View file

@ -6,13 +6,7 @@ import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import { Textarea } from "@/components/ui/textarea";
@ -22,197 +16,187 @@ import { cacheKeys } from "@/lib/query-client/cache-keys";
import { Spinner } from "../ui/spinner";
interface PromptConfigManagerProps {
searchSpaceId: number;
searchSpaceId: number;
}
export function PromptConfigManager({
searchSpaceId,
}: PromptConfigManagerProps) {
const {
data: searchSpace,
isLoading: loading,
refetch: fetchSearchSpace,
} = useQuery({
queryKey: cacheKeys.searchSpaces.detail(searchSpaceId.toString()),
queryFn: () => searchSpacesApiService.getSearchSpace({ id: searchSpaceId }),
enabled: !!searchSpaceId,
});
export function PromptConfigManager({ searchSpaceId }: PromptConfigManagerProps) {
const {
data: searchSpace,
isLoading: loading,
refetch: fetchSearchSpace,
} = useQuery({
queryKey: cacheKeys.searchSpaces.detail(searchSpaceId.toString()),
queryFn: () => searchSpacesApiService.getSearchSpace({ id: searchSpaceId }),
enabled: !!searchSpaceId,
});
const [customInstructions, setCustomInstructions] = useState("");
const [saving, setSaving] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
const [customInstructions, setCustomInstructions] = useState("");
const [saving, setSaving] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
// Initialize state from fetched search space
useEffect(() => {
if (searchSpace) {
setCustomInstructions(searchSpace.qna_custom_instructions || "");
setHasChanges(false);
}
}, [searchSpace]);
// Initialize state from fetched search space
useEffect(() => {
if (searchSpace) {
setCustomInstructions(searchSpace.qna_custom_instructions || "");
setHasChanges(false);
}
}, [searchSpace]);
// Track changes
useEffect(() => {
if (searchSpace) {
const currentCustom = searchSpace.qna_custom_instructions || "";
const changed = currentCustom !== customInstructions;
setHasChanges(changed);
}
}, [searchSpace, customInstructions]);
// Track changes
useEffect(() => {
if (searchSpace) {
const currentCustom = searchSpace.qna_custom_instructions || "";
const changed = currentCustom !== customInstructions;
setHasChanges(changed);
}
}, [searchSpace, customInstructions]);
const handleSave = async () => {
try {
setSaving(true);
const handleSave = async () => {
try {
setSaving(true);
const payload = {
qna_custom_instructions: customInstructions.trim() || "",
};
const payload = {
qna_custom_instructions: customInstructions.trim() || "",
};
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
},
);
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}
);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(
errorData.detail || "Failed to save system instructions",
);
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || "Failed to save system instructions");
}
toast.success("System instructions saved successfully");
setHasChanges(false);
await fetchSearchSpace();
} catch (error: any) {
console.error("Error saving system instructions:", error);
toast.error(error.message || "Failed to save system instructions");
} finally {
setSaving(false);
}
};
toast.success("System instructions saved successfully");
setHasChanges(false);
await fetchSearchSpace();
} catch (error: any) {
console.error("Error saving system instructions:", error);
toast.error(error.message || "Failed to save system instructions");
} finally {
setSaving(false);
}
};
const onSubmit = (e: React.FormEvent) => {
e.preventDefault();
handleSave();
};
const onSubmit = (e: React.FormEvent) => {
e.preventDefault();
handleSave();
};
if (loading) {
return (
<div className="space-y-4 md:space-y-6">
<Card>
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">
<Skeleton className="h-5 md:h-6 w-36 md:w-48" />
<Skeleton className="h-3 md:h-4 w-full max-w-md mt-2" />
</CardHeader>
<CardContent className="space-y-3 md:space-y-4 px-3 md:px-6 pb-3 md:pb-6">
<Skeleton className="h-16 md:h-20 w-full" />
<Skeleton className="h-24 md:h-32 w-full" />
</CardContent>
</Card>
</div>
);
}
if (loading) {
return (
<div className="space-y-4 md:space-y-6">
<Card>
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">
<Skeleton className="h-5 md:h-6 w-36 md:w-48" />
<Skeleton className="h-3 md:h-4 w-full max-w-md mt-2" />
</CardHeader>
<CardContent className="space-y-3 md:space-y-4 px-3 md:px-6 pb-3 md:pb-6">
<Skeleton className="h-16 md:h-20 w-full" />
<Skeleton className="h-24 md:h-32 w-full" />
</CardContent>
</Card>
</div>
);
}
return (
<div className="space-y-4 md:space-y-6">
{/* Work in Progress Notice */}
<Alert
variant="default"
className="bg-amber-50 dark:bg-amber-950/30 border-amber-300 dark:border-amber-700 py-3 md:py-4"
>
<AlertTriangle className="h-3 w-3 md:h-4 md:w-4 text-amber-600 dark:text-amber-500 shrink-0" />
<AlertDescription className="text-amber-800 dark:text-amber-300 text-xs md:text-sm">
<span className="font-semibold">Work in Progress:</span> This
functionality is currently under development and not yet connected to
the backend. Your instructions will be saved but won't affect AI
behavior until the feature is fully implemented.
</AlertDescription>
</Alert>
return (
<div className="space-y-4 md:space-y-6">
{/* Work in Progress Notice */}
<Alert
variant="default"
className="bg-amber-50 dark:bg-amber-950/30 border-amber-300 dark:border-amber-700 py-3 md:py-4"
>
<AlertTriangle className="h-3 w-3 md:h-4 md:w-4 text-amber-600 dark:text-amber-500 shrink-0" />
<AlertDescription className="text-amber-800 dark:text-amber-300 text-xs md:text-sm">
<span className="font-semibold">Work in Progress:</span> This functionality is currently
under development and not yet connected to the backend. Your instructions will be saved
but won't affect AI behavior until the feature is fully implemented.
</AlertDescription>
</Alert>
<Alert className="bg-muted/50 py-3 md:py-4">
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
<AlertDescription className="text-xs md:text-sm">
System instructions apply to all AI interactions in this search space.
They guide how the AI responds, its tone, focus areas, and behavior
patterns.
</AlertDescription>
</Alert>
<Alert className="bg-muted/50 py-3 md:py-4">
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
<AlertDescription className="text-xs md:text-sm">
System instructions apply to all AI interactions in this search space. They guide how the
AI responds, its tone, focus areas, and behavior patterns.
</AlertDescription>
</Alert>
{/* System Instructions Card */}
<form onSubmit={onSubmit} className="space-y-4 md:space-y-6">
<Card>
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">
<CardTitle className="text-base md:text-lg">
Custom System Instructions
</CardTitle>
<CardDescription className="text-xs md:text-sm">
Provide specific guidelines for how you want the AI to respond.
These instructions will be applied to all answers in this search
space.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3 md:space-y-4 px-3 md:px-6 pb-3 md:pb-6">
<div className="space-y-1.5 md:space-y-2">
<Label
htmlFor="custom-instructions-settings"
className="text-sm md:text-base font-medium"
>
Your Instructions
</Label>
<Textarea
id="custom-instructions-settings"
placeholder="E.g., Always provide practical examples, be concise, focus on technical details, use simple language, respond in a specific format..."
value={customInstructions}
onChange={(e) => setCustomInstructions(e.target.value)}
rows={10}
className="resize-none font-mono text-xs md:text-sm"
/>
<div className="flex items-center justify-between">
<p className="text-[10px] md:text-xs text-muted-foreground">
{customInstructions.length} characters
</p>
{customInstructions.length > 0 && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setCustomInstructions("")}
className="h-auto py-0.5 md:py-1 px-1.5 md:px-2 text-[10px] md:text-xs"
>
Clear
</Button>
)}
</div>
</div>
{/* System Instructions Card */}
<form onSubmit={onSubmit} className="space-y-4 md:space-y-6">
<Card>
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">
<CardTitle className="text-base md:text-lg">Custom System Instructions</CardTitle>
<CardDescription className="text-xs md:text-sm">
Provide specific guidelines for how you want the AI to respond. These instructions
will be applied to all answers in this search space.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3 md:space-y-4 px-3 md:px-6 pb-3 md:pb-6">
<div className="space-y-1.5 md:space-y-2">
<Label
htmlFor="custom-instructions-settings"
className="text-sm md:text-base font-medium"
>
Your Instructions
</Label>
<Textarea
id="custom-instructions-settings"
placeholder="E.g., Always provide practical examples, be concise, focus on technical details, use simple language, respond in a specific format..."
value={customInstructions}
onChange={(e) => setCustomInstructions(e.target.value)}
rows={10}
className="resize-none font-mono text-xs md:text-sm"
/>
<div className="flex items-center justify-between">
<p className="text-[10px] md:text-xs text-muted-foreground">
{customInstructions.length} characters
</p>
{customInstructions.length > 0 && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setCustomInstructions("")}
className="h-auto py-0.5 md:py-1 px-1.5 md:px-2 text-[10px] md:text-xs"
>
Clear
</Button>
)}
</div>
</div>
{customInstructions.trim().length === 0 && (
<Alert className="py-2 md:py-3">
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
<AlertDescription className="text-xs md:text-sm">
No system instructions are currently set. The AI will use
default behavior.
</AlertDescription>
</Alert>
)}
</CardContent>
</Card>
{customInstructions.trim().length === 0 && (
<Alert className="py-2 md:py-3">
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
<AlertDescription className="text-xs md:text-sm">
No system instructions are currently set. The AI will use default behavior.
</AlertDescription>
</Alert>
)}
</CardContent>
</Card>
{/* Action Buttons */}
<div className="flex justify-end pt-3 md:pt-4">
<Button
type="submit"
variant="outline"
disabled={!hasChanges || saving}
className="gap-2 bg-white text-black hover:bg-neutral-100 dark:bg-white dark:text-black dark:hover:bg-neutral-200"
>
{saving ? <Spinner size="sm" /> : null}
{saving ? "Saving" : "Save Instructions"}
</Button>
</div>
</form>
</div>
);
{/* Action Buttons */}
<div className="flex justify-end pt-3 md:pt-4">
<Button
type="submit"
variant="outline"
disabled={!hasChanges || saving}
className="gap-2 bg-white text-black hover:bg-neutral-100 dark:bg-white dark:text-black dark:hover:bg-neutral-200"
>
{saving ? <Spinner size="sm" /> : null}
{saving ? "Saving" : "Save Instructions"}
</Button>
</div>
</form>
</div>
);
}

View file

@ -215,7 +215,7 @@ export const createAnimation = (
`,
};
}
if (variant === "circle" && start == "center") {
if (variant === "circle" && start === "center") {
return {
name: `${variant}-${start}${blur ? "-blur" : ""}`,
css: `

View file

@ -67,13 +67,14 @@ const SANDBOX_FILE_RE = /^SANDBOX_FILE:\s*(.+)$/gm;
function extractSandboxFiles(text: string): SandboxFile[] {
const files: SandboxFile[] = [];
let match: RegExpExecArray | null;
while ((match = SANDBOX_FILE_RE.exec(text)) !== null) {
let match: RegExpExecArray | null = SANDBOX_FILE_RE.exec(text);
while (match !== null) {
const filePath = match[1].trim();
if (filePath) {
const name = filePath.includes("/") ? filePath.split("/").pop() || filePath : filePath;
files.push({ path: filePath, name });
}
match = SANDBOX_FILE_RE.exec(text);
}
SANDBOX_FILE_RE.lastIndex = 0;
return files;
@ -148,7 +149,7 @@ function parseExecuteResult(result: ExecuteResult): ParsedOutput {
function truncateCommand(command: string, maxLen = 80): string {
if (command.length <= maxLen) return command;
return command.slice(0, maxLen) + "…";
return `${command.slice(0, maxLen)}`;
}
// ============================================================================

View file

@ -18,7 +18,7 @@ import {
import * as React from "react";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Tooltip, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
const UNDRAGGABLE_KEYS = [KEYS.column, KEYS.tr, KEYS.td];
@ -94,23 +94,24 @@ function Draggable(props: PlateElementProps) {
};
// clear up virtual multiple preview when drag end
// biome-ignore lint/correctness/useExhaustiveDependencies: resetPreview is stable; intentionally only run on isDragging change
React.useEffect(() => {
if (!isDragging) {
resetPreview();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDragging]);
// biome-ignore lint/correctness/useExhaustiveDependencies: previewRef is a stable ref; only run on isAboutToDrag change
React.useEffect(() => {
if (isAboutToDrag) {
previewRef.current?.classList.remove("opacity-0");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isAboutToDrag]);
const [dragButtonTop, setDragButtonTop] = React.useState(0);
return (
// biome-ignore lint/a11y/noStaticElementInteractions: plate editor block wrapper requires mouse events
<div
className={cn(
"relative",
@ -158,6 +159,7 @@ function Draggable(props: PlateElementProps) {
contentEditable={false}
/>
{/* biome-ignore lint/a11y/noStaticElementInteractions: plate editor context menu handler */}
<div
ref={nodeRef}
className="slate-blockWrapper flow-root"
@ -215,8 +217,10 @@ const DragHandle = React.memo(function DragHandle({
return (
<Tooltip>
<TooltipTrigger asChild>
{/* biome-ignore lint/a11y/useSemanticElements: drag handle requires div for plate editor integration */}
<div
className="flex size-full items-center justify-center"
tabIndex={0}
onClick={(e) => {
e.preventDefault();
editor.getApi(BlockSelectionPlugin).blockSelection.focus();
@ -291,6 +295,12 @@ const DragHandle = React.memo(function DragHandle({
onMouseUp={() => {
resetPreview();
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
editor.getApi(BlockSelectionPlugin).blockSelection.focus();
}
}}
data-plate-prevent-deselect
role="button"
>

View file

@ -43,10 +43,16 @@ export function EquationElement({ children, ...props }: PlateElementProps<TEquat
props.className
)}
>
{/* biome-ignore lint/a11y/useSemanticElements: contentEditable context requires div */}
<div
role="button"
tabIndex={0}
className="flex cursor-pointer items-center justify-center"
contentEditable={false}
onDoubleClick={() => setIsEditing(true)}
onKeyDown={(e) => {
if (e.key === "Enter") setIsEditing(true);
}}
>
{element.texExpression ? (
<div ref={katexRef} className="text-center" />
@ -123,10 +129,16 @@ export function InlineEquationElement({ children, ...props }: PlateElementProps<
as="span"
className={cn("inline rounded-sm px-0.5", selected && "bg-brand/15", props.className)}
>
{/* biome-ignore lint/a11y/useSemanticElements: inline contentEditable context requires span */}
<span
role="button"
tabIndex={0}
className="cursor-pointer"
contentEditable={false}
onDoubleClick={() => setIsEditing(true)}
onKeyDown={(e) => {
if (e.key === "Enter") setIsEditing(true);
}}
>
{element.texExpression ? (
<span ref={katexRef} />

View file

@ -97,7 +97,7 @@ function HeroCarouselCard({
observer.observe(video);
return () => observer.disconnect();
}, [src]);
}, []);
const handleCanPlay = useCallback(() => {
setHasLoaded(true);
@ -114,7 +114,19 @@ function HeroCarouselCard({
<p className="text-sm text-neutral-500 dark:text-neutral-400">{description}</p>
</div>
</div>
<div className="cursor-pointer bg-neutral-50 p-2 sm:p-3 dark:bg-neutral-950" onClick={open}>
{/* biome-ignore lint/a11y/useSemanticElements: div wraps video element, button would break layout */}
<div
role="button"
tabIndex={0}
className="cursor-pointer bg-neutral-50 p-2 sm:p-3 dark:bg-neutral-950"
onClick={open}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
open();
}
}}
>
<div className="relative">
<video
ref={videoRef}
@ -185,45 +197,45 @@ function HeroCarousel() {
</AnimatePresence>
</div>
<div className="relative z-5 mt-4 flex items-center justify-center gap-2">
<button
type="button"
onClick={() => !isGifExpanded && goToPrev()}
className="flex size-11 items-center justify-center rounded-full border border-neutral-200 bg-white text-neutral-700 shadow-sm transition-colors hover:bg-neutral-100 touch-manipulation dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-200 dark:hover:bg-neutral-700"
aria-label="Previous slide"
>
<ChevronLeft className="size-5" />
</button>
<div className="relative z-5 mt-4 flex items-center justify-center gap-2">
<button
type="button"
onClick={() => !isGifExpanded && goToPrev()}
className="flex size-11 items-center justify-center rounded-full border border-neutral-200 bg-white text-neutral-700 shadow-sm transition-colors hover:bg-neutral-100 touch-manipulation dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-200 dark:hover:bg-neutral-700"
aria-label="Previous slide"
>
<ChevronLeft className="size-5" />
</button>
<div className="flex items-center">
{carouselItems.map((_, i) => (
<button
key={`dot_${i}`}
type="button"
onClick={() => !isGifExpanded && goTo(i)}
className="flex h-11 min-w-[28px] items-center justify-center touch-manipulation"
aria-label={`Go to slide ${i + 1}`}
>
<span
className={`block h-2.5 rounded-full transition-all duration-300 ${
i === activeIndex
? "w-6 bg-neutral-900 dark:bg-white"
: "w-2.5 bg-neutral-300 hover:bg-neutral-400 dark:bg-neutral-600 dark:hover:bg-neutral-500"
}`}
/>
</button>
))}
<div className="flex items-center">
{carouselItems.map((_, i) => (
<button
key={`dot_${i}`}
type="button"
onClick={() => !isGifExpanded && goTo(i)}
className="flex h-11 min-w-[28px] items-center justify-center touch-manipulation"
aria-label={`Go to slide ${i + 1}`}
>
<span
className={`block h-2.5 rounded-full transition-all duration-300 ${
i === activeIndex
? "w-6 bg-neutral-900 dark:bg-white"
: "w-2.5 bg-neutral-300 hover:bg-neutral-400 dark:bg-neutral-600 dark:hover:bg-neutral-500"
}`}
/>
</button>
))}
</div>
<button
type="button"
onClick={() => !isGifExpanded && goToNext()}
className="flex size-11 items-center justify-center rounded-full border border-neutral-200 bg-white text-neutral-700 shadow-sm transition-colors hover:bg-neutral-100 touch-manipulation dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-200 dark:hover:bg-neutral-700"
aria-label="Next slide"
>
<ChevronRight className="size-5" />
</button>
</div>
<button
type="button"
onClick={() => !isGifExpanded && goToNext()}
className="flex size-11 items-center justify-center rounded-full border border-neutral-200 bg-white text-neutral-700 shadow-sm transition-colors hover:bg-neutral-100 touch-manipulation dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-200 dark:hover:bg-neutral-700"
aria-label="Next slide"
>
<ChevronRight className="size-5" />
</button>
</div>
</div>
);
}

View file

@ -160,22 +160,21 @@ function LinkOpenButton() {
const editor = useEditorRef();
const selection = useEditorSelection();
const attributes = React.useMemo(
() => {
const entry = editor.api.node<TLinkElement>({
match: { type: editor.getType(KEYS.link) },
});
if (!entry) {
return {};
}
const [element] = entry;
return getLinkAttributes(editor, element);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[editor, selection]
);
// biome-ignore lint/correctness/useExhaustiveDependencies: selection triggers recalculation of link attributes
const attributes = React.useMemo(() => {
const entry = editor.api.node<TLinkElement>({
match: { type: editor.getType(KEYS.link) },
});
if (!entry) {
return {};
}
const [element] = entry;
return getLinkAttributes(editor, element);
}, [editor, selection]);
return (
// biome-ignore lint/a11y/noStaticElementInteractions: <a> with spread attributes has dynamic href
// biome-ignore lint/a11y/useAriaPropsSupportedByRole: aria-label needed for icon-only link
<a
{...attributes}
className={buttonVariants({
@ -185,6 +184,9 @@ function LinkOpenButton() {
onMouseOver={(e) => {
e.stopPropagation();
}}
onFocus={(e) => {
e.stopPropagation();
}}
aria-label="Open link in a new tab"
target="_blank"
rel="noopener noreferrer"

View file

@ -219,7 +219,8 @@ export function ToolbarSplitButtonSecondary({
...props
}: React.ComponentPropsWithoutRef<"span"> & VariantProps<typeof dropdownArrowVariants>) {
return (
<span
<button
type="button"
className={cn(
dropdownArrowVariants({
size,
@ -229,11 +230,10 @@ export function ToolbarSplitButtonSecondary({
className
)}
onClick={(e) => e.stopPropagation()}
role="button"
{...props}
{...(props as React.ComponentPropsWithoutRef<"button">)}
>
<ChevronDown className="size-3.5 text-muted-foreground" data-icon />
</span>
</button>
);
}