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:
Yuan Tang
2026-06-29 06:18:30 -04:00
committed by GitHub
parent ea079d7ae2
commit f1ab7d86b6
+58 -6
View File
@@ -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);