From bd4ea181a019dedae481857420b365c8f0499924 Mon Sep 17 00:00:00 2001 From: Alterego-42 <850242984@qq.com> Date: Tue, 23 Jun 2026 22:52:20 +0800 Subject: [PATCH] feat(sessions): folder-style session grouping (file-explorer sidebar) [RFC draft] Replace the flat session list with a file-explorer sidebar: - Always-on rail + hover flyout showing a nestable group tree (up to 4 levels). - Entering a group shows only its direct sessions, backed by REAL keyset pagination on the DB session-meta store (no getAll()+slice). - Drag-reorder of groups (among siblings) and sessions (within a group). - Per-group color / rename / duplicate / delete; Unassigned + virtual Starred views. - AI Manager session: a reserved pinned session exposing a model-callable session-management toolset (list/move/rename/group ops, bulk variants, summaries with configurable concurrency, auto-organize proposal hook). - Adaptive session summaries (type-aware prompt; per-session/global override). - Selective export/import compatible with the official Chatbox format. This is a Draft / RFC for design discussion, built on top of v1.21.1's paginated session-meta store. The migration here carries fork-specific 15->16 self-healing (an artifact of this fork's history) that upstream would simplify to a clean forward migration. Local-build tweaks and a personal OpenAI-Responses change are intentionally excluded. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/renderer/Sidebar.tsx | 23 +- .../components/ExportSelectionTree.tsx | 258 +++++++ src/renderer/components/session/GroupRail.tsx | 94 +++ .../components/session/GroupTreeFlyout.tsx | 424 ++++++++++++ .../components/session/GroupedSessionList.tsx | 260 +++++++ .../components/session/ManagerSessionPin.tsx | 48 ++ .../components/session/SessionItem.tsx | 103 ++- src/renderer/i18n/locales/en/translation.json | 51 +- src/renderer/index.tsx | 10 + src/renderer/lib/concurrency.test.ts | 89 +++ src/renderer/lib/concurrency.ts | 82 +++ src/renderer/lib/export-helpers.test.ts | 192 ++++++ src/renderer/lib/export-helpers.ts | 122 ++++ .../modals/ConfirmDangerousAction.tsx | 70 ++ src/renderer/modals/CreateGroup.tsx | 70 ++ src/renderer/modals/MoveSessionToGroup.tsx | 262 +++++++ src/renderer/modals/SessionSettings.tsx | 22 + src/renderer/modals/SessionSummary.tsx | 243 +++++++ src/renderer/modals/SetGroupColor.tsx | 99 +++ src/renderer/modals/index.tsx | 10 + .../model-calls/toolsets/session-manager.ts | 648 ++++++++++++++++++ src/renderer/packages/prompts.ts | 51 ++ src/renderer/routes/index.tsx | 4 + src/renderer/routes/settings/chat.tsx | 34 + .../routes/settings/default-models.tsx | 58 +- src/renderer/routes/settings/general.tsx | 171 ++++- .../storage/SQLiteSessionMetaStorage.ts | 63 +- src/renderer/storage/SessionMetaStorage.ts | 108 ++- src/renderer/storage/StoreStorage.ts | 4 + .../SQLiteSessionMetaStorage.test.ts | 10 +- .../SessionMetaStorage.group.test.ts | 79 +++ src/renderer/stores/atoms/uiAtoms.ts | 7 + src/renderer/stores/chatStore.ts | 106 +++ src/renderer/stores/groupStore.test.ts | 219 ++++++ src/renderer/stores/groupStore.ts | 199 ++++++ src/renderer/stores/migration.test.ts | 143 +++- src/renderer/stores/migration.ts | 35 +- .../stores/session/auto-organize.test.ts | 41 ++ src/renderer/stores/session/auto-organize.ts | 31 + src/renderer/stores/session/crud.ts | 27 +- src/renderer/stores/session/groups.test.ts | 311 +++++++++ src/renderer/stores/session/groups.ts | 122 ++++ src/renderer/stores/session/orchestration.ts | 1 + src/renderer/stores/session/summary.ts | 165 +++++ src/renderer/stores/session/tools-builder.ts | 17 + src/renderer/stores/sessionActions.ts | 6 + src/renderer/stores/sessionHelpers.ts | 14 +- src/renderer/utils/group-tree.test.ts | 48 ++ src/renderer/utils/group-tree.ts | 53 ++ src/renderer/utils/session-utils.test.ts | 89 ++- src/renderer/utils/session-utils.ts | 8 + src/shared/defaults.ts | 6 + src/shared/types/session.ts | 19 + src/shared/types/settings.ts | 16 + 54 files changed, 5324 insertions(+), 121 deletions(-) create mode 100644 src/renderer/components/ExportSelectionTree.tsx create mode 100644 src/renderer/components/session/GroupRail.tsx create mode 100644 src/renderer/components/session/GroupTreeFlyout.tsx create mode 100644 src/renderer/components/session/GroupedSessionList.tsx create mode 100644 src/renderer/components/session/ManagerSessionPin.tsx create mode 100644 src/renderer/lib/concurrency.test.ts create mode 100644 src/renderer/lib/concurrency.ts create mode 100644 src/renderer/lib/export-helpers.test.ts create mode 100644 src/renderer/lib/export-helpers.ts create mode 100644 src/renderer/modals/ConfirmDangerousAction.tsx create mode 100644 src/renderer/modals/CreateGroup.tsx create mode 100644 src/renderer/modals/MoveSessionToGroup.tsx create mode 100644 src/renderer/modals/SessionSummary.tsx create mode 100644 src/renderer/modals/SetGroupColor.tsx create mode 100644 src/renderer/packages/model-calls/toolsets/session-manager.ts create mode 100644 src/renderer/storage/__tests__/SessionMetaStorage.group.test.ts create mode 100644 src/renderer/stores/groupStore.test.ts create mode 100644 src/renderer/stores/groupStore.ts create mode 100644 src/renderer/stores/session/auto-organize.test.ts create mode 100644 src/renderer/stores/session/auto-organize.ts create mode 100644 src/renderer/stores/session/groups.test.ts create mode 100644 src/renderer/stores/session/groups.ts create mode 100644 src/renderer/stores/session/summary.ts create mode 100644 src/renderer/utils/group-tree.test.ts create mode 100644 src/renderer/utils/group-tree.ts diff --git a/src/renderer/Sidebar.tsx b/src/renderer/Sidebar.tsx index b8a4d4f50b..9b3c867588 100644 --- a/src/renderer/Sidebar.tsx +++ b/src/renderer/Sidebar.tsx @@ -13,13 +13,16 @@ import { } from '@tabler/icons-react' import { useNavigate } from '@tanstack/react-router' import clsx from 'clsx' +import { useAtomValue } from 'jotai' import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import Divider from './components/common/Divider' import { ScalableIcon } from './components/common/ScalableIcon' import SessionAttachmentRagDevPane from './components/dev/SessionAttachmentRagDevPane' import ThemeSwitchButton from './components/dev/ThemeSwitchButton' -import SessionList from './components/session/SessionList' +import GroupedSessionList from './components/session/GroupedSessionList' +import GroupRail from './components/session/GroupRail' +import ManagerSessionPin from './components/session/ManagerSessionPin' import TaskSessionList from './components/session/TaskSessionList' import { FORCE_ENABLE_DEV_PAGES } from './dev/devToolsConfig' import useNeedRoomForMacWinControls from './hooks/useNeedRoomForWinControls' @@ -28,12 +31,13 @@ import useVersion from './hooks/useVersion' import { navigateToSettings } from './modals/Settings' import { trackingEvent } from './packages/event' import platform from './platform' -import { featureFlags } from './utils/feature-flags' import icon from './static/icon.png' +import { currentSidebarGroupIdAtom } from './stores/atoms/uiAtoms' import { settingsStore, useLanguage } from './stores/settingsStore' import { taskSessionStore } from './stores/taskSessionStore' import { useUIStore } from './stores/uiStore' import { installUpdate, useUpdateStore } from './stores/updateStore' +import { featureFlags } from './utils/feature-flags' import { CHATBOX_BUILD_PLATFORM, CHATBOX_BUILD_TARGET } from './variables' export default function Sidebar() { @@ -46,6 +50,7 @@ export default function Sidebar() { const setSidebarWidth = useUIStore((s) => s.setSidebarWidth) const sidebarMode = useUIStore((s) => s.sidebarMode) const setSidebarMode = useUIStore((s) => s.setSidebarMode) + const currentSidebarGroupId = useAtomValue(currentSidebarGroupIdAtom) const sessionListViewportRef = useRef(null) @@ -63,13 +68,13 @@ export default function Sidebar() { const { needRoomForMacWindowControls } = useNeedRoomForMacWinControls() const handleCreateNewSession = useCallback(() => { - navigate({ to: `/` }) + navigate({ to: '/', search: { groupId: currentSidebarGroupId ?? undefined } }) if (isSmallScreen) { setShowSidebar(false) } trackingEvent('create_new_conversation', { event_category: 'user' }) - }, [navigate, setShowSidebar, isSmallScreen]) + }, [navigate, setShowSidebar, isSmallScreen, currentSidebarGroupId]) const handleCreateNewPictureSession = useCallback(() => { navigate({ to: '/image-creator' }) @@ -216,7 +221,15 @@ export default function Sidebar() { {sidebarMode === 'task' && featureFlags.taskMode ? ( ) : ( - + <> + + + + + + + + )} diff --git a/src/renderer/components/ExportSelectionTree.tsx b/src/renderer/components/ExportSelectionTree.tsx new file mode 100644 index 0000000000..4655d14d8c --- /dev/null +++ b/src/renderer/components/ExportSelectionTree.tsx @@ -0,0 +1,258 @@ +import { ActionIcon, Box, Checkbox, Collapse, Flex, Stack, Text } from '@mantine/core' +import type { SessionGroup, SessionMeta } from '@shared/types' +import { IconChevronDown, IconChevronRight, IconFolder, IconInbox } from '@tabler/icons-react' +import { useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { UNASSIGNED_ID } from '@/lib/export-helpers' +import { isSystemSession } from '@/utils/session-utils' + +// TODO(phase-5): if a single group exceeds ~500 sessions consider tanstack/react-virtual +// to keep checkbox renders cheap when the user expands it. + +export interface ExportSelectionValue { + groupIds: Set + sessionIds: Set +} + +export interface ExportSelectionTreeProps { + groups: SessionGroup[] + sessions: SessionMeta[] + value: ExportSelectionValue + onChange: (next: ExportSelectionValue) => void + disabled?: boolean + isExpanded?: boolean + onToggleExpand?: () => void +} + +interface BucketView { + id: string + label: string + isUnassigned: boolean + sessions: SessionMeta[] +} + +export default function ExportSelectionTree({ + groups, + sessions, + value, + onChange, + disabled = false, + isExpanded, + onToggleExpand, +}: ExportSelectionTreeProps) { + const { t } = useTranslation() + + const [internalExpanded, setInternalExpanded] = useState(false) + const expanded = isExpanded ?? internalExpanded + const toggleExpanded = () => { + if (onToggleExpand) { + onToggleExpand() + } else { + setInternalExpanded((v) => !v) + } + } + + const [openGroups, setOpenGroups] = useState>({}) + const toggleGroup = (id: string) => { + setOpenGroups((m) => ({ ...m, [id]: !m[id] })) + } + + const visibleSessions = useMemo( + () => sessions.filter((s) => !isSystemSession(s) && !s.hidden), + [sessions] + ) + + const buckets = useMemo(() => { + const byGroup = new Map() + const unassigned: SessionMeta[] = [] + for (const s of visibleSessions) { + if (s.groupId) { + const arr = byGroup.get(s.groupId) ?? [] + arr.push(s) + byGroup.set(s.groupId, arr) + } else { + unassigned.push(s) + } + } + const sortedGroups = [...groups].sort((a, b) => a.sortIndex - b.sortIndex) + const result: BucketView[] = sortedGroups.map((g) => ({ + id: g.id, + label: g.name, + isUnassigned: false, + sessions: byGroup.get(g.id) ?? [], + })) + // Hide the Unassigned row entirely when no sessions live there to avoid noise. + if (unassigned.length > 0) { + result.push({ + id: UNASSIGNED_ID, + label: t('Unassigned'), + isUnassigned: true, + sessions: unassigned, + }) + } + return result + }, [groups, visibleSessions, t]) + + const totalCount = visibleSessions.length + const selectedCount = useMemo(() => { + let n = 0 + for (const s of visibleSessions) { + if (value.sessionIds.has(s.id)) n++ + } + return n + }, [visibleSessions, value.sessionIds]) + + const triggerLabel = `${t('Select conversations to export')} — ${t('{{selected}} of {{total}} selected', { + selected: selectedCount, + total: totalCount, + })}` + + const setBucketSelected = (bucket: BucketView, allSelected: boolean) => { + const nextSessions = new Set(value.sessionIds) + const nextGroups = new Set(value.groupIds) + for (const s of bucket.sessions) { + if (allSelected) nextSessions.add(s.id) + else nextSessions.delete(s.id) + } + if (bucket.isUnassigned) { + // Unassigned has no group entry, just session ids. + } else if (allSelected) { + nextGroups.add(bucket.id) + } else { + nextGroups.delete(bucket.id) + } + onChange({ groupIds: nextGroups, sessionIds: nextSessions }) + } + + const toggleSession = (bucket: BucketView, sessionId: string, checked: boolean) => { + const nextSessions = new Set(value.sessionIds) + if (checked) nextSessions.add(sessionId) + else nextSessions.delete(sessionId) + const nextGroups = new Set(value.groupIds) + if (!bucket.isUnassigned) { + const allChecked = bucket.sessions.every((s) => nextSessions.has(s.id)) + if (allChecked && bucket.sessions.length > 0) nextGroups.add(bucket.id) + else nextGroups.delete(bucket.id) + } + onChange({ groupIds: nextGroups, sessionIds: nextSessions }) + } + + return ( + + + { + e.stopPropagation() + toggleExpanded() + }} + > + {expanded ? : } + + + {triggerLabel} + + + + + + {buckets.length === 0 ? ( + + {t('No conversations to select')} + + ) : ( + + {buckets.map((bucket) => { + const total = bucket.sessions.length + const selectedInBucket = bucket.sessions.reduce( + (acc, s) => acc + (value.sessionIds.has(s.id) ? 1 : 0), + 0 + ) + const allChecked = total > 0 && selectedInBucket === total + const indeterminate = selectedInBucket > 0 && selectedInBucket < total + const isOpen = !!openGroups[bucket.id] + + return ( + + + toggleGroup(bucket.id)} + disabled={disabled || total === 0} + > + {isOpen ? : } + + { + // indeterminate → all; otherwise toggle. + if (indeterminate) setBucketSelected(bucket, true) + else setBucketSelected(bucket, !allChecked) + }} + /> + {bucket.isUnassigned ? ( + + ) : ( + + )} + + {bucket.label}{' '} + + ({selectedInBucket}/{total}) + + + + + + + {bucket.sessions.length === 0 ? ( + + {t('No conversations to select')} + + ) : ( + bucket.sessions.map((s) => ( + + {s.name} + + } + onChange={(e) => toggleSession(bucket, s.id, e.currentTarget.checked)} + /> + )) + )} + + + + ) + })} + + )} + + + + ) +} diff --git a/src/renderer/components/session/GroupRail.tsx b/src/renderer/components/session/GroupRail.tsx new file mode 100644 index 0000000000..8eaf170fa2 --- /dev/null +++ b/src/renderer/components/session/GroupRail.tsx @@ -0,0 +1,94 @@ +import { ActionIcon, Box, Paper, Stack, Tooltip } from '@mantine/core' +import { IconFolders, IconInbox, IconStarFilled } from '@tabler/icons-react' +import { useAtom } from 'jotai' +import { useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { currentSidebarGroupIdAtom } from '@/stores/atoms/uiAtoms' +import { useGroups } from '@/stores/groupStore' +import { getGroupPath, STARRED_GROUP_ID } from '@/utils/group-tree' +import GroupTreeFlyout from './GroupTreeFlyout' + +const RAIL_W = 40 + +/** + * Thin always-on rail at the left of the session panel. Hovering it (or its flyout) opens the + * nested group tree; picking a group enters it and the flyout auto-hides. The rail also shows a + * compact breadcrumb of the entered group's path as color dots. + */ +export default function GroupRail() { + const { t } = useTranslation() + const { groups } = useGroups() + const [currentGroupId] = useAtom(currentSidebarGroupIdAtom) + const path = useMemo(() => getGroupPath(groups ?? [], currentGroupId), [groups, currentGroupId]) + const [open, setOpen] = useState(false) + const closeTimer = useRef | null>(null) + + const cancelClose = () => { + if (closeTimer.current) { + clearTimeout(closeTimer.current) + closeTimer.current = null + } + } + const openNow = () => { + cancelClose() + setOpen(true) + } + const closeSoon = () => { + cancelClose() + closeTimer.current = setTimeout(() => setOpen(false), 150) + } + + return ( + + + + + + + + + {currentGroupId === STARRED_GROUP_ID ? ( + + ) : currentGroupId === null ? ( + + ) : ( + path.map((g) => ( + + )) + )} + + + {open && ( + + setOpen(false)} /> + + )} + + ) +} diff --git a/src/renderer/components/session/GroupTreeFlyout.tsx b/src/renderer/components/session/GroupTreeFlyout.tsx new file mode 100644 index 0000000000..436fad19d2 --- /dev/null +++ b/src/renderer/components/session/GroupTreeFlyout.tsx @@ -0,0 +1,424 @@ +import type { DragEndEvent } from '@dnd-kit/core' +import { + closestCenter, + DndContext, + KeyboardSensor, + MouseSensor, + TouchSensor, + useSensor, + useSensors, +} from '@dnd-kit/core' +import { restrictToVerticalAxis } from '@dnd-kit/modifiers' +import { + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' +import NiceModal from '@ebay/nice-modal-react' +import { ActionIcon, Box, Divider, Flex, Menu, ScrollArea, Stack, Text, TextInput } from '@mantine/core' +import type { SessionGroup } from '@shared/types' +import { + IconChevronDown, + IconChevronRight, + IconCopy, + IconDots, + IconFolder, + IconFolderPlus, + IconInbox, + IconPalette, + IconPencil, + IconStarFilled, + IconTrash, +} from '@tabler/icons-react' +import { useAtom } from 'jotai' +import { type KeyboardEvent, useCallback, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { currentSidebarGroupIdAtom, expandedGroupsAtom } from '@/stores/atoms/uiAtoms' +import { useGroupSessionCount, useStarredSessions } from '@/stores/chatStore' +import { deleteGroup, MAX_GROUP_DEPTH, updateGroup, useGroups } from '@/stores/groupStore' +import { duplicateGroup, reorderChildGroups, reorderGroups } from '@/stores/session/groups' +import { add as addToast } from '@/stores/toastActions' +import { buildGroupTree, type GroupTreeNode, STARRED_GROUP_ID } from '@/utils/group-tree' + +const INDENT_PX = 18 + +/** The nested group tree shown in the rail flyout. Clicking a group "enters" it (onNavigate closes the flyout). */ +export default function GroupTreeFlyout({ onNavigate }: { onNavigate?: () => void }) { + const { t } = useTranslation() + const { groups } = useGroups() + const tree = useMemo(() => buildGroupTree(groups ?? []), [groups]) + const [currentGroupId, setCurrentGroupId] = useAtom(currentSidebarGroupIdAtom) + const [expandedMap, setExpandedMap] = useAtom(expandedGroupsAtom) + const [renamingId, setRenamingId] = useState(null) + + const enter = useCallback( + (id: string | null) => { + setCurrentGroupId(id) + onNavigate?.() + }, + [setCurrentGroupId, onNavigate] + ) + const isExpanded = useCallback((id: string) => expandedMap[id] !== false, [expandedMap]) + const toggle = useCallback((id: string) => setExpandedMap((m) => ({ ...m, [id]: m[id] === false })), [setExpandedMap]) + + const sensors = useSensors( + useSensor(TouchSensor, { activationConstraint: { delay: 250, tolerance: 10 } }), + useSensor(MouseSensor, { activationConstraint: { distance: 8 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) + ) + const rootIds = useMemo(() => tree.map((n) => n.group.id), [tree]) + const onGroupDragEnd = async (event: DragEndEvent) => { + const { active, over } = event + if (!over || !groups) return + const activeId = String(active.id) + const overId = String(over.id) + if (activeId === overId) return + const a = groups.find((g) => g.id === activeId) + const o = groups.find((g) => g.id === overId) + // Only reorder among siblings (same parent); cross-level drags are ignored. + if (!a || !o || (a.parentId ?? null) !== (o.parentId ?? null)) return + const siblings = groups + .filter((g) => (g.parentId ?? null) === (a.parentId ?? null)) + .sort((x, y) => x.sortIndex - y.sortIndex) + const oldIndex = siblings.findIndex((g) => g.id === activeId) + const newIndex = siblings.findIndex((g) => g.id === overId) + if (oldIndex < 0 || newIndex < 0 || oldIndex === newIndex) return + if (a.parentId === null) await reorderGroups(oldIndex, newIndex) + else await reorderChildGroups(a.parentId, oldIndex, newIndex) + } + + return ( + + enter(null)} /> + enter(STARRED_GROUP_ID)} /> + + + + + + {t('Groups')} + + void NiceModal.show('create-group')} + > + + + + + + + + + {tree.map((node) => ( + + ))} + + + + + + ) +} + +function UngroupedRow({ active, onEnter }: { active: boolean; onEnter: () => void }) { + const { t } = useTranslation() + const count = useGroupSessionCount(null) + return ( + } + label={ + + {t('Ungrouped')} + + } + count={count} + /> + ) +} + +function StarredRow({ active, onEnter }: { active: boolean; onEnter: () => void }) { + const { t } = useTranslation() + const { total } = useStarredSessions() + return ( + } + label={ + + {t('Starred')} + + } + count={total} + /> + ) +} + +interface GroupRowProps { + node: GroupTreeNode + currentGroupId: string | null + isExpanded: (id: string) => boolean + toggle: (id: string) => void + enter: (id: string) => void + renamingId: string | null + setRenamingId: (id: string | null) => void +} + +function GroupRow(props: GroupRowProps) { + const { node, currentGroupId, isExpanded, toggle, enter, renamingId, setRenamingId } = props + const { group, depth, children } = node + const count = useGroupSessionCount(group.id) + const hasChildren = children.length > 0 + const expanded = isExpanded(group.id) + const active = currentGroupId === group.id + const renaming = renamingId === group.id + const { attributes, isDragging, listeners, setNodeRef, transform, transition } = useSortable({ + id: group.id, + disabled: renaming, + }) + const dndStyle: React.CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.4 : undefined, + } + + return ( + <> +
+ enter(group.id)} + chevron={ + hasChildren ? ( + { + e.stopPropagation() + toggle(group.id) + }} + > + {expanded ? : } + + ) : null + } + icon={ + + } + label={ + renaming ? ( + setRenamingId(null)} /> + ) : ( + + {group.name} + + ) + } + count={renaming ? undefined : count} + actions={renaming ? null : setRenamingId(group.id)} />} + /> +
+ {expanded && hasChildren && ( + c.group.id)} strategy={verticalListSortingStrategy}> + {children.map((child) => ( + + ))} + + )} + + ) +} + +interface RowShellProps { + depth: number + active: boolean + onClick: () => void + icon: React.ReactNode + label: React.ReactNode + count?: number + chevron?: React.ReactNode + actions?: React.ReactNode +} + +function RowShell({ depth, active, onClick, icon, label, count, chevron, actions }: RowShellProps) { + return ( + + + {Array.from({ length: Math.max(0, depth - 1) }).map((_, i) => ( + + ))} + + {chevron} + + {icon} + + {label} + + {typeof count === 'number' && count > 0 && ( + + {count} + + )} + {actions && {actions}} + + ) +} + +function RenameInput({ group, onDone }: { group: SessionGroup; onDone: () => void }) { + const [value, setValue] = useState(group.name) + const commit = async () => { + const next = value.trim() + if (next && next !== group.name) { + try { + await updateGroup(group.id, { name: next }) + } catch (err) { + addToast(err instanceof Error ? err.message : String(err)) + } + } + onDone() + } + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault() + void commit() + } else if (e.key === 'Escape') { + e.preventDefault() + onDone() + } + } + return ( + setValue(e.currentTarget.value)} + onBlur={() => void commit()} + onKeyDown={onKeyDown} + onClick={(e) => e.stopPropagation()} + /> + ) +} + +function GroupMenu({ group, depth, onRename }: { group: SessionGroup; depth: number; onRename: () => void }) { + const { t } = useTranslation() + const canNest = depth < MAX_GROUP_DEPTH + const handleDelete = async () => { + const ok = (await NiceModal.show('confirm-dangerous-action', { + type: 'delete_group', + description: t('Delete group "{{name}}"? Sessions will be moved to Unassigned.', { name: group.name }), + })) as boolean + if (ok) await deleteGroup(group.id) + } + return ( + + + e.stopPropagation()} + > + + + + e.stopPropagation()}> + {canNest && ( + } + onClick={() => void NiceModal.show('create-group', { parentId: group.id })} + > + {t('New subgroup')} + + )} + } onClick={onRename}> + {t('Rename group')} + + } + onClick={() => void NiceModal.show('set-group-color', { groupId: group.id })} + > + {t('Set color')} + + } + onClick={async () => { + try { + await duplicateGroup(group.id) + addToast(t('Group duplicated')) + } catch (err) { + addToast(err instanceof Error ? err.message : t('Failed to duplicate group')) + } + }} + > + {t('Duplicate group')} + + + } onClick={() => void handleDelete()}> + {t('Delete group')} + + + + ) +} diff --git a/src/renderer/components/session/GroupedSessionList.tsx b/src/renderer/components/session/GroupedSessionList.tsx new file mode 100644 index 0000000000..3e43ff93a2 --- /dev/null +++ b/src/renderer/components/session/GroupedSessionList.tsx @@ -0,0 +1,260 @@ +import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core' +import { + closestCenter, + DndContext, + DragOverlay, + KeyboardSensor, + MouseSensor, + TouchSensor, + useSensor, + useSensors, +} from '@dnd-kit/core' +import { restrictToVerticalAxis } from '@dnd-kit/modifiers' +import { + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' +import NiceModal from '@ebay/nice-modal-react' +import { ActionIcon, Box, Flex, Text, Tooltip } from '@mantine/core' +import { + IconArchive, + IconChevronRight, + IconInbox, + IconLoader2, + IconPlus, + IconSearch, + IconStarFilled, +} from '@tabler/icons-react' +import { useNavigate, useRouterState } from '@tanstack/react-router' +import { useAtom } from 'jotai' +import { type CSSProperties, type MutableRefObject, useCallback, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Virtuoso } from 'react-virtuoso' +import { currentSidebarGroupIdAtom } from '@/stores/atoms/uiAtoms' +import { useSessionListByGroup, useStarredSessions } from '@/stores/chatStore' +import { useGroups } from '@/stores/groupStore' +import { reorderSessionInGroup } from '@/stores/sessionActions' +import { useUIStore } from '@/stores/uiStore' +import { getGroupPath, STARRED_GROUP_ID } from '@/utils/group-tree' +import SessionItem from './SessionItem' + +export interface Props { + sessionListViewportRef: MutableRefObject +} + +function LoadingFooter() { + return ( + + + + ) +} + +function SortableSessionRow({ id, children }: { id: string; children: React.ReactNode }) { + const { attributes, isDragging, listeners, setNodeRef, transform, transition } = useSortable({ id }) + const style: CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0 : undefined, + } + return ( +
+ {children} +
+ ) +} + +/** Main file-explorer panel: the entered group's direct sessions (or the virtual Starred view), paginated. */ +export default function GroupedSessionList({ sessionListViewportRef }: Props) { + const { t } = useTranslation() + const navigate = useNavigate() + const { groups } = useGroups() + const [currentGroupId, setCurrentGroupId] = useAtom(currentSidebarGroupIdAtom) + const isStarred = currentGroupId === STARRED_GROUP_ID + const path = useMemo(() => getGroupPath(groups ?? [], currentGroupId), [groups, currentGroupId]) + const byGroup = useSessionListByGroup(isStarred ? null : currentGroupId) + const starred = useStarredSessions() + const sessionMetaList = isStarred ? starred.sessionMetaList : byGroup.sessionMetaList + const hasNext = !isStarred && byGroup.hasNextPage + const setOpenSearchDialog = useUIStore((s) => s.setOpenSearchDialog) + const routerState = useRouterState() + + const [activeDragId, setActiveDragId] = useState(null) + const sensors = useSensors( + useSensor(TouchSensor, { activationConstraint: { delay: 250, tolerance: 10 } }), + useSensor(MouseSensor, { activationConstraint: { distance: 10 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) + ) + + const onEndReached = useCallback(() => { + if (!isStarred && byGroup.hasNextPage && !byGroup.isFetchingNextPage) byGroup.fetchNextPage() + }, [isStarred, byGroup.hasNextPage, byGroup.isFetchingNextPage, byGroup.fetchNextPage]) + + const newChatHere = () => + navigate({ to: '/', search: currentGroupId && !isStarred ? { groupId: currentGroupId } : {} }) + + const onDragEnd = async (event: DragEndEvent) => { + setActiveDragId(null) + const { active, over } = event + if (!over || !sessionMetaList) return + const activeId = String(active.id) + const overId = String(over.id) + if (activeId === overId) return + const oldIndex = sessionMetaList.findIndex((s) => s.id === activeId) + const newIndex = sessionMetaList.findIndex((s) => s.id === overId) + if (oldIndex < 0 || newIndex < 0) return + await reorderSessionInGroup(sessionMetaList, oldIndex, newIndex) + } + + const sortableIds = useMemo(() => sessionMetaList?.map((s) => s.id) ?? [], [sessionMetaList]) + const activeDragSession = useMemo( + () => sessionMetaList?.find((s) => s.id === activeDragId), + [activeDragId, sessionMetaList] + ) + const virtuosoComponents = useMemo(() => (hasNext ? { Footer: LoadingFooter } : {}), [hasNext]) + + const listEl = + sessionMetaList && sessionMetaList.length > 0 ? ( + session.id} + scrollerRef={(ref) => { + if (ref instanceof HTMLDivElement) { + sessionListViewportRef.current = ref + } + }} + endReached={onEndReached} + components={virtuosoComponents} + itemContent={(_index, session) => { + const item = ( + + ) + return isStarred ? item : {item} + }} + /> + ) : ( + + + + {isStarred ? t('No starred chats yet') : t('No chats here yet')} + + + + ) + + return ( + + + + {isStarred ? ( + <> + + + {t('Starred')} + + + ) : ( + <> + + setCurrentGroupId(null)} + > + + + + {currentGroupId === null ? ( + + {t('Ungrouped')} + + ) : ( + path.map((g, i) => ( + + + setCurrentGroupId(g.id)} + > + {g.name} + + + )) + )} + + )} + + + {!isStarred && ( + + + + + + )} + + setOpenSearchDialog(true, true)} + > + + + + + NiceModal.show('clear-session-list')} + > + + + + + + {isStarred ? ( + listEl + ) : ( + setActiveDragId(String(e.active.id))} + onDragEnd={onDragEnd} + onDragCancel={() => setActiveDragId(null)} + > + + {listEl} + + + {activeDragSession ? ( +
+ +
+ ) : null} +
+
+ )} +
+ ) +} diff --git a/src/renderer/components/session/ManagerSessionPin.tsx b/src/renderer/components/session/ManagerSessionPin.tsx new file mode 100644 index 0000000000..ef9d297a43 --- /dev/null +++ b/src/renderer/components/session/ManagerSessionPin.tsx @@ -0,0 +1,48 @@ +import { Flex, Text } from '@mantine/core' +import { SESSION_MANAGER_ID } from '@shared/defaults' +import { IconRobot } from '@tabler/icons-react' +import { useRouterState } from '@tanstack/react-router' +import clsx from 'clsx' +import { useTranslation } from 'react-i18next' +import { useIsSmallScreen } from '@/hooks/useScreenChange' +import { switchCurrentSession } from '@/stores/sessionActions' +import { useUIStore } from '@/stores/uiStore' + +// Persistent pin slot for the AI Manager system session. +// Always renders regardless of storage state — clicks are safe even mid-bootstrap +// because switchCurrentSession + chatStore.getSession resolve lazily. +export default function ManagerSessionPin() { + const { t } = useTranslation() + const routerState = useRouterState() + const isSmallScreen = useIsSmallScreen() + const setShowSidebar = useUIStore((s) => s.setShowSidebar) + + const selected = routerState.location.pathname === `/session/${SESSION_MANAGER_ID}` + + const onClick = () => { + switchCurrentSession(SESSION_MANAGER_ID) + if (isSmallScreen) { + setShowSidebar(false) + } + } + + return ( + + + + {t('AI Manager')} + + + ) +} diff --git a/src/renderer/components/session/SessionItem.tsx b/src/renderer/components/session/SessionItem.tsx index dd5ec24fc9..e0311c7970 100644 --- a/src/renderer/components/session/SessionItem.tsx +++ b/src/renderer/components/session/SessionItem.tsx @@ -1,7 +1,16 @@ import NiceModal from '@ebay/nice-modal-react' import { ActionIcon, Flex, Text } from '@mantine/core' import type { SessionMeta } from '@shared/types' -import { IconCopy, IconDots, IconEdit, IconStar, IconStarFilled, IconTrash } from '@tabler/icons-react' +import { + IconCopy, + IconDots, + IconEdit, + IconFileDescription, + IconFolderSymlink, + IconStar, + IconStarFilled, + IconTrash, +} from '@tabler/icons-react' import clsx from 'clsx' import { memo, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -21,10 +30,13 @@ import { ScalableIcon } from '../common/ScalableIcon' export interface Props { session: SessionMeta selected: boolean + isOverlay?: boolean + /** In the virtual Starred view, hide move/copy/delete — only un-star removes a session from there. */ + restricted?: boolean } function SessionItem(props: Props) { - const { session, selected } = props + const { session, selected, isOverlay = false, restricted = false } = props const { t } = useTranslation() const setShowSidebar = useUIStore((s) => s.setShowSidebar) const onClick = () => { @@ -51,13 +63,35 @@ function SessionItem(props: Props) { }) }, }, + ...((restricted + ? [] + : [ + { + text: t('Move to group…'), + icon: IconFolderSymlink, + onClick: () => { + void NiceModal.show('move-session-to-group', { sessionId: session.id }) + }, + }, + ]) as ActionMenuItemProps[]), { - text: t('Copy'), - icon: IconCopy, + text: t('View Summary'), + icon: IconFileDescription, onClick: () => { - copyAndSwitchSession(session) + void NiceModal.show('session-summary', { sessionId: session.id }) }, }, + ...((restricted + ? [] + : [ + { + text: t('Copy'), + icon: IconCopy, + onClick: () => { + copyAndSwitchSession(session) + }, + }, + ]) as ActionMenuItemProps[]), { text: session.starred ? t('Unstar') : t('Star'), icon: session.starred ? IconStarFilled : IconStar, @@ -65,33 +99,37 @@ function SessionItem(props: Props) { void updateSessionStore(session.id, { starred: !session.starred }) }, }, - { divider: true }, - { - doubleCheck: true, - text: t('Delete'), - icon: IconTrash, - disabled: deleting, - onClick: async () => { - if (deletingRef.current) { - return - } - deletingRef.current = true - setDeleting(true) - try { - await deleteSessionStore(session.id) - // Only navigate if deleting the currently selected session - if (selected) { - router.navigate({ to: '/', replace: true }) - } - } catch (error) { - console.error('Failed to delete session:', error) - deletingRef.current = false - setDeleting(false) - } - }, - }, + ...((restricted + ? [] + : [ + { divider: true }, + { + doubleCheck: true, + text: t('Delete'), + icon: IconTrash, + disabled: deleting, + onClick: async () => { + if (deletingRef.current) { + return + } + deletingRef.current = true + setDeleting(true) + try { + await deleteSessionStore(session.id) + // Only navigate if deleting the currently selected session + if (selected) { + router.navigate({ to: '/', replace: true }) + } + } catch (error) { + console.error('Failed to delete session:', error) + deletingRef.current = false + setDeleting(false) + } + }, + }, + ]) as ActionMenuItemProps[]), ], - [session, selected, t, deleting] + [session, selected, t, deleting, restricted] ) return ( @@ -103,7 +141,8 @@ function SessionItem(props: Props) { ? '' : selected ? 'bg-chatbox-background-brand-secondary' - : 'hover:bg-chatbox-background-gray-secondary' + : 'hover:bg-chatbox-background-gray-secondary', + isOverlay && 'shadow-md opacity-90 bg-chatbox-background-primary' )} mx="xs" px="xs" diff --git a/src/renderer/i18n/locales/en/translation.json b/src/renderer/i18n/locales/en/translation.json index 34df27bfe6..b43a3aa78a 100644 --- a/src/renderer/i18n/locales/en/translation.json +++ b/src/renderer/i18n/locales/en/translation.json @@ -48,6 +48,7 @@ "Advanced image formats are not supported. Please convert to JPG or PNG.": "Advanced image formats are not supported. Please convert to JPG or PNG.", "Advanced Mode": "Advanced Mode", "Advanced Settings": "Advanced Settings", + "AI Manager": "AI Manager", "ai provider no implemented paint tips": "The current AI model provider({{aiProvider}}) does not support painting capabilities at this time. Currently, only Chatbox AI, OpenAI and Azure OpenAI offer this feature. If needed, please <0>go to settings and switch the AI model provider.", "AI-generated content may be inaccurate. Please verify important information.": "AI-generated content may be inaccurate. Please verify important information.", "AI-generated images may not be accurate. Review output carefully.": "AI-generated images may not be accurate. Review output carefully.", @@ -88,6 +89,7 @@ "Auto Compaction": "Auto Compaction", "Auto-collapse code blocks": "Auto-collapse code blocks", "Auto-Generate Chat Titles": "Auto-Generate Chat Titles", + "Auto-organize proposal": "Auto-organize proposal", "Auto-preview artifacts": "Auto-preview artifacts", "Automatic updates": "Automatic updates", "Automatically render generated artifacts (e.g., HTML with CSS, JS, Tailwind)": "Automatically render generated artifacts (e.g., HTML with CSS, JS, Tailwind)", @@ -176,6 +178,8 @@ "Configure a custom OpenAI-compatible provider": "Configure a custom OpenAI-compatible provider", "Configure MCP server manually": "Configure MCP server manually", "Confirm": "Confirm", + "Confirm ({{seconds}})": "Confirm ({{seconds}})", + "Confirm dangerous action": "Confirm dangerous action", "Confirm Delete?": "Confirm Delete?", "Confirm to delete this custom provider?": "Confirm to delete this custom provider?", "Confirm?": "Confirm?", @@ -852,6 +856,7 @@ "Thinking Level only works for Gemini 3 models": "Thinking Level only works for Gemini 3 models", "Third-party cloud parsing service, supports PDF and most Office files. Requires API token.": "Third-party cloud parsing service, supports PDF and most Office files. Requires API token.", "This action cannot be undone. All documents and their embeddings will be permanently deleted.": "This action cannot be undone. All documents and their embeddings will be permanently deleted.", + "This action cannot be undone.": "This action cannot be undone.", "This attachment is too large for chat attachments. Please upload it through Knowledge Base instead.": "This attachment is too large for chat attachments. Please upload it through Knowledge Base instead.", "This attachment is very large and may consume more points. You can send it anyway, or remove it and use a smaller file.": "This attachment is very large and may consume more points. You can send it anyway, or remove it and use a smaller file.", "This document contains too much text for chat attachments. Please upload it through Knowledge Base instead.": "This document contains too much text for chat attachments. Please upload it through Knowledge Base instead.", @@ -978,7 +983,51 @@ "Your current License (Chatbox AI Free/Lite) does not support the {{model}} model. To use this model, please upgrade to Chatbox AI Pro or a higher-tier package. Alternatively, you can switch to a different model by accessing the settings.": "Your current License (Chatbox AI Free/Lite) does not support the {{model}} model. To use this model, please upgrade to Chatbox AI Pro or a higher-tier package. Alternatively, you can switch to a different model by accessing the settings.", "Your HTML content has been published. You can access it via the link below.": "Your HTML content has been published. You can access it via the link below.", "Your license has expired.": "Your license has expired.", + "Your license has expired. You can continue using your quota pack.": "Your license has expired. You can continue using your quota pack.", "Your license has expired. Please check your subscription or purchase a new one.": "Your license has expired. Please check your subscription or purchase a new one.", "Your license has expired. You can continue using your expansion pack.": "Your license has expired. You can continue using your expansion pack.", - "Your rating on the App Store would help make Chatbox even better!": "Your rating on the App Store would help make Chatbox even better!" + "Your rating on the App Store would help make Chatbox even better!": "Your rating on the App Store would help make Chatbox even better!", + "Unassigned": "Unassigned", + "New group": "New group", + "New group name": "New group name", + "New chat in this group": "New chat in this group", + "Move to group…": "Move to group…", + "Rename group": "Rename group", + "Delete group": "Delete group", + "Group actions": "Group actions", + "Delete group \"{{name}}\"? Sessions will be moved to Unassigned.": "Delete group \"{{name}}\"? Sessions will be moved to Unassigned.", + "Move to group": "Move to group", + "Create new group…": "Create new group…", + "Failed to move session": "Failed to move session", + "Set color": "Set color", + "Duplicate group": "Duplicate group", + "Group duplicated": "Group duplicated", + "Failed to duplicate group": "Failed to duplicate group", + "View Summary": "View Summary", + "Session Summary": "Session Summary", + "Summary prompt": "Summary prompt", + "Used by the session summary modal. Leave blank to use the global default.": "Used by the session summary modal. Leave blank to use the global default.", + "Leave blank to use the global default summary prompt.": "Leave blank to use the global default summary prompt.", + "Default summary prompt": "Default summary prompt", + "Empty = use built-in default. Per-session settings can override this.": "Empty = use built-in default. Per-session settings can override this.", + "Generating summary…": "Generating summary…", + "Regenerate": "Regenerate", + "Summary may be outdated": "Summary may be outdated", + "Generated at": "Generated at", + "Failed to generate summary": "Failed to generate summary", + "No summary yet": "No summary yet", + "Select conversations to export": "Select conversations to export", + "{{selected}} of {{total}} selected": "{{selected}} of {{total}} selected", + "{{count}} conversations selected": "{{count}} conversations selected", + "No conversations to select": "No conversations to select", + "Default Summary Model": "Default Summary Model", + "Chatbox will use this model to generate session summaries.": "Chatbox will use this model to generate session summaries.", + "Summary Concurrency": "Summary Concurrency", + "How many session summaries the AI manager generates in parallel. On repeated failures it automatically falls back to 3, then 1.": "How many session summaries the AI manager generates in parallel. On repeated failures it automatically falls back to 3, then 1.", + "Groups": "Groups", + "New subgroup": "New subgroup", + "No chats here yet": "No chats here yet", + "No groups found": "No groups found", + "Starred": "Starred", + "No starred chats yet": "No starred chats yet" } diff --git a/src/renderer/index.tsx b/src/renderer/index.tsx index 73bfdc9b52..5dac5d2937 100644 --- a/src/renderer/index.tsx +++ b/src/renderer/index.tsx @@ -43,6 +43,7 @@ import './setup/jk_analytics_init' // 引入保护代码 import './setup/protect' import { QueryClientProvider } from '@tanstack/react-query' +import { ensureManagerSession } from './stores/chatStore' import { initSessionAttachmentRagMaintenance } from './setup/session_attachment_rag_maintenance' import { initLastUsedModelStore } from './stores/lastUsedModelStore' import { initOnboardingStore } from './stores/onboardingStore' @@ -77,6 +78,15 @@ async function initializeApp() { Sentry.captureException(e as Error) } + // Ensure the persistent system-managed AI Manager session exists post-migration. + // Idempotent — safe to call on every launch. + try { + await ensureManagerSession() + } catch (e) { + log.error('ensureManagerSession error', e) + Sentry.captureException(e as Error) + } + // 最后执行 storage 清理,清理不 block 进入UI import('./setup/storage_clear') diff --git a/src/renderer/lib/concurrency.test.ts b/src/renderer/lib/concurrency.test.ts new file mode 100644 index 0000000000..cb33fff6af --- /dev/null +++ b/src/renderer/lib/concurrency.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest' +import { type AttemptResult, concurrencyLadder, mapWithConcurrency, runWithConcurrencyFallback } from './concurrency' + +const tick = () => new Promise((r) => setTimeout(r, 1)) + +describe('mapWithConcurrency', () => { + it('returns results in input order', async () => { + const out = await mapWithConcurrency([1, 2, 3, 4], 2, async (n) => n * 10) + expect(out).toEqual([10, 20, 30, 40]) + }) + + it('never runs more than `limit` tasks at once', async () => { + let inFlight = 0 + let maxInFlight = 0 + await mapWithConcurrency( + Array.from({ length: 20 }, (_, i) => i), + 4, + async (n) => { + inFlight += 1 + maxInFlight = Math.max(maxInFlight, inFlight) + await tick() + inFlight -= 1 + return n + } + ) + expect(maxInFlight).toBeLessThanOrEqual(4) + expect(maxInFlight).toBeGreaterThan(1) + }) + + it('handles empty input', async () => { + expect(await mapWithConcurrency([], 5, async (n) => n)).toEqual([]) + }) +}) + +describe('concurrencyLadder', () => { + it('default 10 -> [10, 3, 1]', () => { + expect(concurrencyLadder(10)).toEqual([10, 3, 1]) + }) + it('collapses when configured is already small', () => { + expect(concurrencyLadder(5)).toEqual([5, 3, 1]) + expect(concurrencyLadder(3)).toEqual([3, 1]) + expect(concurrencyLadder(2)).toEqual([2, 1]) + expect(concurrencyLadder(1)).toEqual([1]) + }) + it('clamps invalid input to at least 1', () => { + expect(concurrencyLadder(0)).toEqual([1]) + expect(concurrencyLadder(-4)).toEqual([1]) + }) +}) + +describe('runWithConcurrencyFallback', () => { + it('returns all successes when nothing fails (single pass)', async () => { + const levels: number[] = [] + const out = await runWithConcurrencyFallback(['a', 'b', 'c'], concurrencyLadder(10), async (id) => { + levels.push(0) + return { ok: true, result: id.toUpperCase() } as AttemptResult + }) + expect(out).toEqual([ + { id: 'a', ok: true, result: 'A' }, + { id: 'b', ok: true, result: 'B' }, + { id: 'c', ok: true, result: 'C' }, + ]) + // each id attempted exactly once (no retry pass) + expect(levels.length).toBe(3) + }) + + it('retries only the failed ids at the next level and keeps earlier successes', async () => { + const attemptsById: Record = {} + // 'b' fails on the first pass (level 10) but succeeds on the retry (level 3) + const out = await runWithConcurrencyFallback(['a', 'b', 'c'], concurrencyLadder(10), async (id) => { + attemptsById[id] = (attemptsById[id] ?? 0) + 1 + if (id === 'b' && attemptsById.b === 1) return { ok: false, error: 'rate limited' } + return { ok: true, result: `${id}:${attemptsById[id]}` } + }) + expect(attemptsById).toEqual({ a: 1, b: 2, c: 1 }) // only b retried + expect(out.find((r) => r.id === 'b')).toEqual({ id: 'b', ok: true, result: 'b:2' }) + expect(out.every((r) => r.ok)).toBe(true) + }) + + it('reports permanent failures after exhausting the ladder, with the last error', async () => { + const attempts: Record = {} + const out = await runWithConcurrencyFallback(['x'], concurrencyLadder(10), async (id) => { + attempts[id] = (attempts[id] ?? 0) + 1 + return { ok: false, error: `fail#${attempts[id]}` } + }) + expect(attempts.x).toBe(3) // 10 -> 3 -> 1, three passes + expect(out).toEqual([{ id: 'x', ok: false, error: 'fail#3' }]) + }) +}) diff --git a/src/renderer/lib/concurrency.ts b/src/renderer/lib/concurrency.ts new file mode 100644 index 0000000000..99c51f1c07 --- /dev/null +++ b/src/renderer/lib/concurrency.ts @@ -0,0 +1,82 @@ +// Small dependency-free concurrency helpers used by the AI-manager bulk summary tool. + +/** + * Map over `items` running at most `limit` async calls concurrently. + * Results are returned in input order. Never rejects on its own — `fn` should + * resolve (not throw) for per-item failures it wants to handle. + */ +export async function mapWithConcurrency( + items: T[], + limit: number, + fn: (item: T, index: number) => Promise +): Promise { + const results = new Array(items.length) + if (items.length === 0) return results + const workerCount = Math.max(1, Math.min(Math.floor(limit) || 1, items.length)) + let cursor = 0 + const worker = async () => { + while (true) { + const index = cursor + cursor += 1 + if (index >= items.length) return + results[index] = await fn(items[index], index) + } + } + await Promise.all(Array.from({ length: workerCount }, () => worker())) + return results +} + +/** + * Build a strictly-decreasing concurrency ladder from `configured` down through + * the fallback levels (3, then 1). Default 10 -> [10, 3, 1]; e.g. 3 -> [3, 1]; + * 1 -> [1]. Each level is a separate retry pass over the still-failing items. + */ +export function concurrencyLadder(configured: number, fallbacks: number[] = [3, 1]): number[] { + const start = Math.max(1, Math.floor(configured) || 1) + return [start, ...fallbacks].reduce((acc, n) => { + const last = acc[acc.length - 1] + if (last === undefined || n < last) acc.push(Math.max(1, Math.floor(n))) + return acc + }, []) +} + +export interface AttemptResult { + ok: boolean + result?: R + error?: string +} + +/** + * Run `attempt` over `ids` at the first concurrency level; items that fail are + * retried at each subsequent (lower) level. Successes from earlier passes are + * kept. Returns one entry per id, in the original order. + */ +export async function runWithConcurrencyFallback( + ids: string[], + levels: number[], + attempt: (id: string) => Promise> +): Promise>> { + const successById = new Map>() + const lastErrorById = new Map() + let pending = [...ids] + for (const level of levels) { + if (pending.length === 0) break + const wave = await mapWithConcurrency(pending, level, (id) => attempt(id)) + const stillPending: string[] = [] + pending.forEach((id, i) => { + const r = wave[i] + if (r.ok) { + successById.set(id, r) + lastErrorById.delete(id) + } else { + stillPending.push(id) + lastErrorById.set(id, r.error ?? 'unknown error') + } + }) + pending = stillPending + } + return ids.map((id) => { + const ok = successById.get(id) + return ok ? { id, ...ok } : { id, ok: false, error: lastErrorById.get(id) ?? 'failed' } + }) +} diff --git a/src/renderer/lib/export-helpers.test.ts b/src/renderer/lib/export-helpers.test.ts new file mode 100644 index 0000000000..944a653e3a --- /dev/null +++ b/src/renderer/lib/export-helpers.test.ts @@ -0,0 +1,192 @@ +import type { SessionGroup, SessionMeta } from '@shared/types' +import { describe, expect, it } from 'vitest' +import { + UNASSIGNED_ID, + deriveInitialSelection, + filterGroupsForExport, + filterSessionsForExport, +} from './export-helpers' + +const makeGroup = (id: string, parentId: string | null = null, sortIndex = 0): SessionGroup => ({ + id, + name: id, + parentId, + sortIndex, + createdAt: 0, + updatedAt: 0, +}) + +const makeSession = (id: string, groupId?: string, extra: Partial = {}): SessionMeta => + ({ + id, + name: id, + type: 'chat', + groupId, + ...extra, + }) as SessionMeta + +describe('filterGroupsForExport / filterSessionsForExport', () => { + it('case 1: full selection keeps every group and every session, preserves groupId', () => { + const groups = [makeGroup('A'), makeGroup('B')] + const sessions = [makeSession('s1', 'A'), makeSession('s2', 'B'), makeSession('s3')] + + const selectedGroupIds = new Set(['A', 'B', UNASSIGNED_ID]) + const selectedSessionIds = new Set(['s1', 's2', 's3']) + + const filteredGroups = filterGroupsForExport(groups, selectedGroupIds) + expect(filteredGroups.map((g) => g.id)).toEqual(['A', 'B']) + + const retainedIds = new Set(filteredGroups.map((g) => g.id)) + const filteredSessions = filterSessionsForExport(sessions, selectedSessionIds, retainedIds) + expect(filteredSessions.map((s) => s.id)).toEqual(['s1', 's2', 's3']) + expect(filteredSessions.map((s) => s.groupId)).toEqual(['A', 'B', undefined]) + }) + + it('case 2: deselecting a single session drops it but leaves groups intact', () => { + const groups = [makeGroup('A'), makeGroup('B')] + const sessions = [makeSession('s1', 'A'), makeSession('s2', 'B'), makeSession('s3')] + + const selectedGroupIds = new Set(['A', 'B', UNASSIGNED_ID]) + const selectedSessionIds = new Set(['s2', 's3']) + + const filteredGroups = filterGroupsForExport(groups, selectedGroupIds) + expect(filteredGroups.map((g) => g.id)).toEqual(['A', 'B']) + + const retainedIds = new Set(filteredGroups.map((g) => g.id)) + const filteredSessions = filterSessionsForExport(sessions, selectedSessionIds, retainedIds) + expect(filteredSessions.map((s) => s.id)).toEqual(['s2', 's3']) + }) + + it('case 3: deselecting an entire group narrows both sets', () => { + const groups = [makeGroup('A'), makeGroup('B')] + const sessions = [makeSession('s1', 'A'), makeSession('s2', 'B')] + + const selectedGroupIds = new Set(['A']) + const selectedSessionIds = new Set(['s1']) + + const filteredGroups = filterGroupsForExport(groups, selectedGroupIds) + expect(filteredGroups.map((g) => g.id)).toEqual(['A']) + + const retainedIds = new Set(filteredGroups.map((g) => g.id)) + const filteredSessions = filterSessionsForExport(sessions, selectedSessionIds, retainedIds) + expect(filteredSessions.map((s) => s.id)).toEqual(['s1']) + expect(filteredSessions[0]?.groupId).toBe('A') + }) + + it('case 4: parent chain is preserved when only the child group is selected', () => { + const groups = [makeGroup('Parent'), makeGroup('Child', 'Parent')] + const selectedGroupIds = new Set(['Child']) + + const filteredGroups = filterGroupsForExport(groups, selectedGroupIds) + expect(filteredGroups.map((g) => g.id)).toEqual(['Parent', 'Child']) + }) + + it('case 5: cross-group session subset keeps original groupId values', () => { + const groups = [makeGroup('A'), makeGroup('B')] + const sessions = [makeSession('s1', 'A'), makeSession('s2', 'A'), makeSession('s3', 'B')] + + const selectedGroupIds = new Set(['A', 'B']) + const selectedSessionIds = new Set(['s2', 's3']) + + const filteredGroups = filterGroupsForExport(groups, selectedGroupIds) + const retainedIds = new Set(filteredGroups.map((g) => g.id)) + const filteredSessions = filterSessionsForExport(sessions, selectedSessionIds, retainedIds) + + expect(filteredSessions).toHaveLength(2) + expect(filteredSessions.find((s) => s.id === 's2')?.groupId).toBe('A') + expect(filteredSessions.find((s) => s.id === 's3')?.groupId).toBe('B') + }) + + it('case 6: unassigned sessions stay unassigned', () => { + const groups: SessionGroup[] = [] + const sessions = [makeSession('s1'), makeSession('s2'), makeSession('s3')] + + const selectedGroupIds = new Set([UNASSIGNED_ID]) + const selectedSessionIds = new Set(['s1', 's2', 's3']) + + const filteredGroups = filterGroupsForExport(groups, selectedGroupIds) + expect(filteredGroups).toEqual([]) + + const retainedIds = new Set(filteredGroups.map((g) => g.id)) + const filteredSessions = filterSessionsForExport(sessions, selectedSessionIds, retainedIds) + expect(filteredSessions).toHaveLength(3) + expect(filteredSessions.every((s) => s.groupId === undefined)).toBe(true) + }) + + it('case 7: orphaned session has its dangling groupId cleared', () => { + const groups = [makeGroup('A')] + const sessions = [makeSession('s1', 'A')] + + const selectedGroupIds = new Set() + const selectedSessionIds = new Set(['s1']) + + const filteredGroups = filterGroupsForExport(groups, selectedGroupIds) + expect(filteredGroups).toEqual([]) + + const retainedIds = new Set(filteredGroups.map((g) => g.id)) + const filteredSessions = filterSessionsForExport(sessions, selectedSessionIds, retainedIds) + expect(filteredSessions).toHaveLength(1) + expect(filteredSessions[0]?.groupId).toBeUndefined() + expect(filteredSessions[0]?.id).toBe('s1') + }) + + it('case 8: empty datasets never crash', () => { + expect(filterGroupsForExport([], new Set())).toEqual([]) + expect(filterGroupsForExport([], new Set(['anything']))).toEqual([]) + expect(filterSessionsForExport([], new Set(), new Set())).toEqual([]) + expect(filterSessionsForExport([], new Set(['x']), new Set(['y']))).toEqual([]) + }) + + it('does not mutate input arrays', () => { + const groups = [makeGroup('A'), makeGroup('B')] + const sessions = [makeSession('s1', 'A')] + const groupsSnapshot = JSON.stringify(groups) + const sessionsSnapshot = JSON.stringify(sessions) + + filterGroupsForExport(groups, new Set(['A'])) + filterSessionsForExport(sessions, new Set(['s1']), new Set()) + + expect(JSON.stringify(groups)).toBe(groupsSnapshot) + expect(JSON.stringify(sessions)).toBe(sessionsSnapshot) + }) + + it('parent chain walk handles cyclic parentId references defensively', () => { + const cyclic = [ + { ...makeGroup('A'), parentId: 'B' }, + { ...makeGroup('B'), parentId: 'A' }, + ] + const filtered = filterGroupsForExport(cyclic, new Set(['A'])) + expect(filtered.map((g) => g.id).sort()).toEqual(['A', 'B']) + }) +}) + +describe('deriveInitialSelection', () => { + it('groups + grouped sessions, no orphans → no UNASSIGNED_ID', () => { + const groups = [makeGroup('A'), makeGroup('B')] + const sessions = [makeSession('s1', 'A'), makeSession('s2', 'B')] + const { groupIds, sessionIds } = deriveInitialSelection(groups, sessions) + expect([...groupIds].sort()).toEqual(['A', 'B']) + expect(groupIds.has(UNASSIGNED_ID)).toBe(false) + expect([...sessionIds].sort()).toEqual(['s1', 's2']) + }) + + it('orphan sessions cause UNASSIGNED_ID to be included', () => { + const groups = [makeGroup('A'), makeGroup('B')] + const sessions = [makeSession('s1', 'A'), makeSession('s2'), makeSession('s3', 'B')] + const { groupIds, sessionIds } = deriveInitialSelection(groups, sessions) + expect(groupIds.has(UNASSIGNED_ID)).toBe(true) + expect([...groupIds].sort()).toEqual(['A', 'B', UNASSIGNED_ID].sort()) + expect([...sessionIds].sort()).toEqual(['s1', 's2', 's3']) + }) + + it('hidden and system sessions are excluded from sessionIds', () => { + const groups: SessionGroup[] = [] + const sessions = [ + makeSession('s1'), + makeSession('s2', undefined, { hidden: true }), + makeSession('s3', undefined, { system: true }), + ] + const { sessionIds } = deriveInitialSelection(groups, sessions) + expect([...sessionIds]).toEqual(['s1']) + }) +}) diff --git a/src/renderer/lib/export-helpers.ts b/src/renderer/lib/export-helpers.ts new file mode 100644 index 0000000000..9f5bb2fedd --- /dev/null +++ b/src/renderer/lib/export-helpers.ts @@ -0,0 +1,122 @@ +import type { SessionGroup, SessionMeta } from '@shared/types' + +export const UNASSIGNED_ID = '__unassigned__' + +/** + * Filter groups for export based on user selection, preserving the parent chain + * of every kept group so dangling parentId references cannot occur in the export. + * + * The reserved {@link UNASSIGNED_ID} sentinel is ignored here — it is not a real + * group id and only appears in selection state for the unassigned bucket. + * + * Returned array keeps the original order from {@link allGroups} (stable). + */ +export function filterGroupsForExport( + allGroups: SessionGroup[], + selectedGroupIds: Set, +): SessionGroup[] { + if (allGroups.length === 0) { + return [] + } + + const byId = new Map() + for (const g of allGroups) { + byId.set(g.id, g) + } + + const retained = new Set() + const visited = new Set() + + for (const group of allGroups) { + if (group.id === UNASSIGNED_ID) { + continue + } + if (!selectedGroupIds.has(group.id)) { + continue + } + let cursor: SessionGroup | undefined = group + while (cursor && !visited.has(cursor.id)) { + visited.add(cursor.id) + retained.add(cursor.id) + const parentId = cursor.parentId + if (parentId === null || parentId === undefined) { + break + } + cursor = byId.get(parentId) + } + } + + const result: SessionGroup[] = [] + for (const g of allGroups) { + if (retained.has(g.id)) { + result.push(g) + } + } + return result +} + +/** + * Filter sessions for export, clearing groupId on any session whose group was + * deselected by the user (the session itself was kept). Such sessions land in + * the unassigned bucket on the import side. + * + * @param retainedGroupIds The id set of groups returned by + * {@link filterGroupsForExport}; required so we can tell which groupId values + * are still valid post-filter. + */ +export function filterSessionsForExport( + allMetas: SessionMeta[], + selectedSessionIds: Set, + retainedGroupIds: Set, +): SessionMeta[] { + const result: SessionMeta[] = [] + for (const meta of allMetas) { + if (!selectedSessionIds.has(meta.id)) { + continue + } + if (meta.groupId !== undefined && !retainedGroupIds.has(meta.groupId)) { + const cleared = { ...meta } + cleared.groupId = undefined + result.push(cleared) + } else { + result.push({ ...meta }) + } + } + return result +} + +/** + * Compute the default selection state for the export UI: every group + every + * non-system, non-hidden session is selected. The {@link UNASSIGNED_ID} + * sentinel is added when at least one orphan session exists. + */ +export function deriveInitialSelection( + groups: SessionGroup[], + sessions: SessionMeta[], +): { groupIds: Set; sessionIds: Set } { + const groupIds = new Set() + for (const g of groups) { + groupIds.add(g.id) + } + + const sessionIds = new Set() + let hasOrphan = false + for (const s of sessions) { + if (s.groupId === undefined || s.groupId === null) { + hasOrphan = true + } + if (s.hidden === true) { + continue + } + if (s.system === true) { + continue + } + sessionIds.add(s.id) + } + + if (hasOrphan) { + groupIds.add(UNASSIGNED_ID) + } + + return { groupIds, sessionIds } +} diff --git a/src/renderer/modals/ConfirmDangerousAction.tsx b/src/renderer/modals/ConfirmDangerousAction.tsx new file mode 100644 index 0000000000..2fee3f85db --- /dev/null +++ b/src/renderer/modals/ConfirmDangerousAction.tsx @@ -0,0 +1,70 @@ +import NiceModal, { useModal } from '@ebay/nice-modal-react' +import { Button, Stack, Text } from '@mantine/core' +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { AdaptiveModal } from '@/components/common/AdaptiveModal' + +interface Props { + type: string + description: string +} + +const COUNTDOWN_SECONDS = 5 +const ENABLE_AFTER_SECONDS = 1 + +const ConfirmDangerousAction = NiceModal.create(({ description }: Props) => { + const modal = useModal() + const { t } = useTranslation() + const [secondsLeft, setSecondsLeft] = useState(COUNTDOWN_SECONDS) + + useEffect(() => { + if (!modal.visible) return + setSecondsLeft(COUNTDOWN_SECONDS) + const timer = setInterval(() => { + setSecondsLeft((prev) => (prev > 0 ? prev - 1 : 0)) + }, 1000) + return () => clearInterval(timer) + }, [modal.visible]) + + const onCancel = () => { + modal.resolve(false) + modal.hide() + } + + const onConfirm = () => { + modal.resolve(true) + modal.hide() + } + + const elapsed = COUNTDOWN_SECONDS - secondsLeft + const disabled = elapsed < ENABLE_AFTER_SECONDS + const confirmLabel = secondsLeft > 0 ? t('Confirm ({{seconds}})', { seconds: secondsLeft }) : t('Confirm') + + return ( + + + {description} + + {t('This action cannot be undone.')} + + + + + + + + + ) +}) + +export default ConfirmDangerousAction diff --git a/src/renderer/modals/CreateGroup.tsx b/src/renderer/modals/CreateGroup.tsx new file mode 100644 index 0000000000..e02189dcdd --- /dev/null +++ b/src/renderer/modals/CreateGroup.tsx @@ -0,0 +1,70 @@ +import NiceModal, { useModal } from '@ebay/nice-modal-react' +import { Button, Flex, Stack, TextInput } from '@mantine/core' +import { type KeyboardEvent, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { AdaptiveModal } from '@/components/common/AdaptiveModal' +import { createGroup } from '@/stores/groupStore' +import { add as addToast } from '@/stores/toastActions' + +const CreateGroup = NiceModal.create(({ parentId = null }: { parentId?: string | null } = {}) => { + const modal = useModal() + const { t } = useTranslation() + const [name, setName] = useState('') + const [submitting, setSubmitting] = useState(false) + + const onClose = () => { + modal.resolve() + modal.hide() + } + + const handleConfirm = async () => { + const trimmed = name.trim() + if (!trimmed || submitting) return + setSubmitting(true) + try { + await createGroup({ name: trimmed, parentId }) + onClose() + } catch (error) { + addToast(error instanceof Error ? error.message : String(error)) + } finally { + setSubmitting(false) + } + } + + const handleKey = (e: KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault() + void handleConfirm() + } + } + + return ( + + + setName(e.currentTarget.value)} + onKeyDown={handleKey} + /> + + + + + + + ) +}) + +export default CreateGroup diff --git a/src/renderer/modals/MoveSessionToGroup.tsx b/src/renderer/modals/MoveSessionToGroup.tsx new file mode 100644 index 0000000000..7cc97c978c --- /dev/null +++ b/src/renderer/modals/MoveSessionToGroup.tsx @@ -0,0 +1,262 @@ +import NiceModal, { useModal } from '@ebay/nice-modal-react' +import { ActionIcon, Button, Flex, Stack, Text, TextInput } from '@mantine/core' +import { IconCheck, IconFolder, IconFolderPlus, IconInbox, IconX } from '@tabler/icons-react' +import { useQuery } from '@tanstack/react-query' +import { Fragment, type KeyboardEvent, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { AdaptiveModal } from '@/components/common/AdaptiveModal' +import { getMetaStorage } from '@/stores/chatStore' +import { createGroup, useGroups } from '@/stores/groupStore' +import { moveSessionToGroup } from '@/stores/sessionActions' +import { add as addToast } from '@/stores/toastActions' +import { buildGroupTree, type GroupTreeNode } from '@/utils/group-tree' + +interface Props { + sessionId: string +} + +const SEARCH_THRESHOLD = 10 +const INDENT_PX = 18 + +const MoveSessionToGroup = NiceModal.create(({ sessionId }: Props) => { + const modal = useModal() + const { t } = useTranslation() + const { groups } = useGroups() + // Read the session's current group straight from the meta store — the global session list query + // may not be populated in the file-explorer view, so it can't be relied on for the checkmark. + const { data: currentGroupId = null } = useQuery({ + queryKey: ['move-modal-session-group', sessionId], + queryFn: async () => (await (await getMetaStorage()).getById(sessionId))?.groupId ?? null, + staleTime: 0, + }) + + const [creating, setCreating] = useState(false) + const [newGroupName, setNewGroupName] = useState('') + const [submitting, setSubmitting] = useState(false) + const [query, setQuery] = useState('') + + const resetState = () => { + setSubmitting(false) + setCreating(false) + setNewGroupName('') + setQuery('') + } + + const onClose = () => { + resetState() + modal.resolve() + modal.hide() + } + + const tree = useMemo(() => buildGroupTree(groups ?? []), [groups]) + const q = query.trim().toLowerCase() + const searchMatches = useMemo( + () => (q ? (groups ?? []).filter((g) => g.name.toLowerCase().includes(q)) : []), + [groups, q] + ) + const showSearch = (groups?.length ?? 0) > SEARCH_THRESHOLD + + const doMove = async (targetGroupId: string | null) => { + if (submitting) return + setSubmitting(true) + try { + await moveSessionToGroup(sessionId, targetGroupId) + resetState() + modal.resolve(targetGroupId) + modal.hide() + } catch (error) { + console.error('Failed to move session:', error) + addToast(t('Failed to move session')) + setSubmitting(false) + } + } + + const confirmCreate = async () => { + const name = newGroupName.trim() + if (!name || submitting) return + setSubmitting(true) + try { + const group = await createGroup({ name }) + await moveSessionToGroup(sessionId, group.id) + resetState() + modal.resolve(group.id) + modal.hide() + } catch (error) { + console.error('Failed to create group:', error) + addToast(t('Failed to move session')) + setSubmitting(false) + } + } + + const cancelCreate = () => { + setCreating(false) + setNewGroupName('') + } + + const handleCreateKey = (e: KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault() + void confirmCreate() + } else if (e.key === 'Escape') { + e.preventDefault() + cancelCreate() + } + } + + const renderRow = ( + key: string, + label: string, + icon: React.ReactNode, + selected: boolean, + onClick: () => void, + depth = 1 + ) => ( + + {icon} + + {label} + + {selected && } + + ) + + const renderNode = (node: GroupTreeNode): React.ReactNode => ( + + {renderRow( + node.group.id, + node.group.name, + , + currentGroupId === node.group.id, + () => void doMove(node.group.id), + node.depth + )} + {node.children.map(renderNode)} + + ) + + return ( + + + {showSearch && ( + setQuery(e.currentTarget.value)} + /> + )} + +
+ + {renderRow( + '__unassigned__', + t('Unassigned'), + , + currentGroupId === null, + () => void doMove(null), + 1 + )} + + {q ? ( + searchMatches.length > 0 ? ( + searchMatches.map((g) => + renderRow( + g.id, + g.name, + , + currentGroupId === g.id, + () => void doMove(g.id), + 1 + ) + ) + ) : ( + + {t('No groups found')} + + ) + ) : ( + tree.map(renderNode) + )} + + {creating ? ( + + + setNewGroupName(e.currentTarget.value)} + onKeyDown={handleCreateKey} + disabled={submitting} + /> + void confirmCreate()} + disabled={submitting || !newGroupName.trim()} + aria-label={t('Confirm') ?? 'Confirm'} + > + + + + + + + ) : ( + setCreating(true)} + > + + + {t('Create new group…')} + + + )} + +
+
+ + + + +
+ ) +}) + +export default MoveSessionToGroup diff --git a/src/renderer/modals/SessionSettings.tsx b/src/renderer/modals/SessionSettings.tsx index 2a83a69ca9..38e79f1e16 100644 --- a/src/renderer/modals/SessionSettings.tsx +++ b/src/renderer/modals/SessionSettings.tsx @@ -136,6 +136,7 @@ const SessionSettingsModal = NiceModal.create( ...(s ?? {}), ...getSessionMeta(editingData), settings: editingData.settings, + summaryPrompt: editingData.summaryPrompt, } as Session return applySessionChanges(merged) @@ -244,6 +245,27 @@ const SessionSettingsModal = NiceModal.create( }} /> + {isChatSession(session) && ( +