Skip to content

Commit b2066dc

Browse files
committed
refactor(web): extract group sidebar sortable list and optimize chunk splitting
1 parent b33473c commit b2066dc

6 files changed

Lines changed: 435 additions & 103 deletions

File tree

web/src/components/SettingsModal.tsx

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ export function SettingsModal({
130130
const [imBusy, setImBusy] = useState(false);
131131
const imLoadSeq = useRef(0);
132132
const weixinAutoStartRef = useRef(false);
133+
const contentScrollRef = useRef<HTMLDivElement | null>(null);
133134

134135
// IM config drafts cache (per-platform local edits, not yet saved to server)
135136
const [imConfigDrafts, setImConfigDrafts] = useState<Partial<Record<IMPlatform, IMConfigDraft>>>({});
@@ -910,19 +911,6 @@ export function SettingsModal({
910911
}
911912
}, [globalTab, globalTabs, scope]);
912913

913-
// ============ Render ============
914-
915-
if (!isOpen) return null;
916-
917-
const scopeRootUrl = (() => {
918-
if (!groupDoc || String(groupDoc.group_id || "") !== String(groupId || "")) return "";
919-
const scopes = Array.isArray(groupDoc.scopes) ? groupDoc.scopes : [];
920-
const activeKey = String(groupDoc.active_scope_key || "");
921-
const active = scopes.find((s) => String(s?.scope_key || "") === activeKey && String(s?.url || "").trim());
922-
const first = scopes.find((s) => String(s?.url || "").trim());
923-
return String((active || first)?.url || "").trim();
924-
})();
925-
926914
const groupTabs: { id: GroupTabId; label: string }[] = [
927915
{ id: "guidance", label: t("tabs.guidance") },
928916
{ id: "automation", label: t("tabs.automation") },
@@ -940,6 +928,27 @@ export function SettingsModal({
940928
else setGlobalTab(tab as GlobalTabId);
941929
};
942930

931+
useEffect(() => {
932+
const el = contentScrollRef.current;
933+
if (!isOpen || !el) return;
934+
requestAnimationFrame(() => {
935+
el.scrollTo({ top: 0, behavior: "auto" });
936+
});
937+
}, [isOpen, scope, activeTab]);
938+
939+
// ============ Render ============
940+
941+
if (!isOpen) return null;
942+
943+
const scopeRootUrl = (() => {
944+
if (!groupDoc || String(groupDoc.group_id || "") !== String(groupId || "")) return "";
945+
const scopes = Array.isArray(groupDoc.scopes) ? groupDoc.scopes : [];
946+
const activeKey = String(groupDoc.active_scope_key || "");
947+
const active = scopes.find((s) => String(s?.scope_key || "") === activeKey && String(s?.url || "").trim());
948+
const first = scopes.find((s) => String(s?.url || "").trim());
949+
return String((active || first)?.url || "").trim();
950+
})();
951+
943952
return (
944953
<ModalFrame
945954
isDark={isDark}
@@ -964,7 +973,10 @@ export function SettingsModal({
964973
/>
965974

966975
{/* Main Content Area */}
967-
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-subtle flex flex-col [scrollbar-gutter:stable]">
976+
<div
977+
ref={contentScrollRef}
978+
className="min-h-0 flex-1 overflow-y-auto scrollbar-subtle flex flex-col [scrollbar-gutter:stable]"
979+
>
968980
<div className="p-5 pb-8 sm:p-8 sm:pb-10 space-y-6">
969981
{scope === "global" && !globalSettingsEnabled && !currentBrowserSignedIn ? (
970982
<div className={`rounded-xl border p-6 ${isDark ? "border-amber-700/40 bg-amber-900/10 text-amber-200" : "border-amber-200 bg-amber-50 text-amber-800"}`}>

web/src/components/app/AppShell.tsx

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { CSSProperties } from "react";
1+
import { useEffect, type CSSProperties } from "react";
22
import { ErrorBoundary } from "../ErrorBoundary";
33
import { TabBar } from "../TabBar";
44
import { AppHeader } from "../layout/AppHeader";
@@ -153,6 +153,46 @@ export function AppShell({
153153
"--sidebar-width": `${sidebarCollapsed ? SIDEBAR_COLLAPSED_WIDTH : sidebarWidth}px`,
154154
} as CSSProperties;
155155

156+
useEffect(() => {
157+
if (!selectedGroupId || runtimeActors.length === 0) return;
158+
if (typeof window === "undefined") return;
159+
160+
const nav = navigator as Navigator & {
161+
connection?: {
162+
saveData?: boolean;
163+
effectiveType?: string;
164+
};
165+
};
166+
const connection = nav.connection;
167+
if (connection?.saveData) return;
168+
if (typeof connection?.effectiveType === "string" && /(^|-)2g$/.test(connection.effectiveType)) return;
169+
170+
let cancelled = false;
171+
let timeoutId: ReturnType<typeof globalThis.setTimeout> | null = null;
172+
let idleId: number | null = null;
173+
const preloadActorTab = () => {
174+
void import("../AgentTab").then(() => {
175+
if (cancelled) return;
176+
});
177+
};
178+
179+
if ("requestIdleCallback" in window) {
180+
idleId = window.requestIdleCallback(() => preloadActorTab(), { timeout: 1500 });
181+
} else {
182+
timeoutId = globalThis.setTimeout(() => preloadActorTab(), 600);
183+
}
184+
185+
return () => {
186+
cancelled = true;
187+
if (idleId !== null && "cancelIdleCallback" in window) {
188+
window.cancelIdleCallback(idleId);
189+
}
190+
if (timeoutId !== null) {
191+
globalThis.clearTimeout(timeoutId);
192+
}
193+
};
194+
}, [selectedGroupId, runtimeActors.length]);
195+
156196
return (
157197
<div
158198
className="relative h-full min-h-0 transition-[grid-template-columns] duration-300 ease-out md:grid md:[grid-template-columns:var(--sidebar-width)_minmax(0,1fr)]"

web/src/components/layout/GroupSidebar.tsx

Lines changed: 105 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,16 @@
1-
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
1+
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
22
import { useTranslation } from 'react-i18next';
3-
import {
4-
DndContext,
5-
closestCenter,
6-
KeyboardSensor,
7-
PointerSensor,
8-
TouchSensor,
9-
useSensor,
10-
useSensors,
11-
DragEndEvent,
12-
} from "@dnd-kit/core";
13-
import {
14-
SortableContext,
15-
sortableKeyboardCoordinates,
16-
verticalListSortingStrategy,
17-
} from "@dnd-kit/sortable";
183
import { GroupMeta } from "../../types";
194
import { classNames } from "../../utils/classNames";
205
import { CloseIcon, FolderIcon, ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, PlusIcon } from "../Icons";
21-
import { SortableGroupItem } from "./SortableGroupItem";
6+
import { GroupSidebarItem } from "./GroupSidebarItem";
227
import { SIDEBAR_MAX_WIDTH, SIDEBAR_MIN_WIDTH } from "../../stores/useUIStore";
238
import { useBrandingStore } from "../../stores";
249

10+
const GroupSidebarSortableList = lazy(() =>
11+
import("./GroupSidebarSortableList").then((module) => ({ default: module.GroupSidebarSortableList }))
12+
);
13+
2514
export interface GroupSidebarProps {
2615
orderedGroups: GroupMeta[];
2716
archivedGroupIds: string[];
@@ -65,6 +54,7 @@ export function GroupSidebar({
6554
const branding = useBrandingStore((s) => s.branding);
6655
const dragStateRef = useRef<{ startX: number; startWidth: number } | null>(null);
6756
const [isResizing, setIsResizing] = useState(false);
57+
const [sortableReady, setSortableReady] = useState(false);
6858
const archivedSet = useMemo(() => new Set(archivedGroupIds), [archivedGroupIds]);
6959
const workingGroups = useMemo(
7060
() => orderedGroups.filter((g) => !archivedSet.has(String(g.group_id || "").trim())),
@@ -91,37 +81,6 @@ export function GroupSidebar({
9181
const autoArchivedOpen = selectedArchived || (orderedGroups.length > 0 && workingGroups.length === 0 && archivedGroups.length > 0);
9282
const archivedPanelOpen = archivedOpen || autoArchivedOpen;
9383

94-
const sensors = useSensors(
95-
useSensor(PointerSensor, {
96-
activationConstraint: {
97-
distance: 8,
98-
},
99-
}),
100-
useSensor(TouchSensor, {
101-
activationConstraint: {
102-
delay: 200,
103-
tolerance: 5,
104-
},
105-
}),
106-
useSensor(KeyboardSensor, {
107-
coordinateGetter: sortableKeyboardCoordinates,
108-
})
109-
);
110-
111-
const handleDragEnd = useCallback(
112-
(section: "working" | "archived", groups: GroupMeta[]) => (event: DragEndEvent) => {
113-
const { active, over } = event;
114-
if (!over || active.id === over.id) return;
115-
const ids = groups.map((g) => String(g.group_id || ""));
116-
const oldIndex = ids.indexOf(String(active.id));
117-
const newIndex = ids.indexOf(String(over.id));
118-
if (oldIndex !== -1 && newIndex !== -1) {
119-
onReorderSection(section, oldIndex, newIndex);
120-
}
121-
},
122-
[onReorderSection]
123-
);
124-
12584
useEffect(() => {
12685
if (!isResizing) return undefined;
12786

@@ -149,6 +108,37 @@ export function GroupSidebar({
149108
};
150109
}, [isResizing, onResizeWidth]);
151110

111+
useEffect(() => {
112+
if (sortableReady) return;
113+
if (typeof window === "undefined") return;
114+
if (!window.matchMedia("(min-width: 768px)").matches) return;
115+
116+
let cancelled = false;
117+
let timeoutId: ReturnType<typeof globalThis.setTimeout> | null = null;
118+
let idleId: number | null = null;
119+
const loadSortableList = () => {
120+
void import("./GroupSidebarSortableList").then(() => {
121+
if (!cancelled) setSortableReady(true);
122+
});
123+
};
124+
125+
if ("requestIdleCallback" in window) {
126+
idleId = window.requestIdleCallback(() => loadSortableList(), { timeout: 1200 });
127+
} else {
128+
timeoutId = globalThis.setTimeout(() => loadSortableList(), 400);
129+
}
130+
131+
return () => {
132+
cancelled = true;
133+
if (idleId !== null && "cancelIdleCallback" in window) {
134+
window.cancelIdleCallback(idleId);
135+
}
136+
if (timeoutId !== null) {
137+
globalThis.clearTimeout(timeoutId);
138+
}
139+
};
140+
}, [sortableReady]);
141+
152142
const handleResizeStart = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
153143
if (isCollapsed) return;
154144
event.preventDefault();
@@ -164,67 +154,96 @@ export function GroupSidebar({
164154

165155
const renderGroupList = useCallback(
166156
(groups: GroupMeta[], section: "working" | "archived") => {
167-
const sortableIds = groups.map((g) => String(g.group_id || ""));
168157
const isArchivedSection = section === "archived";
169-
return (
170-
<DndContext
171-
sensors={sensors}
172-
collisionDetection={closestCenter}
173-
onDragEnd={handleDragEnd(section, groups)}
174-
>
175-
<SortableContext
176-
items={sortableIds}
177-
strategy={verticalListSortingStrategy}
178-
>
179-
<div className={classNames(
180-
isCollapsed ? "flex flex-col items-center gap-2" : "space-y-1"
181-
)}>
158+
const menuActionLabel = isArchivedSection ? t("restoreGroup") : t("archiveGroup");
159+
const handleMenuAction = (gid: string) => {
160+
if (isArchivedSection) {
161+
onRestoreGroup(gid);
162+
return;
163+
}
164+
setArchivedOpen(true);
165+
onArchiveGroup(gid);
166+
};
167+
168+
if (!isCollapsed && !readOnly && sortableReady) {
169+
return (
170+
<Suspense fallback={
171+
<div className="space-y-1">
182172
{groups.map((g) => {
183173
const gid = String(g.group_id || "");
184-
const active = gid === selectedGroupId;
185174
return (
186-
<SortableGroupItem
175+
<GroupSidebarItem
187176
key={gid}
188177
group={g}
189-
isActive={active}
190-
isDark={isDark}
191-
isCollapsed={isCollapsed}
178+
isActive={gid === selectedGroupId}
179+
isCollapsed={false}
192180
isArchived={isArchivedSection}
193-
dragDisabled={!!readOnly}
194-
menuActionLabel={isArchivedSection ? t("restoreGroup") : t("archiveGroup")}
181+
menuActionLabel={menuActionLabel}
195182
menuAriaLabel={`${t("groupActions")} · ${g.title || gid}`}
196-
onMenuAction={
197-
isCollapsed
198-
? undefined
199-
: isArchivedSection
200-
? () => onRestoreGroup(gid)
201-
: () => {
202-
setArchivedOpen(true);
203-
onArchiveGroup(gid);
204-
}
205-
}
183+
onMenuAction={() => handleMenuAction(gid)}
206184
onSelect={() => {
207185
onSelectGroup(gid);
208186
if (window.matchMedia("(max-width: 767px)").matches) onClose();
209187
}}
210-
onWarm={active ? undefined : () => onWarmGroup?.(gid)}
188+
onWarm={gid === selectedGroupId ? undefined : () => onWarmGroup?.(gid)}
211189
/>
212190
);
213191
})}
214192
</div>
215-
</SortableContext>
216-
</DndContext>
193+
}>
194+
<GroupSidebarSortableList
195+
groups={groups}
196+
section={section}
197+
selectedGroupId={selectedGroupId}
198+
isDark={isDark}
199+
isCollapsed={false}
200+
readOnly={readOnly}
201+
menuActionLabel={menuActionLabel}
202+
menuAriaLabel={t("groupActions")}
203+
onMenuAction={handleMenuAction}
204+
onReorderSection={onReorderSection}
205+
onSelectGroup={onSelectGroup}
206+
onWarmGroup={onWarmGroup}
207+
onClose={onClose}
208+
/>
209+
</Suspense>
210+
);
211+
}
212+
213+
return (
214+
<div className={classNames(isCollapsed ? "flex flex-col items-center gap-2" : "space-y-1")}>
215+
{groups.map((g) => {
216+
const gid = String(g.group_id || "");
217+
return (
218+
<GroupSidebarItem
219+
key={gid}
220+
group={g}
221+
isActive={gid === selectedGroupId}
222+
isCollapsed={isCollapsed}
223+
isArchived={isArchivedSection}
224+
menuActionLabel={isCollapsed ? undefined : menuActionLabel}
225+
menuAriaLabel={isCollapsed ? undefined : `${t("groupActions")} · ${g.title || gid}`}
226+
onMenuAction={isCollapsed ? undefined : () => handleMenuAction(gid)}
227+
onSelect={() => {
228+
onSelectGroup(gid);
229+
if (window.matchMedia("(max-width: 767px)").matches) onClose();
230+
}}
231+
onWarm={gid === selectedGroupId ? undefined : () => onWarmGroup?.(gid)}
232+
/>
233+
);
234+
})}
235+
</div>
217236
);
218237
},
219-
[handleDragEnd, isCollapsed, isDark, onArchiveGroup, onClose, onRestoreGroup, onSelectGroup, onWarmGroup, readOnly, selectedGroupId, sensors, t]
238+
[isCollapsed, isDark, onArchiveGroup, onClose, onReorderSection, onRestoreGroup, onSelectGroup, onWarmGroup, readOnly, selectedGroupId, sortableReady, t]
220239
);
221240

222241
return (
223242
<>
224243
<aside
225244
className={classNames(
226-
"h-full flex flex-col glass-sidebar",
227-
"fixed md:relative z-40",
245+
"h-full min-h-0 flex flex-col glass-sidebar",
246+
"fixed inset-y-0 left-0 md:relative md:inset-auto z-40",
228247
isResizing ? "transition-none" : "transition-[width,transform] duration-300 ease-out",
229248
isCollapsed ? "w-[60px]" : "w-[280px] md:w-[var(--sidebar-width)]",
230249
isOpen ? "translate-x-0" : "-translate-x-full",
@@ -335,7 +354,7 @@ export function GroupSidebar({
335354

336355
{/* Group list */}
337356
<div className={classNames(
338-
"flex-1 overflow-auto",
357+
"min-h-0 flex-1 overflow-auto scrollbar-hide",
339358
isCollapsed ? "p-2" : "p-3"
340359
)}>
341360
{!isCollapsed && (

0 commit comments

Comments
 (0)