authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-13 13:37:11-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-13 13:37:11-03:00
log0118b52295264a73ef7cd045437181f7b3e5a99f
tree28cff062c5f56b53799b772748e0aaad67ced7df
parentf26198d799bb81bf754985d457a60f8a851ea895
signature Signed by PGP key B5690EEEBB952194

fix(web): keep sparse sidebar shelves at the bottom (#11595)


3 files changed, 93 insertions(+), 17 deletions(-)

apps/web/src/components/Sidebar.drag.test.ts+31
...@@ -253,6 +253,37 @@ describe("sidebar collision detection", () => {...@@ -253,6 +253,37 @@ describe("sidebar collision detection", () => {
253});253});
254254
255describe("sidebar drag projection", () => {255describe("sidebar drag projection", () => {
256 it.each([
257 ["a1", "a2"],
258 ["a1", sidebarMarkerId("settled-header")],
259 ["z", "a2"],
260 ])("keeps sparse shelves at the bottom when dragging %s over %s", (active, over) => {
261 const items = [
262 pinnedHeader,
263 divider,
264 thread("a1", "active"),
265 thread("a2", "active"),
266 marker("snoozed-header"),
267 thread("z", "snoozed"),
268 settledHeader,
269 thread("s", "settled"),
270 ];
271 const strategy = createSidebarSortingStrategy({
272 items,
273 settledOrder: over === sidebarMarkerId("settled-header") ? ["a1", "s"] : ["s"],
274 settledExpanded: true,
275 boundaryLabelHeight: 24,
276 snoozedThreadCount: 1,
277 });
278 const args = layout(items, active, over);
279 for (const rect of args.rects.slice(4)) {
280 rect.top += 400;
281 rect.bottom += 400;
282 }
283 const lastIndex = items.length - 1;
284 expect(strategy({ ...args, index: lastIndex })).toEqual(stationary);
285 });
286
256 const pinned = [287 const pinned = [
257 pinnedHeader,288 pinnedHeader,
258 thread("p1", "pinned"),289 thread("p1", "pinned"),
apps/web/src/components/Sidebar.drag.ts+44-13
...@@ -194,27 +194,58 @@ export function createSidebarSortingStrategy(input: {...@@ -194,27 +194,58 @@ export function createSidebarSortingStrategy(input: {
194 }194 }
195 marker("settled-header");195 marker("settled-header");
196 section("settled");196 section("settled");
197 const result = items.map(() => hidden);197 const heights = projected.map((item) => {
198 let top = rects[0].top;
199 for (const item of projected) {
200 const index = indices.get(sidebarListItemId(item));198 const index = indices.get(sidebarListItemId(item));
201 const rect = index === undefined ? undefined : rects[index];199 const rect = index === undefined ? undefined : rects[index];
202 if (index !== undefined && rect) result[index] = { ...stationary, y: top - rect.top };
203 const fallback =200 const fallback =
204 item.kind === "thread" && (item.section === "pinned" || item.section === "active")201 item.kind === "thread" && (item.section === "pinned" || item.section === "active")
205 ? cardHeight202 ? cardHeight
206 : slimHeight;203 : slimHeight;
207 const moved = item.kind === "thread" && item.key === active.key;204 const moved = item.kind === "thread" && item.key === active.key;
208 const height =205 return item.kind === "marker" &&
209 item.kind === "marker" &&
210 (item.marker === "pinned-header" || item.marker === "pinned-divider")206 (item.marker === "pinned-header" || item.marker === "pinned-divider")
211 ? labelHeight207 ? labelHeight
212 : item.kind === "marker" && item.marker.endsWith("placeholder")208 : item.kind === "marker" && item.marker.endsWith("placeholder")
213 ? slimHeight209 ? slimHeight
214 : moved210 : moved
215 ? fallback211 ? fallback
216 : (rect?.height ?? fallback);212 : (rect?.height ?? fallback);
217 top += height + 1;213 });
214 const firstShelf = items.findIndex(
215 (item) =>
216 item.kind === "marker" &&
217 (item.marker === "snoozed-header" || item.marker === "settled-header"),
218 );
219 const shelfRect = rects[firstShelf];
220 const beforeShelf = rects[firstShelf - 1];
221 const lastRect = rects.at(-1);
222 // Consume the shelf's auto margin as drag labels and resized rows need
223 // room, keeping the combined shelves at their measured bottom.
224 let shelfSpace =
225 !input.compact &&
226 shelfRect &&
227 beforeShelf &&
228 lastRect &&
229 shelfRect.top > beforeShelf.bottom + 1
230 ? Math.max(
231 0,
232 lastRect.bottom - rects[0].top - heights.reduce((sum, height) => sum + height + 1, -1),
233 )
234 : 0;
235 const result = items.map(() => hidden);
236 let top = rects[0].top;
237 for (const [projectedIndex, item] of projected.entries()) {
238 if (
239 item.kind === "marker" &&
240 (item.marker === "snoozed-header" || item.marker === "settled-header")
241 ) {
242 top += shelfSpace;
243 shelfSpace = 0;
244 }
245 const index = indices.get(sidebarListItemId(item));
246 const rect = index === undefined ? undefined : rects[index];
247 if (index !== undefined && rect) result[index] = { ...stationary, y: top - rect.top };
248 top += heights[projectedIndex]! + 1;
218 }249 }
219 result[activeIndex] = stationary;250 result[activeIndex] = stationary;
220 return result;251 return result;
apps/web/src/components/Sidebar.tsx+18-4
...@@ -661,6 +661,7 @@ function SidebarDragBoundary(props: {...@@ -661,6 +661,7 @@ function SidebarDragBoundary(props: {
661function SidebarSectionHeader(props: {661function SidebarSectionHeader(props: {
662 marker: "snoozed-header" | "settled-header";662 marker: "snoozed-header" | "settled-header";
663 label: string;663 label: string;
664 className?: string;
664 // While dragging, the settled header reads at full strength and takes the665 // While dragging, the settled header reads at full strength and takes the
665 // accent while the lifted row is over it.666 // accent while the lifted row is over it.
666 dragging?: boolean;667 dragging?: boolean;
...@@ -712,7 +713,7 @@ function SidebarSectionHeader(props: {...@@ -712,7 +713,7 @@ function SidebarSectionHeader(props: {
712 <SortableSidebarMarker713 <SortableSidebarMarker
713 marker={props.marker}714 marker={props.marker}
714 data-testid={`sidebar-${props.marker}`}715 data-testid={`sidebar-${props.marker}`}
715 className="mx-0.5 h-8"716 className={cn("mx-0.5 h-8", props.className)}
716 >717 >
717 <Tooltip disabled={!compact}>718 <Tooltip disabled={!compact}>
718 <TooltipTrigger719 <TooltipTrigger
...@@ -4541,7 +4542,10 @@ export default function Sidebar() {...@@ -4541,7 +4542,10 @@ export default function Sidebar() {
4541 <>4542 <>
4542 <SidebarChromeHeader isElectron={isElectron} />4543 <SidebarChromeHeader isElectron={isElectron} />
4543 <SidebarContent4544 <SidebarContent
4544 className={cn("gap-0", compact && "group-data-[collapsible=icon]:overflow-visible")}4545 className={cn(
4546 "gap-0",
4547 compact ? "group-data-[collapsible=icon]:overflow-visible" : "min-h-full",
4548 )}
4545 fixedFooter={4549 fixedFooter={
4546 compact && !isSearchingThreads && snoozedThreads.length > 0 ? (4550 compact && !isSearchingThreads && snoozedThreads.length > 0 ? (
4547 <ul4551 <ul
...@@ -4717,7 +4721,12 @@ export default function Sidebar() {...@@ -4717,7 +4721,12 @@ export default function Sidebar() {
4717 </SidebarGroup>4721 </SidebarGroup>
4718 }4722 }
4719 >4723 >
4720 <SidebarGroup className="ps-[calc(var(--sidebar-content-inset)+1px)] pe-[var(--sidebar-content-inset)] pb-1 pt-0">4724 <SidebarGroup
4725 className={cn(
4726 "ps-[calc(var(--sidebar-content-inset)+1px)] pe-[var(--sidebar-content-inset)] pb-1 pt-0",
4727 !compact && "flex-1",
4728 )}
4729 >
4721 {isSearchingThreads ? (4730 {isSearchingThreads ? (
4722 threadSearchResults.length > 0 ? (4731 threadSearchResults.length > 0 ? (
4723 <TooltipProvider4732 <TooltipProvider
...@@ -4806,7 +4815,10 @@ export default function Sidebar() {...@@ -4806,7 +4815,10 @@ export default function Sidebar() {
4806 <ul4815 <ul
4807 ref={attachListMotionRef}4816 ref={attachListMotionRef}
4808 role="list"4817 role="list"
4809 className="relative flex flex-col gap-px"4818 className={cn(
4819 "relative flex flex-col gap-px",
4820 !compact && sidebarListItems.length > 0 && "flex-1",
4821 )}
4810 >4822 >
4811 {(() => {4823 {(() => {
4812 const renderThreadRowInner = (4824 const renderThreadRowInner = (
...@@ -5021,6 +5033,7 @@ export default function Sidebar() {...@@ -5021,6 +5033,7 @@ export default function Sidebar() {
5021 <SidebarSectionHeader5033 <SidebarSectionHeader
5022 key="snoozed-shelf-header"5034 key="snoozed-shelf-header"
5023 marker="snoozed-header"5035 marker="snoozed-header"
5036 className={cn(!compact && "mt-auto")}
5024 label={5037 label={
5025 snoozedShelfExpanded5038 snoozedShelfExpanded
5026 ? "Snoozed"5039 ? "Snoozed"
...@@ -5038,6 +5051,7 @@ export default function Sidebar() {...@@ -5038,6 +5051,7 @@ export default function Sidebar() {
5038 <SidebarSectionHeader5051 <SidebarSectionHeader
5039 key="settled-shelf-header"5052 key="settled-shelf-header"
5040 marker="settled-header"5053 marker="settled-header"
5054 className={cn(!compact && snoozedThreads.length === 0 && "mt-auto")}
5041 label={5055 label={
5042 settledShelfExpanded5056 settledShelfExpanded
5043 ? "Settled"5057 ? "Settled"