Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ import { DashboardSidebarProjectSection } from "./components/DashboardSidebarPro
import { DashboardSidebarSectionRenameProvider } from "./components/DashboardSidebarSectionRenameContext";
import { DashboardSidebarSessionsSection } from "./components/DashboardSidebarSessionsSection";
import { DashboardSidebarWorkspacesHeader } from "./components/DashboardSidebarWorkspacesHeader";
import { SectionDragSpacer } from "./components/SectionDragSpacer";
import { useV2SetupScriptCard } from "./components/V2SetupScriptCard";
import { useDashboardSidebarData } from "./hooks/useDashboardSidebarData";
import { useDashboardSidebarShortcuts } from "./hooks/useDashboardSidebarShortcuts";
Expand Down Expand Up @@ -131,13 +130,16 @@ export function DashboardSidebar({
groups,
pinnedWorkspaces,
sessionWorkspaces,
sessionTagGroups,
ungroupedSessionWorkspaces,
sessionChildren,
refreshWorkspacePullRequest,
toggleProjectCollapsed,
} = useDashboardSidebarData();
const { deleteSection, reorderProjects, renameSection, setSectionColor } =
useDashboardSidebarState();
const {
deleteSection,
reorderProjects,
renameSection,
toggleSectionCollapsed,
} = useDashboardSidebarState();
// Converts legacy uuid-keyed folders to tag-backed folders in the
// background; retries whenever the workspace cache changes.
useMigrateLegacySidebarFolders();
Expand Down Expand Up @@ -170,6 +172,7 @@ export function DashboardSidebar({
const workspaceShortcutLabels = useDashboardSidebarShortcuts(
orderedGroups,
sessionWorkspaces,
sessionChildren,
);
const selectableWorkspaceIds = useMemo(() => {
const ids = new Set<string>();
Expand Down Expand Up @@ -282,7 +285,7 @@ export function DashboardSidebar({
<DashboardSidebarDndProvider
projects={orderedGroups}
pinnedWorkspaces={pinnedWorkspaces}
sessionWorkspaces={sessionWorkspaces}
sessionChildren={sessionChildren}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When a session is bulk-selected, any subsequent project list or order update clears it because selectableWorkspaceIds still excludes all session rows. Add the session workspaces (including grouped rows) to the available-ID set now that Sessions uses the shared bulk-selectable lane.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/DashboardSidebar.tsx, line 288:

<comment>When a session is bulk-selected, any subsequent project list or order update clears it because `selectableWorkspaceIds` still excludes all session rows. Add the session workspaces (including grouped rows) to the available-ID set now that Sessions uses the shared bulk-selectable lane.</comment>

<file context>
@@ -282,7 +285,7 @@ export function DashboardSidebar({
 								projects={orderedGroups}
 								pinnedWorkspaces={pinnedWorkspaces}
-								sessionWorkspaces={sessionWorkspaces}
+								sessionChildren={sessionChildren}
 								isSidebarCollapsed={isCollapsed}
 								workspaceShortcutLabels={workspaceShortcutLabels}
</file context>

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pre-existing: main's selectableWorkspaceIds also excluded session rows while the Sessions section already offered bulk selection with projectId: "sessions". Unchanged by this PR; noted as a follow-up.

isSidebarCollapsed={isCollapsed}
workspaceShortcutLabels={workspaceShortcutLabels}
onReorderProjects={handleReorderProjects}
Expand All @@ -307,14 +310,12 @@ export function DashboardSidebar({
/>
<DashboardSidebarSessionsSection
sessionWorkspaces={sessionWorkspaces}
tagGroups={sessionTagGroups}
ungroupedWorkspaces={ungroupedSessionWorkspaces}
isCollapsed={isCollapsed}
workspaceShortcutLabels={workspaceShortcutLabels}
onWorkspaceHover={refreshWorkspacePullRequest}
onDeleteTagGroup={deleteSection}
onRenameTagGroup={renameSection}
onSetTagGroupColor={setSectionColor}
onDeleteSection={deleteSection}
onRenameSection={renameSection}
onToggleSectionCollapse={toggleSectionCollapsed}
/>
{!isCollapsed && (
<div className="mt-3 first:mt-0">
Expand All @@ -340,7 +341,6 @@ export function DashboardSidebar({
))}
</SortableContext>
)}
<SectionDragSpacer />
</OverflowFadeContainer>
<SidebarCardSlot
isCollapsed={isCollapsed}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { useLingui } from "@lingui/react/macro";
import { useMemo } from "react";
import { useSidebarSectionsCollapseStore } from "renderer/stores/sidebar-sections-collapse";
import {
dropZoneId,
Expand Down Expand Up @@ -42,6 +43,13 @@ export function DashboardSidebarPinnedSection({
const { pinnedItems, workspacesById, projectsById, activeType } =
useDashboardSidebarDnd();
const isDraggingWorkspace = activeType === "workspace";
// Captured once per drag, on the render where the drag flag flips — the
// only render where "empty" still means "empty at pickup".
// biome-ignore lint/correctness/useExhaustiveDependencies: pinnedItems is read at that moment on purpose
const emptyAtPickup = useMemo(
() => isDraggingWorkspace && pinnedItems.length === 0,
[isDraggingWorkspace],
);
const isSectionCollapsed = useSidebarSectionsCollapseStore(
(s) => s.collapsed.pinned,
);
Expand All @@ -63,10 +71,35 @@ export function DashboardSidebarPinnedSection({
);
}

// Keep the section (and its drop targets) mounted through the whole drag,
// even when the last pinned row is dragged out mid-drag.
if (pinnedItems.length === 0 && !isDraggingWorkspace) return null;

// With nothing pinned, the section must not appear in-flow at pickup:
// mounting a header and drop zone above the dragged row shifts it under
// the pointer, and when the sidebar has no scroll room for dnd-kit to
// compensate the ghost sits that far off the cursor for the whole drag.
// Float the target instead — zero height in the list, stuck to the top of
// the scroller so it stays reachable however far the list is scrolled.
// A drag that started with pins keeps the in-flow section even after its
// last row transfers out, so the layout under the pointer holds still.
if (pinnedItems.length === 0 && emptyAtPickup) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When a no-pin drag enters the floating pin target, onDragOver adds the workspace to pinnedItems, making this condition false and mounting the in-flow Pinned section mid-drag. Keep the floating branch active for the entire drag that started empty by checking emptyAtPickup without the live length guard.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPinnedSection/DashboardSidebarPinnedSection.tsx, line 84:

<comment>When a no-pin drag enters the floating pin target, `onDragOver` adds the workspace to `pinnedItems`, making this condition false and mounting the in-flow Pinned section mid-drag. Keep the floating branch active for the entire drag that started empty by checking `emptyAtPickup` without the live length guard.</comment>

<file context>
@@ -63,10 +71,35 @@ export function DashboardSidebarPinnedSection({
+	// the scroller so it stays reachable however far the list is scrolled.
+	// A drag that started with pins keeps the in-flow section even after its
+	// last row transfers out, so the layout under the pointer holds still.
+	if (pinnedItems.length === 0 && emptyAtPickup) {
+		return (
+			// -mb-3 cancels the `mt-3 first:mt-0` the next section gains once
</file context>

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentional. The float exists to keep the pickup itself shift-free; once the row transfers into Pinned, the in-flow section is what shows the row landing there, and the DragOverlay is unaffected by mid-drag layout. Keeping the float would leave the transferred row with nowhere to render. Measured A/B against main: the transfer path costs the same on both sides.

return (
// -mb-3 cancels the `mt-3 first:mt-0` the next section gains once
// it is no longer the scroller's first child — otherwise the float
// still nudges the list by that margin.
<div className="-mb-3 sticky top-0 z-20 h-0">
<div className="bg-sidebar/90 py-1 backdrop-blur-sm dark:bg-muted/90">
<SidebarDropZone
dropZoneId={dropZoneId(PINNED_CONTAINER)}
label={t({
id: "dashboard.sidebar.pinnedSection.dropToPin",
message: "Drop to pin",
})}
/>
</div>
</div>
);
}

return (
<div className="mt-3 pb-1 first:mt-0">
<DashboardSidebarSectionHeader
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ export function DashboardSidebarProjectSection({
className="overflow-hidden"
>
<DashboardSidebarExpandedProjectContent
containerId={project.id}
projectId={project.id}
isCollapsed={project.isCollapsed}
workspaceShortcutLabels={workspaceShortcutLabels}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,34 +1,54 @@
import {
SortableContext,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { SortableContext } from "@dnd-kit/sortable";
import { useLingui } from "@lingui/react/macro";
import { AnimatePresence, motion } from "framer-motion";
import { useMemo } from "react";
import {
dropZoneId,
parseId,
SESSIONS_CONTAINER,
useDashboardSidebarDnd,
} from "../../../../hooks/useSidebarDnd";
import { useDashboardSidebarSelection } from "../../../../providers/DashboardSidebarSelectionProvider";
import type {
DashboardSidebarWorkspace,
DashboardSidebarWorkspaceIndentation,
} from "../../../../types";
import { WorkspaceBulkMenuScope } from "../../../DashboardSidebarWorkspaceItem/components/WorkspaceBulkMenuScope";
import { SidebarDropZone } from "../../../SidebarDropZone";
import { SortableSectionHeader } from "../../../SortableSectionHeader";
import { SortableWorkspaceItem } from "../../../SortableWorkspaceItem";

/** Main rows are never bulk-selected; sessions and worktrees are. */
const isBulkSelectable = (workspace: DashboardSidebarWorkspace) =>
workspace.type !== "main" && workspace.pendingTransaction?.type !== "insert";
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

interface DashboardSidebarExpandedProjectContentProps {
projectId: string;
/** DnD container: a project id, or SESSIONS_CONTAINER for the Sessions lane. */
containerId: string;
/** Lane the rows are filed under: the project id, or null for sessions. */
projectId: string | null;
isCollapsed: boolean;
/** Row indentation overrides; the Sessions lane sits flush with its header. */
topLevelIndentation?: DashboardSidebarWorkspaceIndentation;
groupedIndentation?: DashboardSidebarWorkspaceIndentation;
workspaceShortcutLabels: Map<string, string>;
onWorkspaceHover: (workspaceId: string) => void | Promise<void>;
onDeleteSection: (sectionId: string) => void;
onRenameSection: (sectionId: string, name: string) => void;
onToggleSectionCollapse: (sectionId: string) => void;
}

/**
* The sortable row list of one DnD container — every project, and the
* Sessions lane. Ungrouped rows and folder headers are flat siblings so a
* folder drag can move whole groups as units (see useSidebarDnd).
*/
export function DashboardSidebarExpandedProjectContent({
containerId,
projectId,
isCollapsed,
topLevelIndentation,
groupedIndentation,
workspaceShortcutLabels,
onWorkspaceHover,
onDeleteSection,
Expand All @@ -38,41 +58,40 @@ export function DashboardSidebarExpandedProjectContent({
const { t } = useLingui();
const {
projectItems,
getProjectSortableItems,
activeType,
activeContainer,
sessionItems,
getContainerSortingStrategy,
activeSectionId,
activeWorkspaceHome,
groupInfo,
collapsedSectionIds,
workspacesById,
sectionsById,
} = useDashboardSidebarDnd();
const flatItems = useMemo(
() => projectItems[projectId] ?? [],
[projectItems, projectId],
() =>
containerId === SESSIONS_CONTAINER
? sessionItems
: (projectItems[containerId] ?? []),
[projectItems, sessionItems, containerId],
);
const sortableItems = getProjectSortableItems(projectId);
const sortingStrategy = getContainerSortingStrategy(containerId);
const { isWorkspaceSelected, selectWorkspaceFromEvent } =
useDashboardSidebarSelection();

// A pinned workspace can only return to its home project; when every row
// of that project is pinned, the empty list needs an explicit drop target.
// A pinned workspace can only return to its home container; when every row
// of that container is pinned, the empty list needs an explicit drop target.
const dropZoneEligible =
!isCollapsed && flatItems.length === 0 && activeWorkspaceHome === projectId;
!isCollapsed &&
flatItems.length === 0 &&
activeWorkspaceHome === containerId;

const selectableWorkspaceIds = useMemo(
() =>
flatItems.flatMap((id) => {
const parsed = parseId(id);
if (!parsed || parsed.type !== "workspace") return [];
const workspace = workspacesById.get(parsed.realId);
if (
!workspace ||
workspace.type !== "worktree" ||
workspace.pendingTransaction?.type === "insert"
) {
return [];
}
if (!workspace || !isBulkSelectable(workspace)) return [];
const group = groupInfo.get(parsed.realId);
if (group && collapsedSectionIds.has(group.sectionId)) return [];
return [parsed.realId];
Expand All @@ -96,10 +115,7 @@ export function DashboardSidebarExpandedProjectContent({
workspacesById={workspacesById}
groupInfo={groupInfo}
>
<SortableContext
items={sortableItems}
strategy={verticalListSortingStrategy}
>
<SortableContext items={flatItems} strategy={sortingStrategy}>
{flatItems.map((id) => {
const parsed = parseId(id);
if (!parsed) return null;
Expand All @@ -125,25 +141,25 @@ export function DashboardSidebarExpandedProjectContent({
const isInSection = !!group;
const isInCollapsedSection =
isInSection && collapsedSectionIds.has(group.sectionId);
const sectionDragActive =
activeType === "section" && activeContainer === projectId;
const hidden =
isInCollapsedSection || (sectionDragActive && isInSection);
const canBulkSelect =
workspace.type === "worktree" &&
workspace.pendingTransaction?.type !== "insert";
const inDraggedSection =
isInSection && group.sectionId === activeSectionId;
const canBulkSelect = isBulkSelectable(workspace);

// The zero-height collapse lives inside the sortable wrapper
// (see SortableWorkspaceItem) so the clip box moves with the
// dnd translate — wrapping here would clip displaced rows
// out of view mid-drag.
// out of view mid-drag. Rows stay mounted and full-height
// during a section drag: the group travels as a block.
return (
<SortableWorkspaceItem
key={String(id)}
sortableId={String(id)}
workspace={workspace}
accentColor={group?.color}
isInSection={isInSection}
indentation={
isInSection ? groupedIndentation : topLevelIndentation
}
onHoverCardOpen={onWorkspaceHover}
shortcutLabel={workspaceShortcutLabels.get(parsed.realId)}
isSelected={
Expand All @@ -154,15 +170,15 @@ export function DashboardSidebarExpandedProjectContent({
? (event) =>
selectWorkspaceFromEvent(event, {
workspaceId: parsed.realId,
projectId,
projectId: containerId,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When a user Ctrl/Shift-clicks a session row, the selection stores "sessions" as its project ID, but the outer bulk toolbar resolves selections only through the project list. The toolbar therefore disappears for session selections; add a Sessions-aware scope to the outer bulk actions or keep session selections out of this path.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarExpandedProjectContent/DashboardSidebarExpandedProjectContent.tsx, line 173:

<comment>When a user Ctrl/Shift-clicks a session row, the selection stores `"sessions"` as its project ID, but the outer bulk toolbar resolves selections only through the project list. The toolbar therefore disappears for session selections; add a Sessions-aware scope to the outer bulk actions or keep session selections out of this path.</comment>

<file context>
@@ -154,15 +170,15 @@ export function DashboardSidebarExpandedProjectContent({
 															selectWorkspaceFromEvent(event, {
 																workspaceId: parsed.realId,
-																projectId,
+																projectId: containerId,
 																orderedWorkspaceIds: selectableWorkspaceIds,
 															})
</file context>

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pre-existing: the Sessions section passed projectId: "sessions" on main too, and its bulk actions come from the row context menu scope rather than the project toolbar. Not widened here.

orderedWorkspaceIds: selectableWorkspaceIds,
})
: undefined
}
collapsed={hidden}
collapseInstantly={sectionDragActive}
collapsed={isInCollapsedSection}
isDragPlaceholder={inDraggedSection}
disabled={
hidden ||
isInCollapsedSection ||
(workspace.type === "main" &&
workspace.hostType === "local-device")
}
Expand All @@ -172,7 +188,7 @@ export function DashboardSidebarExpandedProjectContent({
</SortableContext>
{dropZoneEligible && (
<SidebarDropZone
dropZoneId={dropZoneId(projectId)}
dropZoneId={dropZoneId(containerId)}
label={t({
id: "dashboard.sidebar.projectContent.dropToUnpin",
message: "Drop to unpin",
Expand Down
Loading
Loading