feat(ap-web): support shift-click range selection in multi-session mode (#1534)
* feat(ap-web): support shift-click range selection in multi-session mode * style: fix prettier formatting for ternary expression * fix(ap-web): use actual rendered project IDs for shift-select ranges Project folders fetch their own sessions via useProjectSessions, which can diverge from the global paginated list. Build the shift-select visible order from each ProjectFolder's rendered data instead of the global sections.projectGroups.
This commit is contained in:
@@ -225,11 +225,28 @@ export function Sidebar({ open, onClose, dragProgress = null }: SidebarProps) {
|
||||
const [selectionMode, setSelectionMode] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggleSelected = useCallback((id: string) => {
|
||||
const lastSelectedIdRef = useRef<string | null>(null);
|
||||
const visibleIdsRef = useRef<string[]>([]);
|
||||
|
||||
const toggleSelected = useCallback((id: string, shiftKey?: boolean) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (shiftKey && lastSelectedIdRef.current != null) {
|
||||
const ids = visibleIdsRef.current;
|
||||
const anchorIdx = ids.indexOf(lastSelectedIdRef.current);
|
||||
const currentIdx = ids.indexOf(id);
|
||||
if (anchorIdx !== -1 && currentIdx !== -1) {
|
||||
const [start, end] =
|
||||
anchorIdx < currentIdx ? [anchorIdx, currentIdx] : [currentIdx, anchorIdx];
|
||||
for (let i = start; i <= end; i++) {
|
||||
next.add(ids[i]);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
}
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
lastSelectedIdRef.current = id;
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
@@ -245,6 +262,7 @@ export function Sidebar({ open, onClose, dragProgress = null }: SidebarProps) {
|
||||
const exitSelectionMode = useCallback(() => {
|
||||
setSelectionMode(false);
|
||||
setSelectedIds(new Set());
|
||||
lastSelectedIdRef.current = null;
|
||||
}, []);
|
||||
|
||||
// Debounce search input so we don't fire a server request on every
|
||||
@@ -544,6 +562,7 @@ export function Sidebar({ open, onClose, dragProgress = null }: SidebarProps) {
|
||||
selectionMode={selectionMode}
|
||||
selectedIds={selectedIds}
|
||||
onToggleSelected={toggleSelected}
|
||||
visibleIdsRef={visibleIdsRef}
|
||||
/>
|
||||
</nav>
|
||||
|
||||
@@ -673,6 +692,7 @@ function ProjectFolder({
|
||||
selectedIds,
|
||||
onToggleSelected,
|
||||
onProjectAssigned,
|
||||
projectRenderedIdsRef,
|
||||
}: {
|
||||
name: string;
|
||||
expanded: boolean;
|
||||
@@ -685,8 +705,9 @@ function ProjectFolder({
|
||||
onTogglePinned: (conversationId: string) => void;
|
||||
selectionMode: boolean;
|
||||
selectedIds: Set<string>;
|
||||
onToggleSelected: (conversationId: string) => void;
|
||||
onToggleSelected: (conversationId: string, shiftKey?: boolean) => void;
|
||||
onProjectAssigned?: (projectName: string) => void;
|
||||
projectRenderedIdsRef?: RefObject<Map<string, string[]>>;
|
||||
}) {
|
||||
const query = useProjectSessions(name, expanded);
|
||||
const pinnedSet = useMemo(() => new Set(pinnedConversationIds), [pinnedConversationIds]);
|
||||
@@ -699,6 +720,15 @@ function ProjectFolder({
|
||||
);
|
||||
}, [query.data, pinnedSet, activeOverride]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectRenderedIdsRef) return;
|
||||
const ids = expanded ? conversations.map((c) => c.id) : [];
|
||||
projectRenderedIdsRef.current.set(name, ids);
|
||||
return () => {
|
||||
projectRenderedIdsRef.current.delete(name);
|
||||
};
|
||||
}, [projectRenderedIdsRef, name, expanded, conversations]);
|
||||
|
||||
// While the first page loads, show a "Loading…" footer instead of the "No
|
||||
// chats" empty state (which would otherwise flash before rows arrive).
|
||||
const loadingFirstPage = expanded && query.isLoading;
|
||||
@@ -774,7 +804,8 @@ interface ConversationListProps {
|
||||
onTogglePinned: (conversationId: string) => void;
|
||||
selectionMode: boolean;
|
||||
selectedIds: Set<string>;
|
||||
onToggleSelected: (conversationId: string) => void;
|
||||
onToggleSelected: (conversationId: string, shiftKey?: boolean) => void;
|
||||
visibleIdsRef: RefObject<string[]>;
|
||||
}
|
||||
|
||||
// permission_level null (no ACL row / legacy) or >= 4 both mean owner.
|
||||
@@ -793,6 +824,7 @@ function ConversationList({
|
||||
selectionMode,
|
||||
selectedIds,
|
||||
onToggleSelected,
|
||||
visibleIdsRef,
|
||||
}: ConversationListProps) {
|
||||
// All loaded conversations from the single paginated list (for pinned
|
||||
// backfill, normalization, and the flat session list).
|
||||
@@ -804,6 +836,10 @@ function ConversationList({
|
||||
// Project names for grouping sessions by their reserved project label.
|
||||
const { data: projectNames = [] } = useProjects();
|
||||
|
||||
// Each ProjectFolder registers its actually-rendered conversation IDs here
|
||||
// so shift-select ranges use the real rendered order, not the global list.
|
||||
const projectRenderedIdsRef = useRef<Map<string, string[]>>(new Map());
|
||||
|
||||
// Backfill pinned sessions that aren't in the loaded set.
|
||||
const loadedIds = useMemo(() => new Set(allConversations.map((c) => c.id)), [allConversations]);
|
||||
const pinnedBackfill = usePinnedConversationBackfill(pinnedConversationIds, loadedIds);
|
||||
@@ -1110,6 +1146,21 @@ function ConversationList({
|
||||
...visible("Shared with me", sections.shared),
|
||||
].map((c) => c.id);
|
||||
}, [sections, effectiveCollapsedSections, expandedProjects]);
|
||||
|
||||
// Build shift-select visible order from actual rendered data: project folders
|
||||
// report their own IDs (from useProjectSessions), so the range is correct even
|
||||
// when the per-project query diverges from the global paginated list.
|
||||
const visible = (title: string, list: readonly Conversation[]) =>
|
||||
effectiveCollapsedSections.includes(title) ? [] : list.map((c) => c.id);
|
||||
const projectsCollapsed = effectiveCollapsedSections.includes("Projects");
|
||||
visibleIdsRef.current = [
|
||||
...visible("Pinned", sections.pinned),
|
||||
...(projectsCollapsed
|
||||
? []
|
||||
: sections.projectGroups.flatMap((g) => projectRenderedIdsRef.current.get(g.name) ?? [])),
|
||||
...visible("Chats", sections.sessions),
|
||||
...visible("Shared with me", sections.shared),
|
||||
];
|
||||
useSessionSwitchHotkey(orderedConversationIds, activeId);
|
||||
|
||||
// Cmd/Ctrl+1..9/0 jumps to the first ten pinned sessions (desktop only;
|
||||
@@ -1294,6 +1345,7 @@ function ConversationList({
|
||||
selectedIds={selectedIds}
|
||||
onToggleSelected={onToggleSelected}
|
||||
onProjectAssigned={expandProject}
|
||||
projectRenderedIdsRef={projectRenderedIdsRef}
|
||||
/>
|
||||
))}
|
||||
</SectionGroup>
|
||||
@@ -1648,7 +1700,7 @@ function ConversationSection({
|
||||
onTogglePinned: (conversationId: string) => void;
|
||||
selectionMode: boolean;
|
||||
selectedIds: Set<string>;
|
||||
onToggleSelected: (conversationId: string) => void;
|
||||
onToggleSelected: (conversationId: string, shiftKey?: boolean) => void;
|
||||
/** Placeholder shown when expanded with no rows (e.g. an empty project). */
|
||||
emptyMessage?: string;
|
||||
/** Indent the rows one extra step (used to nest a project's chats). */
|
||||
@@ -2035,7 +2087,7 @@ function ConversationRow({
|
||||
onTogglePinned: (conversationId: string) => void;
|
||||
selectionMode: boolean;
|
||||
isSelected: boolean;
|
||||
onToggleSelected: (conversationId: string) => void;
|
||||
onToggleSelected: (conversationId: string, shiftKey?: boolean) => void;
|
||||
onProjectAssigned?: (projectName: string) => void;
|
||||
}) {
|
||||
// `useParams` reads from the active matched route. On `/`, the param is
|
||||
@@ -2325,7 +2377,7 @@ function ConversationRow({
|
||||
if (selectionMode) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onToggleSelected(conversation.id);
|
||||
onToggleSelected(conversation.id, e.shiftKey);
|
||||
return;
|
||||
}
|
||||
onClick(e);
|
||||
|
||||
Reference in New Issue
Block a user