authorgravatar for hello@ipanasenko.meIllia Panasenko <hello@ipanasenko.me> 2026-08-20 23:23:18+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-20 17:23:18-04:00
log1afe5545b7d0397bbd6515981372072d146002ad
treeb2a778faa6441e2b3e222a86695772544c116043
parent730ce9edd9873144c1d2b01e5f1c85414c3760ad
signature Signed by PGP key B5690EEEBB952194

fix(web): fix thread jumping after reorder (#7103)


3 files changed, 37 insertions(+), 0 deletions(-)

apps/web/src/components/Sidebar.logic.test.ts+28
...@@ -1,5 +1,7 @@...@@ -1,5 +1,7 @@
1import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";1import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
2import { defaultAnimateLayoutChanges, type AnimateLayoutChanges } from "@dnd-kit/sortable";
2import {3import {
4 animatePinnedLayoutChanges,
3 archiveSelectedThreadEntries,5 archiveSelectedThreadEntries,
4 buildBulkTitleRegenerationContextMenuItem,6 buildBulkTitleRegenerationContextMenuItem,
5 buildMultiSelectThreadContextMenuItems,7 buildMultiSelectThreadContextMenuItems,
...@@ -53,6 +55,32 @@ import {...@@ -53,6 +55,32 @@ import {
5355
54const localEnvironmentId = EnvironmentId.make("environment-local");56const localEnvironmentId = EnvironmentId.make("environment-local");
5557
58describe("animatePinnedLayoutChanges", () => {
59 const baseArgs: Parameters<AnimateLayoutChanges>[0] = {
60 active: null,
61 containerId: "pinned-threads",
62 isDragging: false,
63 isSorting: false,
64 id: "thread-a",
65 index: 1,
66 items: ["thread-b", "thread-a"],
67 newIndex: 0,
68 previousItems: ["thread-a", "thread-b"],
69 previousContainerId: "pinned-threads",
70 transition: { duration: 200, easing: "ease" },
71 wasDragging: true,
72 };
73
74 it("does not replay layout movement after the pointer is released", () => {
75 expect(defaultAnimateLayoutChanges(baseArgs)).toBe(true);
76 expect(animatePinnedLayoutChanges(baseArgs)).toBe(false);
77 });
78
79 it("keeps layout movement while the user is sorting", () => {
80 expect(animatePinnedLayoutChanges({ ...baseArgs, isSorting: true })).toBe(true);
81 });
82});
83
56describe("shouldNavigateAfterProjectRemoval", () => {84describe("shouldNavigateAfterProjectRemoval", () => {
57 const projectThreads = [{ environmentId: "environment-local", id: "thread-1" }];85 const projectThreads = [{ environmentId: "environment-local", id: "thread-1" }];
5886
apps/web/src/components/Sidebar.logic.ts+7
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import * as React from "react";1import * as React from "react";
2import { defaultAnimateLayoutChanges, type AnimateLayoutChanges } from "@dnd-kit/sortable";
2import type { ContextMenuItem } from "@t3tools/contracts";3import type { ContextMenuItem } from "@t3tools/contracts";
3import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings";4import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings";
4import {5import {
...@@ -23,6 +24,12 @@ export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 100;...@@ -23,6 +24,12 @@ export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 100;
23// it small; cold opens still render instantly from the cached snapshot.24// it small; cold opens still render instantly from the cached snapshot.
24export const SIDEBAR_THREAD_PREWARM_LIMIT = 3;25export const SIDEBAR_THREAD_PREWARM_LIMIT = 3;
2526
27// The list already reaches its destination through sortable transforms while
28// the pointer is down. dnd-kit's default also animates the committed DOM order
29// after release, replaying the same movement across every affected row.
30export const animatePinnedLayoutChanges: AnimateLayoutChanges = (args) =>
31 args.isSorting ? defaultAnimateLayoutChanges(args) : false;
32
26type SidebarProject = {33type SidebarProject = {
27 id: string;34 id: string;
28 title: string;35 title: string;
apps/web/src/components/Sidebar.tsx+2
...@@ -121,6 +121,7 @@ import type { SidebarThreadSummary } from "../types";...@@ -121,6 +121,7 @@ import type { SidebarThreadSummary } from "../types";
121import { cn } from "~/lib/utils";121import { cn } from "~/lib/utils";
122import { buildThreadActionMenuItems } from "./threadActionMenu.logic";122import { buildThreadActionMenuItems } from "./threadActionMenu.logic";
123import {123import {
124 animatePinnedLayoutChanges,
124 buildBulkTitleRegenerationContextMenuItem,125 buildBulkTitleRegenerationContextMenuItem,
125 formatWorkingDurationLabel,126 formatWorkingDurationLabel,
126 firstValidTimestampMs,127 firstValidTimestampMs,
...@@ -448,6 +449,7 @@ function SortablePinnedThreadRow(props: {...@@ -448,6 +449,7 @@ function SortablePinnedThreadRow(props: {
448}) {449}) {
449 const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({450 const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({
450 id: props.id,451 id: props.id,
452 animateLayoutChanges: animatePinnedLayoutChanges,
451 });453 });
452 return props.children({ listeners, setNodeRef, transform, transition, isDragging });454 return props.children({ listeners, setNodeRef, transform, transition, isDragging });
453}455}