feat(sessions): add custom folder grouping for sessions (#460)
* feat(sessions): add custom folder grouping for sessions Allow users to create custom folders to organize chat sessions. Folders are stored in localStorage, scoped per project. Features: - Create/rename/delete folders from sidebar header - Move sessions to folders via context menu - Remove sessions from folders - Collapse/expand folder state persisted - Automatic cleanup when sessions are deleted Implementation: - useSessionFoldersStore: Zustand store with CRUD + localStorage persistence - SessionFolderItem: Component for folder header (name, count, actions) + body - SessionSidebar: Integration with context menu, folder rendering in session list * fix(sessions): remove sidebar header folder action * fix: remove folder when they becomes empty * refactor(sessions): switch folder scope to session directory and clean folder interactions * fix: move folder actions outside rename mode --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
62e5c3edfe
commit
6e63f23bb8
@@ -0,0 +1,263 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
RiFolderLine,
|
||||
RiFolderOpenLine,
|
||||
RiArrowRightSLine,
|
||||
RiArrowDownSLine,
|
||||
RiPencilAiLine,
|
||||
RiDeleteBinLine,
|
||||
RiCheckLine,
|
||||
RiCloseLine,
|
||||
} from '@remixicon/react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { SessionFolder } from '@/stores/useSessionFoldersStore';
|
||||
|
||||
interface SessionFolderItemProps<TSessionNode> {
|
||||
folder: SessionFolder;
|
||||
sessions: TSessionNode[];
|
||||
isCollapsed: boolean;
|
||||
onToggle: () => void;
|
||||
onRename: (name: string) => void;
|
||||
onDelete: () => void;
|
||||
renderSessionNode: (
|
||||
node: TSessionNode,
|
||||
depth?: number,
|
||||
groupDir?: string | null,
|
||||
projectId?: string | null,
|
||||
) => React.ReactNode;
|
||||
groupDirectory?: string | null;
|
||||
projectId?: string | null;
|
||||
mobileVariant?: boolean;
|
||||
isRenaming?: boolean;
|
||||
renameDraft?: string;
|
||||
onRenameDraftChange?: (value: string) => void;
|
||||
onRenameSave?: () => void;
|
||||
onRenameCancel?: () => void;
|
||||
}
|
||||
|
||||
const SessionFolderItemBase = <TSessionNode,>({
|
||||
folder,
|
||||
sessions,
|
||||
isCollapsed,
|
||||
onToggle,
|
||||
onRename,
|
||||
onDelete,
|
||||
renderSessionNode,
|
||||
groupDirectory,
|
||||
projectId,
|
||||
mobileVariant = false,
|
||||
isRenaming = false,
|
||||
renameDraft = '',
|
||||
onRenameDraftChange,
|
||||
onRenameSave,
|
||||
onRenameCancel,
|
||||
}: SessionFolderItemProps<TSessionNode>) => {
|
||||
const [localRenaming, setLocalRenaming] = React.useState(false);
|
||||
const [localDraft, setLocalDraft] = React.useState('');
|
||||
const inputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const renaming = isRenaming || localRenaming;
|
||||
const draft = isRenaming ? renameDraft : localDraft;
|
||||
|
||||
const handleStartRename = React.useCallback(() => {
|
||||
setLocalDraft(folder.name);
|
||||
setLocalRenaming(true);
|
||||
}, [folder.name]);
|
||||
|
||||
const handleSaveRename = React.useCallback(() => {
|
||||
const trimmed = draft.trim();
|
||||
if (trimmed && trimmed !== folder.name) {
|
||||
onRename(trimmed);
|
||||
}
|
||||
if (isRenaming && onRenameSave) {
|
||||
onRenameSave();
|
||||
}
|
||||
setLocalRenaming(false);
|
||||
setLocalDraft('');
|
||||
}, [draft, folder.name, isRenaming, onRename, onRenameSave]);
|
||||
|
||||
const handleCancelRename = React.useCallback(() => {
|
||||
if (isRenaming && onRenameCancel) {
|
||||
onRenameCancel();
|
||||
}
|
||||
setLocalRenaming(false);
|
||||
setLocalDraft('');
|
||||
}, [isRenaming, onRenameCancel]);
|
||||
|
||||
const handleDraftChange = React.useCallback(
|
||||
(value: string) => {
|
||||
if (isRenaming && onRenameDraftChange) {
|
||||
onRenameDraftChange(value);
|
||||
} else {
|
||||
setLocalDraft(value);
|
||||
}
|
||||
},
|
||||
[isRenaming, onRenameDraftChange],
|
||||
);
|
||||
|
||||
// Auto-focus rename when externally triggered (e.g. from session menu)
|
||||
React.useEffect(() => {
|
||||
if (!isRenaming) return;
|
||||
const focusInput = () => {
|
||||
const input = inputRef.current;
|
||||
if (!input) return;
|
||||
input.focus();
|
||||
input.select();
|
||||
};
|
||||
const frameId = requestAnimationFrame(focusInput);
|
||||
const timeoutId = window.setTimeout(focusInput, 0);
|
||||
return () => {
|
||||
cancelAnimationFrame(frameId);
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [isRenaming]);
|
||||
|
||||
const FolderIcon = isCollapsed ? RiFolderLine : RiFolderOpenLine;
|
||||
|
||||
return (
|
||||
<div className="oc-folder">
|
||||
{/* Folder header */}
|
||||
<div
|
||||
className={cn(
|
||||
'group/folder flex items-center justify-between gap-1.5 py-1 min-w-0 rounded-sm',
|
||||
'hover:bg-interactive-hover/50 cursor-pointer',
|
||||
)}
|
||||
onClick={renaming ? undefined : onToggle}
|
||||
role={renaming ? undefined : 'button'}
|
||||
tabIndex={renaming ? undefined : 0}
|
||||
onKeyDown={
|
||||
renaming
|
||||
? undefined
|
||||
: (event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
onToggle();
|
||||
}
|
||||
}
|
||||
}
|
||||
aria-label={isCollapsed ? `Expand folder ${folder.name}` : `Collapse folder ${folder.name}`}
|
||||
>
|
||||
<div className="min-w-0 flex items-center gap-1.5 pl-1.5 flex-1">
|
||||
<FolderIcon className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
||||
|
||||
{renaming ? (
|
||||
<form
|
||||
className="flex min-w-0 flex-1 items-center gap-1"
|
||||
data-keyboard-avoid="true"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
handleSaveRename();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={draft}
|
||||
onChange={(event) => handleDraftChange(event.target.value)}
|
||||
className="flex-1 min-w-0 bg-transparent typography-ui-label outline-none placeholder:text-muted-foreground"
|
||||
autoFocus
|
||||
placeholder="Folder name"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.stopPropagation();
|
||||
handleCancelRename();
|
||||
return;
|
||||
}
|
||||
if (event.key === ' ' || event.key === 'Enter') {
|
||||
event.stopPropagation();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<RiCheckLine className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
handleCancelRename();
|
||||
}}
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<RiCloseLine className="size-4" />
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<div className="min-w-0 flex items-center gap-1.5 flex-1">
|
||||
<span className="typography-ui-label font-semibold text-muted-foreground truncate">
|
||||
{folder.name}
|
||||
</span>
|
||||
<span className="typography-micro text-muted-foreground/70 flex-shrink-0">
|
||||
• {sessions.length}
|
||||
</span>
|
||||
{isCollapsed ? (
|
||||
<RiArrowRightSLine className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<RiArrowDownSLine className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
{!renaming ? (
|
||||
<div className="flex items-center gap-0.5 px-0.5">
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-0.5 transition-opacity',
|
||||
mobileVariant ? 'opacity-100' : 'opacity-0 group-hover/folder:opacity-100 group-focus-within/folder:opacity-100',
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
handleStartRename();
|
||||
}}
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-label={`Rename folder ${folder.name}`}
|
||||
>
|
||||
<RiPencilAiLine className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-destructive hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-label={`Delete folder ${folder.name}`}
|
||||
>
|
||||
<RiDeleteBinLine className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Folder body */}
|
||||
{!isCollapsed ? (
|
||||
<div className="pb-1 pl-2">
|
||||
{sessions.length > 0 ? (
|
||||
sessions.map((node) =>
|
||||
renderSessionNode(node, 0, groupDirectory ?? null, projectId ?? null),
|
||||
)
|
||||
) : (
|
||||
<div className="py-1 pl-1.5 text-left typography-micro text-muted-foreground/70">
|
||||
Empty folder
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const SessionFolderItem = React.memo(SessionFolderItemBase) as <TSessionNode>(
|
||||
props: SessionFolderItemProps<TSessionNode>,
|
||||
) => React.ReactElement;
|
||||
Reference in New Issue
Block a user