From 7059405bae442c53f0ded14e4df9df011bd9dce7 Mon Sep 17 00:00:00 2001 From: Taras Date: Fri, 21 Aug 2026 19:02:14 +0300 Subject: [PATCH 01/26] feat(web): add sidebar thread drag and drop --- .../features/threads/thread-list-v2-items.tsx | 44 +- .../src/orchestration/decider.pinned.test.ts | 23 +- .../src/orchestration/decider.snoozed.test.ts | 22 +- apps/server/src/orchestration/decider.ts | 196 +- apps/web/src/components/Sidebar.dnd.logic.ts | 214 ++ apps/web/src/components/Sidebar.tsx | 2611 ++++++++++++++--- .../sidebar/SidebarThreadDragPreview.tsx | 77 + .../src/components/threadActionMenu.logic.ts | 5 +- apps/web/src/components/ui/scroll-area.tsx | 28 +- apps/web/src/components/ui/sidebar.tsx | 20 +- apps/web/src/hooks/useThreadActionMenu.ts | 2 +- docs/internals/sidebar-thread-dnd.md | 32 + docs/user/thread-sidebar.md | 32 +- packages/contracts/src/orchestration.ts | 5 +- 14 files changed, 2893 insertions(+), 418 deletions(-) create mode 100644 apps/web/src/components/Sidebar.dnd.logic.ts create mode 100644 apps/web/src/components/sidebar/SidebarThreadDragPreview.tsx create mode 100644 docs/internals/sidebar-thread-dnd.md diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index e6589cd56300..bbfaa3585c2c 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -77,7 +77,7 @@ const SLIM_MENU_ACTIONS: MenuAction[] = [ ]; const SNOOZED_MENU_ACTIONS: MenuAction[] = [ - { id: "unsnooze", title: "Wake thread", image: "clock" }, + { id: "unsnooze", title: "Wake", image: "clock" }, { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, ]; @@ -451,8 +451,10 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { // Swipe: the v2 primary action is the lifecycle transition. Every settled // row can un-settle — explicit settles clear the override, auto-settled - // rows get pinned active until real activity clears the pin. - const canUnsettle = variant === "slim"; + // rows stay explicitly active until real activity clears the override. + // The row's rendered category is authoritative for its lifecycle menu. + // Snoozed rows are slim too, but their only transition is Wake. + const canUnsettle = variant === "slim" && !snoozedRow; const [snoozeGateTick, bumpSnoozeGateTick] = useState(0); const snoozeGateExpiryMs = props.snoozeSupported ? resolveThreadListV2SnoozeGateExpiryMs(thread, { now: new Date().toISOString() }) @@ -483,9 +485,23 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { })), [snoozePresets], ); + const snoozeMenuItem = useMemo( + () => + swipeActions.secondary === "snooze" + ? [ + { + id: "snooze", + title: "Snooze", + image: "clock", + subactions: snoozePresetActions, + }, + ] + : [], + [snoozePresetActions, swipeActions.secondary], + ); // Pinned cards keep the full lifecycle menu; only the pin item flips to - // Unpin. (Settling a pinned thread clears the pin server-side; snoozing - // hides the card until wake with the pin intact.) + // Unpin. Settling or snoozing a pinned thread moves it out of Pinned; + // Wake returns a snoozed thread to Regular. const pinMenuItem = useMemo( () => props.pinningSupported @@ -531,17 +547,12 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const snoozableCardMenuActions = useMemo( () => [ { id: "settle", title: "Settle", image: "checkmark" }, - { - id: "snooze", - title: "Snooze", - image: "clock", - subactions: snoozePresetActions, - }, + ...snoozeMenuItem, ...pinMenuItem, ...titleRegenerationMenuItems, { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, ], - [pinMenuItem, snoozePresetActions, titleRegenerationMenuItems], + [pinMenuItem, snoozeMenuItem, titleRegenerationMenuItems], ); const cardMenuActions = useMemo( () => [ @@ -552,14 +563,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ], [pinMenuItem, titleRegenerationMenuItems], ); - const slimMenuActions = useMemo( + const settledMenuActions = useMemo( () => [ SLIM_MENU_ACTIONS[0]!, - ...(thread.pinnedAt != null ? pinMenuItem : []), + ...snoozeMenuItem, + ...pinMenuItem, ...titleRegenerationMenuItems, SLIM_MENU_ACTIONS[1]!, ], - [pinMenuItem, thread.pinnedAt, titleRegenerationMenuItems], + [pinMenuItem, snoozeMenuItem, titleRegenerationMenuItems], ); const snoozedMenuActions = useMemo( () => [SNOOZED_MENU_ACTIONS[0]!, ...titleRegenerationMenuItems, SNOOZED_MENU_ACTIONS[1]!], @@ -943,7 +955,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { : !props.settlementSupported ? legacyMenuActions : canUnsettle - ? slimMenuActions + ? settledMenuActions : swipeActions.secondary === "snooze" ? snoozableCardMenuActions : cardMenuActions diff --git a/apps/server/src/orchestration/decider.pinned.test.ts b/apps/server/src/orchestration/decider.pinned.test.ts index 4ad00ba994b4..c010935f6596 100644 --- a/apps/server/src/orchestration/decider.pinned.test.ts +++ b/apps/server/src/orchestration/decider.pinned.test.ts @@ -70,7 +70,7 @@ it.layer(NodeServices.layer)("pinned thread decider", (it) => { readModel: makeReadModel({}), }); const events = Array.isArray(event) ? event : [event]; - expect(events).toHaveLength(1); + expect(events.map((entry) => entry.type)).toEqual(["thread.pinned", "thread.unsettled"]); expect(events[0]?.type).toBe("thread.pinned"); if (events[0]?.type === "thread.pinned") { expect(events[0].payload.pinnedAt).toBe(events[0].payload.updatedAt); @@ -86,7 +86,7 @@ it.layer(NodeServices.layer)("pinned thread decider", (it) => { commandId: CommandId.make("cmd-pin-again"), threadId: ThreadId.make("thread-1"), }, - readModel: makeReadModel({ pinnedAt: PINNED_AT }), + readModel: makeReadModel({ pinnedAt: PINNED_AT, settledOverride: "active" }), }); const events = Array.isArray(event) ? event : [event]; expect(events[0]?.type).toBe("thread.pinned"); @@ -105,7 +105,7 @@ it.layer(NodeServices.layer)("pinned thread decider", (it) => { commandId: CommandId.make("cmd-unpin"), threadId: ThreadId.make("thread-1"), }, - readModel: makeReadModel({ pinnedAt: PINNED_AT }), + readModel: makeReadModel({ pinnedAt: PINNED_AT, settledOverride: "active" }), }); const events = Array.isArray(event) ? event : [event]; expect(events[0]?.type).toBe("thread.unpinned"); @@ -160,14 +160,17 @@ it.layer(NodeServices.layer)("pinned thread decider", (it) => { commandId: CommandId.make("cmd-pin-snoozed"), threadId: ThreadId.make("thread-1"), }, - readModel: makeReadModel({ snoozedUntil: "1970-01-02T09:00:00.000Z" }), + readModel: makeReadModel({ + settledOverride: "active", + snoozedUntil: "1970-01-02T09:00:00.000Z", + }), }); const events = Array.isArray(event) ? event : [event]; expect(events.map((entry) => entry.type)).toEqual(["thread.pinned", "thread.unsnoozed"]); }), ); - it.effect("pinning an unparked thread emits only thread.pinned", () => + it.effect("pinning a neutral thread explicitly keeps it active", () => Effect.gen(function* () { const event = yield* decideOrchestrationCommand({ command: { @@ -178,7 +181,7 @@ it.layer(NodeServices.layer)("pinned thread decider", (it) => { readModel: makeReadModel({}), }); const events = Array.isArray(event) ? event : [event]; - expect(events.map((entry) => entry.type)).toEqual(["thread.pinned"]); + expect(events.map((entry) => entry.type)).toEqual(["thread.pinned", "thread.unsettled"]); }), ); @@ -190,7 +193,7 @@ it.layer(NodeServices.layer)("pinned thread decider", (it) => { commandId: CommandId.make("cmd-settle-pinned"), threadId: ThreadId.make("thread-1"), }, - readModel: makeReadModel({ pinnedAt: PINNED_AT }), + readModel: makeReadModel({ pinnedAt: PINNED_AT, settledOverride: "active" }), }); const events = Array.isArray(event) ? event : [event]; expect(events.map((entry) => entry.type)).toEqual(["thread.settled", "thread.unpinned"]); @@ -256,7 +259,11 @@ it.layer(NodeServices.layer)("pinned thread decider", (it) => { threadId: ThreadId.make("thread-1"), orderKey: "t", }, - readModel: makeReadModel({ pinnedAt: PINNED_AT, pinOrderKey: "g" }), + readModel: makeReadModel({ + pinnedAt: PINNED_AT, + pinOrderKey: "g", + settledOverride: "active", + }), }); const events = Array.isArray(event) ? event : [event]; expect(events[0]?.type).toBe("thread.pinned"); diff --git a/apps/server/src/orchestration/decider.snoozed.test.ts b/apps/server/src/orchestration/decider.snoozed.test.ts index 1012240b18a0..d1c3d60b74ce 100644 --- a/apps/server/src/orchestration/decider.snoozed.test.ts +++ b/apps/server/src/orchestration/decider.snoozed.test.ts @@ -25,6 +25,7 @@ function makeReadModel(input: { readonly snoozedUntil?: string | null; readonly snoozedAt?: string | null; readonly archivedAt?: string | null; + readonly settledOverride?: "settled" | "active" | null; readonly activities?: OrchestrationThread["activities"]; readonly messages?: OrchestrationThread["messages"]; }): OrchestrationReadModel { @@ -45,7 +46,7 @@ function makeReadModel(input: { createdAt: NOW, updatedAt: NOW, archivedAt: input.archivedAt ?? null, - settledOverride: null, + settledOverride: input.settledOverride ?? null, settledAt: null, snoozedUntil: input.snoozedUntil ?? null, snoozedAt: input.snoozedAt ?? (input.snoozedUntil != null ? SNOOZED_AT : null), @@ -74,7 +75,7 @@ it.layer(NodeServices.layer)("snoozed thread decider", (it) => { readModel: makeReadModel({}), }); const events = Array.isArray(event) ? event : [event]; - expect(events).toHaveLength(1); + expect(events.map((entry) => entry.type)).toEqual(["thread.snoozed", "thread.unsettled"]); expect(events[0]?.type).toBe("thread.snoozed"); if (events[0]?.type === "thread.snoozed") { expect(events[0].payload.snoozedUntil).toBe(FUTURE_WAKE); @@ -148,7 +149,10 @@ it.layer(NodeServices.layer)("snoozed thread decider", (it) => { threadId: ThreadId.make("thread-1"), snoozedUntil: FUTURE_WAKE, }, - readModel: makeReadModel({ snoozedUntil: FUTURE_WAKE }), + readModel: makeReadModel({ + snoozedUntil: FUTURE_WAKE, + settledOverride: "active", + }), }); const events = Array.isArray(reEmit) ? reEmit : [reEmit]; expect(events).toHaveLength(1); @@ -169,7 +173,10 @@ it.layer(NodeServices.layer)("snoozed thread decider", (it) => { threadId: ThreadId.make("thread-1"), snoozedUntil: "1970-01-03T09:00:00.000Z", }, - readModel: makeReadModel({ snoozedUntil: FUTURE_WAKE }), + readModel: makeReadModel({ + snoozedUntil: FUTURE_WAKE, + settledOverride: "active", + }), }); const events = Array.isArray(event) ? event : [event]; if (events[0]?.type === "thread.snoozed") { @@ -188,7 +195,10 @@ it.layer(NodeServices.layer)("snoozed thread decider", (it) => { threadId: ThreadId.make("thread-1"), reason: "user", }, - readModel: makeReadModel({ snoozedUntil: FUTURE_WAKE }), + readModel: makeReadModel({ + snoozedUntil: FUTURE_WAKE, + settledOverride: "active", + }), }); const events = Array.isArray(event) ? event : [event]; expect(events[0]?.type).toBe("thread.unsnoozed"); @@ -204,7 +214,7 @@ it.layer(NodeServices.layer)("snoozed thread decider", (it) => { threadId: ThreadId.make("thread-1"), reason: "user", }, - readModel: makeReadModel({}), + readModel: makeReadModel({ settledOverride: "active" }), }); const awakeEvents = Array.isArray(awake) ? awake : [awake]; expect(awakeEvents[0]?.type).toBe("thread.unsnoozed"); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 4f61955fa6aa..c116a4fd610b 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -552,25 +552,58 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" command, threadId: command.threadId, }); - // Idempotent by re-emission (see thread.settle): reducing the event a - // second time lands on the same override state. A re-emission keeps - // the existing updatedAt so duplicates do not churn ordering. - const alreadyPinnedActive = thread.settledOverride === "active"; const occurredAt = yield* nowIso; - return { + // The user command moves the thread to Regular, which also clears the + // other category fields. Activity-driven events are emitted elsewhere + // and keep their neutral-reset behavior. + const alreadyActive = thread.settledOverride === "active"; + const unsettledEvent = { ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt, commandId: command.commandId, })), - type: "thread.unsettled", + type: "thread.unsettled" as const, payload: { threadId: command.threadId, reason: command.reason, - updatedAt: alreadyPinnedActive ? thread.updatedAt : occurredAt, + updatedAt: alreadyActive ? thread.updatedAt : occurredAt, }, }; + const cleanupEvents: Array> = []; + if (thread.pinnedAt != null) { + cleanupEvents.push({ + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.unpinned", + payload: { + threadId: command.threadId, + updatedAt: occurredAt, + }, + }); + } + if (thread.snoozedUntil != null) { + cleanupEvents.push({ + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.unsnoozed", + payload: { + threadId: command.threadId, + reason: "user", + updatedAt: occurredAt, + }, + }); + } + return cleanupEvents.length > 0 ? [unsettledEvent, ...cleanupEvents] : unsettledEvent; } case "thread.snooze": { @@ -626,14 +659,14 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" thread.snoozedUntil === command.snoozedUntil && thread.snoozedAt != null ? thread.snoozedAt : null; - return { + const snoozedEvent = { ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt, commandId: command.commandId, })), - type: "thread.snoozed", + type: "thread.snoozed" as const, payload: { threadId: command.threadId, snoozedUntil: command.snoozedUntil, @@ -641,6 +674,41 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" updatedAt: existingSnoozedAt !== null ? thread.updatedAt : occurredAt, }, }; + // Snooze is a destination, not a deferred restoration marker. Clearing + // the other exclusive categories keeps Wake's meaning unambiguous. + const cleanupEvents: Array> = []; + if (thread.pinnedAt != null) { + cleanupEvents.push({ + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.unpinned", + payload: { + threadId: command.threadId, + updatedAt: occurredAt, + }, + }); + } + if (thread.settledOverride !== "active") { + cleanupEvents.push({ + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.unsettled", + payload: { + threadId: command.threadId, + reason: "user", + updatedAt: occurredAt, + }, + }); + } + return cleanupEvents.length > 0 ? [snoozedEvent, ...cleanupEvents] : snoozedEvent; } case "thread.unsnooze": { @@ -649,25 +717,57 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" command, threadId: command.threadId, }); - // Idempotent by re-emission (see thread.settle): waking a thread that - // is not snoozed lands on the same null state without churning - // updatedAt. const alreadyAwake = thread.snoozedUntil == null; const occurredAt = yield* nowIso; - return { + const unsnoozedEvent = { ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt, commandId: command.commandId, })), - type: "thread.unsnoozed", + type: "thread.unsnoozed" as const, payload: { threadId: command.threadId, reason: command.reason, updatedAt: alreadyAwake ? thread.updatedAt : occurredAt, }, }; + // Activity-driven wakes are emitted elsewhere. This user command + // reaches Regular, including its explicit active override. + const cleanupEvents: Array> = []; + if (thread.pinnedAt != null) { + cleanupEvents.push({ + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.unpinned", + payload: { + threadId: command.threadId, + updatedAt: occurredAt, + }, + }); + } + if (thread.settledOverride !== "active") { + cleanupEvents.push({ + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.unsettled", + payload: { + threadId: command.threadId, + reason: "user", + updatedAt: occurredAt, + }, + }); + } + return cleanupEvents.length > 0 ? [unsnoozedEvent, ...cleanupEvents] : unsnoozedEvent; } case "thread.pin": { @@ -677,10 +777,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" threadId: command.threadId, }); const occurredAt = yield* nowIso; - // Re-pinning an already-pinned thread is a duplicate (double-click, - // raced clients): re-emit with the original timestamps so the - // projection is a no-op. Pinning has no lifecycle invariants — a pin - // only ever promotes visibility, so it can never hide pending work. + // Re-pinning preserves the original timestamp and key. const existingPinnedAt = thread.pinnedAt ?? null; const pinnedEvent = { ...(yield* withEventBase({ @@ -693,22 +790,17 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" payload: { threadId: command.threadId, pinnedAt: existingPinnedAt ?? occurredAt, - // A fresh pin takes the client's slot in the arranged order; on a - // re-pin the existing key wins so raced duplicates cannot move a - // thread the user already placed. + // A fresh pin takes the client's slot in the arranged order. ...(existingPinnedAt === null && command.orderKey !== undefined ? { pinOrderKey: command.orderKey } : {}), updatedAt: existingPinnedAt !== null ? thread.updatedAt : occurredAt, }, }; - // Pinning is a promotion: it clears the parked states rather than - // silently outranking them. An explicit settle un-settles (reason - // "user", same override the un-settle button stamps), and a snooze's - // return ticket is spent — the thread is on top NOW, not on Tuesday. - const promotionEvents: Array> = []; - if (thread.settledOverride === "settled") { - promotionEvents.push({ + // Pinning clears settled and snoozed state. + const cleanupEvents: Array> = []; + if (thread.settledOverride !== "active") { + cleanupEvents.push({ ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, @@ -724,7 +816,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }); } if (thread.snoozedUntil != null) { - promotionEvents.push({ + cleanupEvents.push({ ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, @@ -739,7 +831,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }, }); } - return promotionEvents.length > 0 ? [pinnedEvent, ...promotionEvents] : pinnedEvent; + return cleanupEvents.length > 0 ? [pinnedEvent, ...cleanupEvents] : pinnedEvent; } case "thread.unpin": { @@ -748,24 +840,58 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" command, threadId: command.threadId, }); - // Idempotent by re-emission (see thread.settle): unpinning a thread - // that is not pinned lands on the same null state without churning - // updatedAt. const alreadyUnpinned = thread.pinnedAt == null; const occurredAt = yield* nowIso; - return { + const unpinnedEvent = { ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt, commandId: command.commandId, })), - type: "thread.unpinned", + type: "thread.unpinned" as const, payload: { threadId: command.threadId, updatedAt: alreadyUnpinned ? thread.updatedAt : occurredAt, }, }; + // Unpin is an explicit move to Regular. It spends a snooze and stamps + // the active override so automatic settlement cannot immediately move + // the thread away again. + const cleanupEvents: Array> = []; + if (thread.snoozedUntil != null) { + cleanupEvents.push({ + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.unsnoozed", + payload: { + threadId: command.threadId, + reason: "user", + updatedAt: occurredAt, + }, + }); + } + if (thread.settledOverride !== "active") { + cleanupEvents.push({ + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.unsettled", + payload: { + threadId: command.threadId, + reason: "user", + updatedAt: occurredAt, + }, + }); + } + return cleanupEvents.length > 0 ? [unpinnedEvent, ...cleanupEvents] : unpinnedEvent; } case "thread.pin.reorder": { diff --git a/apps/web/src/components/Sidebar.dnd.logic.ts b/apps/web/src/components/Sidebar.dnd.logic.ts new file mode 100644 index 000000000000..585d398dd952 --- /dev/null +++ b/apps/web/src/components/Sidebar.dnd.logic.ts @@ -0,0 +1,214 @@ +export type SidebarDndSection = "pinned" | "regular" | "snoozed" | "settled"; + +export type SidebarDndAction = + | "pin" + | "unpin" + | "unsettle" + | "unsnooze" + | "settle" + | "snooze" + | "reorder-pinned" + | "noop"; + +export type SidebarDndPreviewVariant = "card" | "slim"; + +export interface SidebarDndPoint { + readonly x: number; + readonly y: number; +} + +export interface SidebarDndRect { + readonly left: number; + readonly top: number; + readonly width: number; + readonly height: number; +} + +export interface SidebarDndPointerAnchor { + readonly x: number; + readonly y: number; +} + +export interface SidebarDndDraggableId { + readonly kind: "draggable"; + readonly section: SidebarDndSection; + readonly threadKey: string; +} + +export interface SidebarDndRowId { + readonly kind: "row"; + readonly section: SidebarDndSection; + readonly threadKey: string; +} + +export interface SidebarDndSectionId { + readonly kind: "section"; + readonly section: SidebarDndSection; +} + +export type SidebarDndId = SidebarDndDraggableId | SidebarDndRowId | SidebarDndSectionId; + +const DND_ID_PREFIX = "sidebar-thread-dnd"; + +export function createSidebarDndDraggableId(input: { + section: SidebarDndSection; + threadKey: string; +}): string { + return `${DND_ID_PREFIX}:draggable:${input.section}:${encodeURIComponent(input.threadKey)}`; +} + +export function createSidebarDndRowId(input: { + section: SidebarDndSection; + threadKey: string; +}): string { + return `${DND_ID_PREFIX}:row:${input.section}:${encodeURIComponent(input.threadKey)}`; +} + +export function createSidebarDndSectionId(input: { section: SidebarDndSection }): string { + return `${DND_ID_PREFIX}:section:${input.section}`; +} + +function parseSection(value: string): SidebarDndSection | null { + switch (value) { + case "pinned": + case "regular": + case "snoozed": + case "settled": + return value; + default: + return null; + } +} + +function parseThreadKey(value: string): string | null { + if (value.length === 0) return null; + try { + const threadKey = decodeURIComponent(value); + return threadKey.length === 0 || encodeURIComponent(threadKey) !== value ? null : threadKey; + } catch { + return null; + } +} + +/** Safely parses only IDs produced by the sidebar DnD helpers. */ +export function parseSidebarDndId(value: unknown): SidebarDndId | null { + if (typeof value !== "string") return null; + const parts = value.split(":"); + if (parts[0] !== DND_ID_PREFIX) return null; + + switch (parts[1]) { + case "draggable": + case "row": { + if (parts.length !== 4) return null; + const section = parseSection(parts[2] ?? ""); + const threadKey = parseThreadKey(parts[3] ?? ""); + if (section === null || threadKey === null) return null; + return parts[1] === "draggable" + ? { kind: "draggable", section, threadKey } + : { kind: "row", section, threadKey }; + } + case "section": { + if (parts.length !== 3) return null; + const section = parseSection(parts[2] ?? ""); + return section === null ? null : { kind: "section", section }; + } + default: + return null; + } +} + +/** The lifecycle command that realizes a drop between two sidebar sections. */ +export function resolveSidebarDndAction(input: { + source: SidebarDndSection; + destination: SidebarDndSection; +}): SidebarDndAction { + const { destination, source } = input; + if (source === destination) { + switch (source) { + case "pinned": + return "reorder-pinned"; + case "snoozed": + case "regular": + case "settled": + return "noop"; + default: { + const _exhaustive: never = source; + return _exhaustive; + } + } + } + + switch (destination) { + case "pinned": + return "pin"; + case "snoozed": + return "snooze"; + case "settled": + return "settle"; + case "regular": + switch (source) { + case "pinned": + return "unpin"; + case "snoozed": + return "unsnooze"; + case "settled": + return "unsettle"; + case "regular": + return "noop"; + default: { + const _exhaustive: never = source; + return _exhaustive; + } + } + default: { + const _exhaustive: never = destination; + return _exhaustive; + } + } +} + +/** Cards remain full-height in pinned and regular sections; parked work is slim. */ +export function resolveSidebarDndPreviewVariant(input: { + source: SidebarDndSection; + destination: SidebarDndSection | null; +}): SidebarDndPreviewVariant { + const section = input.destination ?? input.source; + switch (section) { + case "pinned": + case "regular": + return "card"; + case "snoozed": + case "settled": + return "slim"; + default: { + const _exhaustive: never = section; + return _exhaustive; + } + } +} + +/** + * The cursor's normalized position in the source row. A zero-sized source + * falls back to its center, so a stale measurement cannot jump the overlay. + */ +export function captureSidebarDndPointerAnchor(input: { + pointer: SidebarDndPoint; + sourceRect: SidebarDndRect; +}): SidebarDndPointerAnchor { + return { + x: normalizePointerAxis(input.pointer.x, input.sourceRect.left, input.sourceRect.width), + y: normalizePointerAxis(input.pointer.y, input.sourceRect.top, input.sourceRect.height), + }; +} + +function normalizePointerAxis(pointer: number, start: number, length: number): number { + if ( + !Number.isFinite(pointer) || + !Number.isFinite(start) || + !Number.isFinite(length) || + length <= 0 + ) { + return 0.5; + } + return Math.min(1, Math.max(0, (pointer - start) / length)); +} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 971ead810f07..d45aaff81961 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -3,21 +3,32 @@ import { useAtomValue } from "@effect/atom-react"; import * as Schema from "effect/Schema"; import { DndContext, + DragOverlay, + MeasuringStrategy, PointerSensor, closestCenter, + pointerWithin, + useDraggable, + useDroppable, useSensor, useSensors, + type CollisionDetection, + type DragCancelEvent, type DragEndEvent, + type DragMoveEvent, + type DragOverEvent, + type DragStartEvent, } from "@dnd-kit/core"; import { SortableContext, - arrayMove, useSortable, verticalListSortingStrategy, + type SortingStrategy, } from "@dnd-kit/sortable"; -import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers"; +import { restrictToVerticalAxis } from "@dnd-kit/modifiers"; import { CSS } from "@dnd-kit/utilities"; import { + canSettle, canSnooze, changeRequestAutoSettles, effectiveSettled, @@ -59,13 +70,16 @@ import { memo, useCallback, useEffect, + useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, + type PointerEvent as ReactPointerEvent, type ReactNode, } from "react"; +import { createPortal } from "react-dom"; import { useParams, useRouter } from "@tanstack/react-router"; import { @@ -112,6 +126,8 @@ import { vcsEnvironment } from "../state/vcs"; import { threadEnvironment } from "../state/threads"; import { useEnvironmentQuery } from "../state/query"; import { useAtomCommand } from "../state/use-atom-command"; +import { environmentSnapshotAtom } from "../state/shell"; +import { appAtomRegistry } from "../rpc/atomRegistry"; import { buildThreadRouteParams, resolveActiveThreadRouteRef, @@ -142,6 +158,19 @@ import { sortSettledThreadsForSidebar, sortThreadsForSidebar, } from "./Sidebar.logic"; +import { + captureSidebarDndPointerAnchor, + createSidebarDndDraggableId, + createSidebarDndRowId, + createSidebarDndSectionId, + parseSidebarDndId, + resolveSidebarDndAction, + resolveSidebarDndPreviewVariant, + type SidebarDndAction, + type SidebarDndPointerAnchor, + type SidebarDndPreviewVariant, + type SidebarDndSection, +} from "./Sidebar.dnd.logic"; import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; import { ThreadWorktreeIndicator, @@ -177,6 +206,7 @@ import { Input } from "./ui/input"; import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu"; import { SidebarContent, SidebarGroup, SidebarMenuButton, useSidebar } from "./ui/sidebar"; import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; +import { SidebarThreadDragPreview } from "./sidebar/SidebarThreadDragPreview"; import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; import { @@ -194,6 +224,13 @@ const SETTLED_TAIL_PAGE_COUNT = 25; // Keep the v2 key so existing preferences survive the v2-to-default rename. const SETTLED_SHELF_EXPANDED_KEY = "t3code:sidebar-v2:settled-expanded"; const SNOOZED_SHELF_EXPANDED_KEY = "t3code:sidebar-v2:snoozed-expanded"; +const SIDEBAR_DND_SECTION_ORDER = [ + "pinned", + "regular", + "snoozed", + "settled", +] satisfies ReadonlyArray; +const SIDEBAR_DND_EMPTY_RAIL_HEIGHT = 48; function compactSidebarTimeLabel(label: string): string { if (label === "just now") return "now"; @@ -435,25 +472,151 @@ function SnoozePopoverButton(props: { ); } -// Subset of useSortable applied to a pinned card's root
  • . Listeners go -// on the whole card (no dedicated handle): the pointer sensor's distance -// constraint keeps plain clicks working, and we skip dnd-kit's aria -// attributes since there is no keyboard sensor and the card body already -// carries its own button semantics. -type SortablePinnedRowBag = Pick< - ReturnType, - "listeners" | "setNodeRef" | "transform" | "transition" | "isDragging" ->; - -function SortablePinnedThreadRow(props: { - id: string; - children: (bag: SortablePinnedRowBag) => ReactNode; +type SidebarThreadDndRowBag = { + readonly listeners: ReturnType["listeners"]; + readonly setNodeRef: (node: HTMLElement | null) => void; + readonly transform: ReturnType["transform"]; + readonly transition: string | undefined; + readonly isDragging: boolean; + readonly isSortable: boolean; +}; + +function SortableSidebarThreadRow(props: { + threadKey: string; + section: SidebarDndSection; + disabled: boolean; + onNodeChange: (threadKey: string, node: HTMLElement | null) => void; + children: (bag: SidebarThreadDndRowBag) => ReactNode; }) { - const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({ - id: props.id, + const id = createSidebarDndDraggableId({ section: props.section, threadKey: props.threadKey }); + const sortable = useSortable({ + id, + disabled: props.disabled, animateLayoutChanges: animatePinnedLayoutChanges, + data: { section: props.section, threadKey: props.threadKey }, + }); + const setNodeRef = useCallback( + (node: HTMLElement | null) => { + sortable.setNodeRef(node); + props.onNodeChange(props.threadKey, node); + }, + [props.onNodeChange, props.threadKey, sortable.setNodeRef], + ); + useEffect( + () => () => { + props.onNodeChange(props.threadKey, null); + }, + [props.onNodeChange, props.threadKey], + ); + return props.children({ + listeners: sortable.listeners, + setNodeRef, + transform: sortable.transform, + transition: sortable.transition, + isDragging: sortable.isDragging, + isSortable: true, }); - return props.children({ listeners, setNodeRef, transform, transition, isDragging }); +} + +function DraggableSidebarThreadRow(props: { + threadKey: string; + section: SidebarDndSection; + dragDisabled: boolean; + dropDisabled: boolean; + onNodeChange: (threadKey: string, node: HTMLElement | null) => void; + children: (bag: SidebarThreadDndRowBag) => ReactNode; +}) { + const draggable = useDraggable({ + id: createSidebarDndDraggableId({ + section: props.section, + threadKey: props.threadKey, + }), + disabled: props.dragDisabled, + data: { section: props.section, threadKey: props.threadKey }, + }); + const droppable = useDroppable({ + id: createSidebarDndRowId({ section: props.section, threadKey: props.threadKey }), + disabled: props.dropDisabled, + data: { section: props.section, threadKey: props.threadKey }, + }); + const setNodeRef = useCallback( + (node: HTMLElement | null) => { + draggable.setNodeRef(node); + droppable.setNodeRef(node); + props.onNodeChange(props.threadKey, node); + }, + [draggable.setNodeRef, droppable.setNodeRef, props.onNodeChange, props.threadKey], + ); + useEffect( + () => () => { + props.onNodeChange(props.threadKey, null); + }, + [props.onNodeChange, props.threadKey], + ); + return props.children({ + listeners: draggable.listeners, + setNodeRef, + // Sorted lists never apply the draggable transform to their source row. + transform: null, + transition: undefined, + isDragging: draggable.isDragging, + isSortable: false, + }); +} + +function SidebarThreadSectionDropZone(props: { + section: SidebarDndSection; + disabled: boolean; + children: (bag: { + readonly setNodeRef: (node: HTMLElement | null) => void; + readonly isOver: boolean; + }) => ReactNode; +}) { + const droppable = useDroppable({ + id: createSidebarDndSectionId({ section: props.section }), + disabled: props.disabled, + data: { section: props.section }, + }); + return props.children({ setNodeRef: droppable.setNodeRef, isOver: droppable.isOver }); +} + +function SidebarThreadViewportDropRail(props: { + section: SidebarDndSection; + top: number; + setDropNodeRef: (node: HTMLElement | null) => void; + onNodeChange: (section: SidebarDndSection, node: HTMLElement | null) => void; + children: ReactNode; +}) { + const setNodeRef = useCallback( + (node: HTMLDivElement | null) => { + props.setDropNodeRef(node); + props.onNodeChange(props.section, node); + }, + [props.onNodeChange, props.section, props.setDropNodeRef], + ); + + return ( +
    + {props.children} +
    + ); +} + +function SidebarThreadDropIndicator(props: { edge: "before" | "after" }) { + return ( + + ); } // One unsent draft session the user has invested content in. Two lines, @@ -701,14 +864,20 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { autoSettleOnMerge: boolean; // Same contract for thread.snooze/unsnooze. snoozeSupported: boolean; - // Pinned threads show the same pin marker in active, settled, and snoozed - // rows. The marker can unpin the thread when the server supports pinning. + // Renders the pin glyph. Pinned cards keep the full settle/snooze quick + // actions: both move the thread out of Pinned. The glyph is also the + // in-row pin state cue (the pinned block has no header), so it always + // shows while pinned; it only becomes a clickable unpin quick-action once + // the pinning capability is confirmed, and stays a passive marker while + // the descriptor is not loaded. Pinning itself lives in the context menu. pinningSupported: boolean; isPinned: boolean; - // Present only on pinned cards whose server supports reordering: dnd-kit - // sortable bag applied to the card root so the whole card drags (the - // pointer sensor's distance constraint keeps plain clicks working). - sortable?: SortablePinnedRowBag | undefined; + // Applied to the exact row root measured by DnD Kit. Sorted rows never use + // the draggable transform; Pinned alone receives sortable transforms. + dnd?: SidebarThreadDndRowBag | undefined; + dndDimmed: boolean; + dndInert: boolean; + dropIndicator: "before" | "after" | null; // Compact wake countdown ("2h") for rows in the snoozed shelf. snoozeWakeLabelText: string | null; // When a snooze ended (timer or early wake); drives the Woke pill until @@ -805,13 +974,12 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { // switching sidebars must not light up every historical thread as unread. const isUnread = hasUnseenCompletion({ ...thread, lastVisitedAt }); const status = resolveSidebarThreadStatus(thread); - // A woken thread reappears at its original position (the sort is - // deliberately static), so the pill has to carry the weight. Snoozing is - // an explicit act, so the pill clears only when the user re-engages: - // reading a completion-triggered wake, clicking the pill, sending a - // message, settling, archiving, or a change request state that settles the - // thread. Timer wakes survive a mere visit. An unparseable visit timestamp - // counts as never-visited, so corrupt local data cannot eat the wake signal. + // A woken thread returns in Regular's natural order. The pill stays until + // the user re-engages by reading a completion-triggered wake, clicking it, + // sending a message, settling, archiving, or reaching a change request + // state that settles the thread. Timer wakes survive a mere visit. An + // unparseable visit timestamp counts as never-visited, so corrupt local + // data cannot eat the wake signal. const lastVisitedDate = lastVisitedAt === undefined ? null : parseTimestampDate(lastVisitedAt); const wokeAtDate = props.wokeAt === null ? null : parseTimestampDate(props.wokeAt); const isWoke = @@ -975,6 +1143,28 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { }, [onContextMenu, threadRef], ); + const handleDndPointerDownCapture = useCallback((event: ReactPointerEvent) => { + if ( + event.pointerType === "touch" || + event.button !== 0 || + event.metaKey || + event.ctrlKey || + event.altKey || + event.shiftKey + ) { + event.stopPropagation(); + return; + } + const target = event.target; + if (!(target instanceof Element)) return; + if ( + target.closest( + "button, a, input, textarea, select, [contenteditable='true'], [data-thread-selection-safe]", + ) + ) { + event.stopPropagation(); + } + }, []); const handleKeyDown = useCallback( (event: ReactKeyboardEvent) => { if (event.target !== event.currentTarget) return; @@ -1064,7 +1254,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { // Snooze is offered only where it can succeed: capability-gated and never // on blocked-on-you work or queued turns (the server rejects both). const showSnoozeButton = - props.snoozeSupported && canSnooze(thread, { now: new Date().toISOString() }); + !props.dndInert && + props.snoozeSupported && + canSnooze(thread, { now: new Date().toISOString() }); // If the thread becomes blocked while the popover is open, the button // unmounts without firing onOpenChange(false). Deriving the flag keeps a // stale true from permanently hiding the status label / pinning the @@ -1214,17 +1406,32 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ) : null; if (variant === "slim") { + const dnd = props.dnd; return (
  • + {props.dropIndicator ? : null} ) : isWoke ? ( - // A wake can land straight in the settled tail (e.g. PR - // merged while snoozed); the signal must survive the trip. + {props.dropIndicator ? : null} | null; + readonly viewportRailTopBySection: ReadonlyMap | null; +}; + +type SidebarPinnedInsertionPlan = { + readonly order: readonly string[]; + readonly assignments: ReadonlyArray<{ readonly id: string; readonly orderKey: string }>; + readonly threadByKey: ReadonlyMap; +}; + +interface SidebarThreadDropTarget { + readonly targetSection: SidebarDndSection; + readonly targetThreadKey: string | null; + readonly targetEdge: "before" | "after" | null; +} + +type SidebarLayoutCorrection = + | { readonly kind: "stable" } + | { readonly kind: "corrected" } + | { + readonly kind: "clamped"; + readonly edge: "start" | "end"; + readonly missingScrollRange: number; + }; + +interface SidebarScrollRangeHold { + readonly node: HTMLUListElement; + readonly originalMinHeight: string; + readonly originalPaddingTop: string; + readonly originalPaddingBottom: string; + readonly height: number; + readonly topInset: number; + readonly bottomInset: number; +} + +type SidebarAutoAnimateController = ReturnType & { + readonly destroy?: () => void; +}; + +function sidebarThreadKey(thread: Pick): string { + return scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); +} + +function sidebarDndSectionIndex( + section: SidebarDndSection, + threadKey: string, + sections: Readonly>, +): number { + const index = sections[section].findIndex((thread) => sidebarThreadKey(thread) === threadKey); + return Math.max(0, index); +} + +function insertSidebarThreadAt( + threads: readonly EnvironmentThreadShell[], + thread: EnvironmentThreadShell, + index: number, +): EnvironmentThreadShell[] { + const next = [...threads]; + next.splice(Math.min(Math.max(0, index), next.length), 0, thread); + return next; +} + +function movePinnedThreadAtEdge(input: { + keys: readonly string[]; + activeKey: string; + overKey: string; + edge: "before" | "after"; +}): string[] | null { + if (!input.keys.includes(input.activeKey)) return null; + if (input.activeKey === input.overKey) return [...input.keys]; + + const next = input.keys.filter((key) => key !== input.activeKey); + const overIndex = next.indexOf(input.overKey); + if (overIndex === -1) return null; + const insertionIndex = overIndex + (input.edge === "after" ? 1 : 0); + next.splice(insertionIndex, 0, input.activeKey); + return next; +} + +function SidebarThreadDragOverlayContent(props: { + transaction: SidebarThreadDragTransaction; + variant: SidebarDndPreviewVariant; + projectTitle: string | null; + projectCwd: string | null; + projectFaviconPath: string | null; +}) { + const innerRef = useRef(null); + const animationRef = useRef(null); + const geometryRef = useRef<{ + readonly width: number; + readonly height: number; + } | null>(null); + const previewHeight = props.variant === "card" ? 82 : 36; + const previewWidth = props.transaction.sourceRect.width; + const left = + props.transaction.pointerAnchor.x * props.transaction.sourceRect.width - + props.transaction.pointerAnchor.x * previewWidth; + const top = + props.transaction.pointerAnchor.y * props.transaction.sourceRect.height - + props.transaction.pointerAnchor.y * previewHeight; + + useLayoutEffect(() => { + const node = innerRef.current; + if (node === null) return; + const nextGeometry = { width: previewWidth, height: previewHeight }; + const previousGeometry = geometryRef.current; + geometryRef.current = nextGeometry; + if (previousGeometry === null) { + return; + } + const interruptedRect = + animationRef.current?.playState === "running" ? node.getBoundingClientRect() : null; + animationRef.current?.cancel(); + const settledRect = node.getBoundingClientRect(); + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; + const fromWidth = interruptedRect?.width ?? previousGeometry.width; + const fromHeight = interruptedRect?.height ?? previousGeometry.height; + const scaleX = settledRect.width > 0 ? fromWidth / settledRect.width : 1; + const scaleY = settledRect.height > 0 ? fromHeight / settledRect.height : 1; + const settledAnchorX = settledRect.left + props.transaction.pointerAnchor.x * settledRect.width; + const settledAnchorY = settledRect.top + props.transaction.pointerAnchor.y * settledRect.height; + const translateX = + interruptedRect === null + ? 0 + : interruptedRect.left + + props.transaction.pointerAnchor.x * interruptedRect.width - + settledAnchorX; + const translateY = + interruptedRect === null + ? 0 + : interruptedRect.top + + props.transaction.pointerAnchor.y * interruptedRect.height - + settledAnchorY; + animationRef.current = node.animate( + [ + { + transform: `translate(${translateX}px, ${translateY}px) scale(${scaleX}, ${scaleY})`, + opacity: 0.88, + }, + { transform: "translate(0, 0) scale(1, 1)", opacity: 1 }, + ], + { duration: 160, easing: "cubic-bezier(0.2, 0, 0, 1)", fill: "both" }, + ); + }, [left, previewHeight, previewWidth, props.variant]); + useEffect(() => () => animationRef.current?.cancel(), []); + + return ( +
    +
    + +
    +
    + ); +} + const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { thread: SidebarThreadSummary; projectCwd: string | null; @@ -2040,9 +2458,13 @@ export default function Sidebar() { snapshot != null && (thread.worktreePath === null || snapshot.branch === thread.branch) ? snapshot.pr : null; - // Snooze outranks settlement and pinning until the thread wakes. + // User lifecycle commands keep these categories exclusive. Snooze is + // checked first so a transient stale projection still honors "hide + // until Tuesday" instead of flashing the thread in another section. if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) { snoozed.push(thread); + } else if (thread.pinnedAt != null) { + pinned.push(thread); } else if ( supportsSettlement && effectiveSettled(thread, { @@ -2096,6 +2518,33 @@ export default function Sidebar() { threads, ]); + const canonicalSectionByThreadKey = useMemo(() => { + const sections = new Map(); + for (const thread of pinnedThreads) sections.set(sidebarThreadKey(thread), "pinned"); + for (const thread of activeThreads) sections.set(sidebarThreadKey(thread), "regular"); + for (const thread of snoozedThreads) sections.set(sidebarThreadKey(thread), "snoozed"); + for (const thread of settledThreads) sections.set(sidebarThreadKey(thread), "settled"); + return sections; + }, [activeThreads, pinnedThreads, settledThreads, snoozedThreads]); + const allThreadByKey = useMemo( + () => new Map(threads.map((thread) => [sidebarThreadKey(thread), thread] as const)), + [threads], + ); + // Pinned placement is global even when the sidebar is project-scoped. A + // visible boundary therefore keeps hidden pinned neighbors in its order + // calculation instead of moving them as a side effect of filtering. + const allPinnedThreads = useMemo( + () => + sortPinnedThreadsForSidebar( + threads.filter((thread) => thread.archivedAt === null && thread.pinnedAt != null), + ), + [threads], + ); + const allThreadByKeyRef = useRef(allThreadByKey); + allThreadByKeyRef.current = allThreadByKey; + const canonicalSectionByThreadKeyRef = useRef(canonicalSectionByThreadKey); + canonicalSectionByThreadKeyRef.current = canonicalSectionByThreadKey; + const threadSearchInputRef = useRef(null); const [threadSearchQuery, setThreadSearchQuery] = useState(""); const [activeSearchResultIndex, setActiveSearchResultIndex] = useState(0); @@ -2552,9 +3001,452 @@ export default function Sidebar() { // win) and ANY membership change (new pin, unpin, snooze/wake) also // release it: the override can't say where members it never saw belong, // and holding it would launder a stale order into later drags. - const pinnedDndSensors = useSensors( + const threadDndSensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), ); + const [dragTransaction, setDragTransactionState] = useState( + null, + ); + const dragTransactionRef = useRef(null); + const setDragTransaction = useCallback( + ( + next: + | SidebarThreadDragTransaction + | null + | ((current: SidebarThreadDragTransaction | null) => SidebarThreadDragTransaction | null), + ) => { + const resolved = typeof next === "function" ? next(dragTransactionRef.current) : next; + dragTransactionRef.current = resolved; + setDragTransactionState(resolved); + }, + [], + ); + const sidebarViewportRef = useRef(null); + const sidebarViewportOverlayRef = useRef(null); + const viewportRailSectionsRef = useRef(new Set()); + const threadListNodeRef = useRef(null); + const sidebarScrollRangeHoldRef = useRef(null); + const threadRowNodesRef = useRef(new Map()); + const autoAnimateControllerRef = useRef(null); + const autoAnimatePausedRef = useRef(false); + const viewportOverflowAnchorRef = useRef(""); + const correctedScrollTopRef = useRef(null); + const retainedLayoutAnchorRef = useRef<{ + element: HTMLElement; + top: number; + } | null>(null); + const rawPointerRef = useRef<{ x: number; y: number } | null>(null); + const releasePointerRef = useRef<{ x: number; y: number } | null>(null); + const activePointerIdRef = useRef(null); + const pointerListenerCleanupRef = useRef<(() => void) | null>(null); + const pinnedReorderInFlightRef = useRef(false); + const snoozeDropEpochRef = useRef(0); + const handleViewportRailNodeChange = useCallback( + (section: SidebarDndSection, node: HTMLElement | null) => { + if (node === null) { + viewportRailSectionsRef.current.delete(section); + return; + } + viewportRailSectionsRef.current.add(section); + }, + [], + ); + const handleThreadRowNodeChange = useCallback((threadKey: string, node: HTMLElement | null) => { + if (node === null) { + threadRowNodesRef.current.delete(threadKey); + return; + } + threadRowNodesRef.current.set(threadKey, node); + }, []); + const pauseSidebarLayoutMotion = useCallback(() => { + if (autoAnimatePausedRef.current) return; + autoAnimatePausedRef.current = true; + autoAnimateControllerRef.current?.disable(); + const viewport = sidebarViewportRef.current; + if (viewport === null) return; + viewportOverflowAnchorRef.current = viewport.style.overflowAnchor; + viewport.style.overflowAnchor = "none"; + }, []); + const chooseSidebarLayoutAnchor = useCallback( + (preferred: HTMLElement | null, excludedThreadKey: string | null = null) => { + const viewport = sidebarViewportRef.current; + if (viewport === null) return null; + const canAnchor = (element: HTMLElement) => { + if (!element.isConnected || element.dataset.dndTransformed === "true") return false; + const rect = element.getBoundingClientRect(); + const viewportRect = viewport.getBoundingClientRect(); + return rect.bottom > viewportRect.top && rect.top < viewportRect.bottom; + }; + if (preferred !== null && canAnchor(preferred)) return preferred; + for (const [threadKey, element] of threadRowNodesRef.current) { + if (threadKey === excludedThreadKey) continue; + if (canAnchor(element)) return element; + } + return null; + }, + [], + ); + const retainSidebarLayoutAnchor = useCallback( + (preferred: HTMLElement | null = null, excludedThreadKey: string | null = null) => { + const anchor = chooseSidebarLayoutAnchor(preferred, excludedThreadKey); + retainedLayoutAnchorRef.current = + anchor === null ? null : { element: anchor, top: anchor.getBoundingClientRect().top }; + }, + [chooseSidebarLayoutAnchor], + ); + const correctSidebarLayoutAnchor = useCallback((): SidebarLayoutCorrection => { + const viewport = sidebarViewportRef.current; + const retained = retainedLayoutAnchorRef.current; + if ( + viewport === null || + retained === null || + !retained.element.isConnected || + retained.element.dataset.dndTransformed === "true" + ) { + retainSidebarLayoutAnchor(); + return { kind: "stable" }; + } + const nextTop = retained.element.getBoundingClientRect().top; + const delta = nextTop - retained.top; + if (Math.abs(delta) > 0.5) { + const maxScrollTop = Math.max(0, viewport.scrollHeight - viewport.clientHeight); + const previousScrollTop = viewport.scrollTop; + const requestedScrollTop = previousScrollTop + delta; + const nextScrollTop = Math.min(maxScrollTop, Math.max(0, requestedScrollTop)); + viewport.scrollTop = nextScrollTop; + const appliedScrollTop = viewport.scrollTop; + if (Math.abs(appliedScrollTop - previousScrollTop) > 0.5) { + correctedScrollTopRef.current = appliedScrollTop; + } + if (Math.abs(appliedScrollTop - requestedScrollTop) > 0.5) { + return { + kind: "clamped", + edge: requestedScrollTop < 0 ? "start" : "end", + missingScrollRange: Math.abs(appliedScrollTop - requestedScrollTop), + }; + } + } + retainedLayoutAnchorRef.current = { + element: retained.element, + top: retained.element.getBoundingClientRect().top, + }; + return { kind: Math.abs(delta) > 0.5 ? "corrected" : "stable" }; + }, [retainSidebarLayoutAnchor]); + const clearSidebarScrollRangeHold = useCallback(() => { + const hold = sidebarScrollRangeHoldRef.current; + if (hold === null) return; + hold.node.style.minHeight = hold.originalMinHeight; + hold.node.style.paddingTop = hold.originalPaddingTop; + hold.node.style.paddingBottom = hold.originalPaddingBottom; + sidebarScrollRangeHoldRef.current = null; + }, []); + const holdSidebarScrollRange = useCallback(() => { + const node = threadListNodeRef.current; + if (node === null) return; + const current = sidebarScrollRangeHoldRef.current; + if (current !== null && current.node !== node) { + current.node.style.minHeight = current.originalMinHeight; + current.node.style.paddingTop = current.originalPaddingTop; + current.node.style.paddingBottom = current.originalPaddingBottom; + sidebarScrollRangeHoldRef.current = null; + } + const activeHold = sidebarScrollRangeHoldRef.current; + const height = Math.max(activeHold?.height ?? 0, node.getBoundingClientRect().height); + const next = { + node, + originalMinHeight: activeHold?.originalMinHeight ?? node.style.minHeight, + originalPaddingTop: activeHold?.originalPaddingTop ?? node.style.paddingTop, + originalPaddingBottom: activeHold?.originalPaddingBottom ?? node.style.paddingBottom, + height, + topInset: activeHold?.topInset ?? 0, + bottomInset: activeHold?.bottomInset ?? 0, + } satisfies SidebarScrollRangeHold; + sidebarScrollRangeHoldRef.current = next; + node.style.minHeight = `${height}px`; + node.style.paddingTop = + next.topInset === 0 + ? next.originalPaddingTop + : `calc(${next.originalPaddingTop || "0px"} + ${next.topInset}px)`; + node.style.paddingBottom = + next.bottomInset === 0 + ? next.originalPaddingBottom + : `calc(${next.originalPaddingBottom || "0px"} + ${next.bottomInset}px)`; + }, []); + const extendSidebarScrollRange = useCallback( + (edge: "start" | "end", missingScrollRange: number) => { + const hold = sidebarScrollRangeHoldRef.current; + if (hold === null || missingScrollRange <= 0.5) return false; + const next = { + ...hold, + height: hold.height + missingScrollRange, + topInset: hold.topInset + (edge === "start" ? missingScrollRange : 0), + bottomInset: hold.bottomInset + (edge === "end" ? missingScrollRange : 0), + } satisfies SidebarScrollRangeHold; + sidebarScrollRangeHoldRef.current = next; + next.node.style.minHeight = `${next.height}px`; + next.node.style.paddingTop = + next.topInset === 0 + ? next.originalPaddingTop + : `calc(${next.originalPaddingTop || "0px"} + ${next.topInset}px)`; + next.node.style.paddingBottom = + next.bottomInset === 0 + ? next.originalPaddingBottom + : `calc(${next.originalPaddingBottom || "0px"} + ${next.bottomInset}px)`; + return true; + }, + [], + ); + const releaseSidebarScrollRangeIfSafe = useCallback(() => { + const hold = sidebarScrollRangeHoldRef.current; + if (hold === null) return true; + const viewport = sidebarViewportRef.current; + if (viewport === null || !hold.node.isConnected) { + clearSidebarScrollRangeHold(); + return true; + } + + const anchor = chooseSidebarLayoutAnchor(null); + const previousAnchorTop = anchor?.getBoundingClientRect().top ?? null; + const previousScrollTop = viewport.scrollTop; + + const previousOverflowAnchor = viewport.style.overflowAnchor; + viewport.style.overflowAnchor = "none"; + try { + if (hold.topInset > 0.5) { + viewport.scrollTop = Math.max(0, previousScrollTop - hold.topInset); + } + hold.node.style.minHeight = hold.originalMinHeight; + hold.node.style.paddingTop = hold.originalPaddingTop; + hold.node.style.paddingBottom = hold.originalPaddingBottom; + const naturalMaxScrollTop = Math.max(0, viewport.scrollHeight - viewport.clientHeight); + const anchorDelta = + anchor === null || previousAnchorTop === null + ? 0 + : anchor.getBoundingClientRect().top - previousAnchorTop; + const requestedScrollTop = viewport.scrollTop + anchorDelta; + const outsideNaturalRange = + requestedScrollTop < -0.5 || requestedScrollTop > naturalMaxScrollTop + 0.5; + const temporaryInsetReachedNaturalBoundary = + (requestedScrollTop < -0.5 && hold.topInset > 0.5) || + (requestedScrollTop > naturalMaxScrollTop + 0.5 && hold.bottomInset > 0.5); + if (outsideNaturalRange && !temporaryInsetReachedNaturalBoundary) { + hold.node.style.minHeight = `${hold.height}px`; + hold.node.style.paddingTop = + hold.topInset === 0 + ? hold.originalPaddingTop + : `calc(${hold.originalPaddingTop || "0px"} + ${hold.topInset}px)`; + hold.node.style.paddingBottom = + hold.bottomInset === 0 + ? hold.originalPaddingBottom + : `calc(${hold.originalPaddingBottom || "0px"} + ${hold.bottomInset}px)`; + viewport.scrollTop = previousScrollTop; + return false; + } + viewport.scrollTop = Math.min(naturalMaxScrollTop, Math.max(0, requestedScrollTop)); + correctedScrollTopRef.current = viewport.scrollTop; + sidebarScrollRangeHoldRef.current = null; + return true; + } finally { + viewport.style.overflowAnchor = previousOverflowAnchor; + } + }, [chooseSidebarLayoutAnchor, clearSidebarScrollRangeHold]); + const cleanupTrackedPointer = useCallback(() => { + pointerListenerCleanupRef.current?.(); + pointerListenerCleanupRef.current = null; + activePointerIdRef.current = null; + rawPointerRef.current = null; + releasePointerRef.current = null; + }, []); + const canDropThreadInSection = useCallback( + (thread: EnvironmentThreadShell, source: SidebarDndSection, destination: SidebarDndSection) => { + const capabilities = serverConfigs.get(thread.environmentId)?.environment.capabilities; + const action = resolveSidebarDndAction({ source, destination }); + switch (action) { + case "noop": + return true; + case "reorder-pinned": + return capabilities?.threadPinReorder === true; + case "pin": + // Exact placement needs both the category command and an order key. + return capabilities?.threadPinning === true && capabilities.threadPinReorder === true; + case "unpin": + return capabilities?.threadPinning === true; + case "unsettle": + return capabilities?.threadSettlement === true; + case "unsnooze": + return capabilities?.threadSnooze === true; + case "settle": + return ( + capabilities?.threadSettlement === true && + canSettle(thread, { now: new Date().toISOString() }) + ); + case "snooze": + return ( + capabilities?.threadSnooze === true && + canSnooze(thread, { now: new Date().toISOString() }) + ); + } + }, + [serverConfigs], + ); + const canDragThread = useCallback( + (thread: EnvironmentThreadShell, source: SidebarDndSection) => + SIDEBAR_DND_SECTION_ORDER.some((destination) => { + const action = resolveSidebarDndAction({ source, destination }); + return action !== "noop" && canDropThreadInSection(thread, source, destination); + }), + [canDropThreadInSection], + ); + const moveClampedEmptyRailsToViewport = useCallback( + (transaction: SidebarThreadDragTransaction) => { + if (transaction.phase !== "dragging" || transaction.viewportRailTopBySection !== null) { + return false; + } + const sourceOrderIndex = SIDEBAR_DND_SECTION_ORDER.indexOf(transaction.sourceSection); + const sections = [ + { section: "pinned", threads: pinnedThreads }, + { section: "regular", threads: activeThreads }, + { section: "snoozed", threads: snoozedThreads }, + { section: "settled", threads: settledThreads }, + ] satisfies ReadonlyArray<{ + readonly section: SidebarDndSection; + readonly threads: readonly EnvironmentThreadShell[]; + }>; + const overlaySections = sections + .slice(0, sourceOrderIndex) + .filter( + ({ section, threads }) => + threads.length === 0 && + canDropThreadInSection(transaction.sourceThread, transaction.sourceSection, section), + ) + .map(({ section }) => section); + if (overlaySections.length === 0) return false; + setDragTransaction((current) => { + if ( + current === null || + current.sourceThreadKey !== transaction.sourceThreadKey || + current.viewportRailTopBySection !== null + ) { + return current; + } + return { + ...current, + viewportRailTopBySection: new Map( + overlaySections.map((section, index) => [ + section, + index * SIDEBAR_DND_EMPTY_RAIL_HEIGHT, + ]), + ), + }; + }); + return true; + }, + [ + activeThreads, + canDropThreadInSection, + pinnedThreads, + setDragTransaction, + settledThreads, + snoozedThreads, + ], + ); + const correctSidebarDragLayout = useCallback( + (transaction: SidebarThreadDragTransaction) => { + const correction = correctSidebarLayoutAnchor(); + if (correction.kind !== "clamped") return; + if (correction.edge === "end" && moveClampedEmptyRailsToViewport(transaction)) return; + if (!extendSidebarScrollRange(correction.edge, correction.missingScrollRange)) { + retainSidebarLayoutAnchor(); + return; + } + if (correctSidebarLayoutAnchor().kind === "clamped") { + retainSidebarLayoutAnchor(); + } + }, + [ + correctSidebarLayoutAnchor, + extendSidebarScrollRange, + moveClampedEmptyRailsToViewport, + retainSidebarLayoutAnchor, + ], + ); + useLayoutEffect(() => { + if (dragTransaction !== null) { + holdSidebarScrollRange(); + correctSidebarDragLayout(dragTransaction); + return; + } + if (pinnedReorderInFlightRef.current) return; + if (!autoAnimatePausedRef.current) { + releaseSidebarScrollRangeIfSafe(); + return; + } + correctSidebarLayoutAnchor(); + autoAnimatePausedRef.current = false; + const viewport = sidebarViewportRef.current; + if (viewport !== null) { + viewport.style.overflowAnchor = viewportOverflowAnchorRef.current; + } + autoAnimateControllerRef.current?.enable(); + retainedLayoutAnchorRef.current = null; + releaseSidebarScrollRangeIfSafe(); + }); + useEffect(() => { + if (dragTransaction === null) return; + const viewport = sidebarViewportRef.current; + if (viewport === null) return; + const handleScroll = () => { + const correctedScrollTop = correctedScrollTopRef.current; + if (correctedScrollTop !== null && Math.abs(viewport.scrollTop - correctedScrollTop) <= 0.5) { + return; + } + correctedScrollTopRef.current = null; + const retained = retainedLayoutAnchorRef.current; + if (retained === null || !retained.element.isConnected) { + retainSidebarLayoutAnchor(); + return; + } + retainedLayoutAnchorRef.current = { + element: retained.element, + top: retained.element.getBoundingClientRect().top, + }; + }; + viewport.addEventListener("scroll", handleScroll, { passive: true }); + return () => viewport.removeEventListener("scroll", handleScroll); + }, [dragTransaction, retainSidebarLayoutAnchor]); + useEffect(() => { + if (dragTransaction !== null || sidebarScrollRangeHoldRef.current === null) return; + const viewport = sidebarViewportRef.current; + if (viewport === null) return; + const handleScroll = () => { + if (releaseSidebarScrollRangeIfSafe()) { + viewport.removeEventListener("scroll", handleScroll); + } + }; + viewport.addEventListener("scroll", handleScroll, { passive: true }); + return () => viewport.removeEventListener("scroll", handleScroll); + }, [dragTransaction, releaseSidebarScrollRangeIfSafe]); + useEffect(() => { + if (dragTransaction === null || typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(() => { + const transaction = dragTransactionRef.current; + if (transaction !== null) { + holdSidebarScrollRange(); + correctSidebarDragLayout(transaction); + } + }); + if (sidebarViewportRef.current !== null) observer.observe(sidebarViewportRef.current); + if (threadListNodeRef.current !== null) observer.observe(threadListNodeRef.current); + return () => observer.disconnect(); + }, [correctSidebarDragLayout, dragTransaction, holdSidebarScrollRange]); + useEffect( + () => () => { + cleanupTrackedPointer(); + clearSidebarScrollRangeHold(); + autoAnimateControllerRef.current?.destroy?.(); + }, + [cleanupTrackedPointer, clearSidebarScrollRangeHold], + ); const [optimisticPinnedOrder, setOptimisticPinnedOrder] = useState<{ readonly order: readonly string[]; /** pinOrderKey per thread as of the drop — the baseline that tells a @@ -2572,6 +3464,37 @@ export default function Sidebar() { getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), }); }, [optimisticPinnedOrder, pinnedThreads]); + const pinnedSortingOverIndex = useMemo(() => { + const transaction = dragTransaction; + if ( + transaction === null || + transaction.phase !== "dragging" || + transaction.sourceSection !== "pinned" || + transaction.targetSection !== "pinned" || + transaction.targetThreadKey === null || + transaction.targetEdge === null + ) { + return null; + } + const keys = orderedPinnedThreads + .map(sidebarThreadKey) + .filter((threadKey) => reorderablePinnedKeys.has(threadKey)); + const previewOrder = movePinnedThreadAtEdge({ + keys, + activeKey: transaction.sourceThreadKey, + overKey: transaction.targetThreadKey, + edge: transaction.targetEdge, + }); + return previewOrder?.indexOf(transaction.sourceThreadKey) ?? null; + }, [dragTransaction, orderedPinnedThreads, reorderablePinnedKeys]); + const pinnedSortingStrategy = useCallback( + (args) => + verticalListSortingStrategy({ + ...args, + overIndex: pinnedSortingOverIndex ?? args.overIndex, + }), + [pinnedSortingOverIndex], + ); useEffect(() => { if (optimisticPinnedOrder === null) return; const canonical = pinnedThreads.filter((thread) => @@ -2607,6 +3530,7 @@ export default function Sidebar() { !membershipChanged && canonicalKeys.every((key, index) => key === optimisticPinnedOrder.order[index]); if (membershipChanged || foreignKeyLanded || allAssignmentsLanded || orderConfirmed) { + pinnedReorderInFlightRef.current = false; setOptimisticPinnedOrder(null); } }, [optimisticPinnedOrder, pinnedThreads, reorderablePinnedKeys]); @@ -2614,9 +3538,8 @@ export default function Sidebar() { (threadRef: ScopedThreadRef) => { void (async () => { // Fresh pins take the top of the arranged run: pinThread computes a - // key before the smallest key across ALL pinned shells — including - // snoozed pins hidden from this list, whose keys are still part of - // the run — so the new pin can't land beneath a hidden head. + // key before the smallest key across all pinned shells, so the new + // pin lands at the head of the global arranged run. const result = await pinThread(threadRef); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); @@ -2651,21 +3574,30 @@ export default function Sidebar() { [unpinThread], ); - const handlePinnedDragEnd = useCallback( - (event: DragEndEvent) => { - const activeKey = String(event.active.id); - const overKey = event.over === null ? null : String(event.over.id); - if (overKey === null || activeKey === overKey) return; + const handlePinnedReorder = useCallback( + (activeKey: string, overKey: string | null, targetEdge: "before" | "after" | null) => { + if ( + pinnedReorderInFlightRef.current || + overKey === null || + targetEdge === null || + activeKey === overKey + ) { + return; + } const reorderable = orderedPinnedThreads.filter((thread) => reorderablePinnedKeys.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), ); const keys = reorderable.map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), ); - const fromIndex = keys.indexOf(activeKey); - const toIndex = keys.indexOf(overKey); - if (fromIndex === -1 || toIndex === -1) return; - const newOrder = arrayMove([...keys], fromIndex, toIndex); + const newOrder = movePinnedThreadAtEdge({ + keys, + activeKey, + overKey, + edge: targetEdge, + }); + if (newOrder === null) return; + if (newOrder.every((key, index) => key === keys[index])) return; const threadByKey = new Map(reorderable.map((thread, index) => [keys[index]!, thread])); const keysAtDrop = new Map( reorderable.map((thread, index) => [keys[index]!, thread.pinOrderKey ?? null]), @@ -2676,6 +3608,7 @@ export default function Sidebar() { movedId: activeKey, }); if (assignments.length === 0) return; + pinnedReorderInFlightRef.current = true; setOptimisticPinnedOrder({ order: newOrder, keysAtDrop, @@ -2700,6 +3633,7 @@ export default function Sidebar() { // Any failure — interrupted included — releases the override: // a key that never lands would otherwise hold it until some // unrelated world change came along. + pinnedReorderInFlightRef.current = false; setOptimisticPinnedOrder(null); if (isAtomCommandInterrupted(result)) return; const error = squashAtomCommandFailure(result); @@ -2717,6 +3651,53 @@ export default function Sidebar() { }, [orderedPinnedThreads, reorderPinnedThread, reorderablePinnedKeys], ); + const planPinnedInsertion = useCallback( + (transaction: SidebarThreadDragTransaction): SidebarPinnedInsertionPlan | null => { + if (transaction.sourceSection === "pinned" || transaction.targetSection !== "pinned") { + return null; + } + const existingKeys = allPinnedThreads.map(sidebarThreadKey); + let insertionIndex = existingKeys.length; + if (transaction.targetThreadKey !== null) { + const targetIndex = existingKeys.indexOf(transaction.targetThreadKey); + if (targetIndex !== -1) { + insertionIndex = targetIndex + (transaction.targetEdge === "after" ? 1 : 0); + } + } else if (existingKeys.length === 0) { + insertionIndex = 0; + } + const order = [...existingKeys]; + order.splice(insertionIndex, 0, transaction.sourceThreadKey); + const threadByKey = new Map( + allPinnedThreads.map((thread) => [sidebarThreadKey(thread), thread] as const), + ); + threadByKey.set(transaction.sourceThreadKey, transaction.sourceThread); + const keysById = new Map( + allPinnedThreads.map((thread) => [sidebarThreadKey(thread), thread.pinOrderKey ?? null]), + ); + keysById.set(transaction.sourceThreadKey, null); + const assignments = planPinnedReorder({ + orderedIds: order, + keysById, + movedId: transaction.sourceThreadKey, + }); + if (assignments.length === 0) return null; + for (const assignment of assignments) { + const thread = threadByKey.get(assignment.id); + if (thread === undefined) return null; + const capabilities = serverConfigs.get(thread.environmentId)?.environment.capabilities; + if (assignment.id === transaction.sourceThreadKey) { + if (capabilities?.threadPinning !== true || capabilities.threadPinReorder !== true) { + return null; + } + } else if (capabilities?.threadPinReorder !== true) { + return null; + } + } + return { order, assignments, threadByKey }; + }, + [allPinnedThreads, serverConfigs], + ); // One snooze per thread at a time — same double-dispatch guard as settle. const snoozingThreadKeysRef = useRef(new Set()); const performSnooze = useCallback( @@ -2746,7 +3727,7 @@ export default function Sidebar() { if (routeThreadKeyRef.current === threadKey) { navigateAfterSnooze?.(); } - return { status: "success" } as const; + return { status: "success", sequence: result.value.sequence } as const; } finally { snoozingThreadKeysRef.current.delete(threadKey); } @@ -2773,15 +3754,15 @@ export default function Sidebar() { return; } if (outcome.status !== "success") return; - // Snooze hides the row, so the toast is the only confirmation — - // and the Undo is the escape hatch for a mis-click. + // Snooze hides the row, so the toast is the only confirmation. Wake + // is explicit: Snooze no longer promises to restore an old category. toastManager.add( stackedThreadToast({ type: "success", title: `Snoozed until ${snoozeWakeDescription(preset.snoozedUntil, new Date(), timestampFormat)}`, timeout: 5_000, actionProps: { - children: "Undo", + children: "Wake", onClick: () => attemptUnsnooze(threadRef), }, }), @@ -2791,6 +3772,595 @@ export default function Sidebar() { [attemptUnsnooze, performSnooze, timestampFormat], ); + const finishSidebarDragTransaction = useCallback( + (options: { excludeSource?: boolean } = {}) => { + const transaction = dragTransactionRef.current; + snoozeDropEpochRef.current += 1; + if (transaction?.phase === "awaiting-snooze-choice") { + void readLocalApi()?.contextMenu.close(); + } + cleanupTrackedPointer(); + retainSidebarLayoutAnchor( + options.excludeSource || transaction === null + ? null + : (threadRowNodesRef.current.get(transaction.sourceThreadKey) ?? null), + options.excludeSource && transaction !== null ? transaction.sourceThreadKey : null, + ); + setDragTransaction(null); + }, + [cleanupTrackedPointer, retainSidebarLayoutAnchor, setDragTransaction], + ); + const beginSidebarDropReconciliation = useCallback( + (input: { + transaction: SidebarThreadDragTransaction; + destinationSection: SidebarDndSection; + receiptSequencesByEnvironment: ReadonlyMap; + pinnedOrder?: readonly string[] | null; + snoozedUntil?: string | null; + }) => { + retainSidebarLayoutAnchor(null, input.transaction.sourceThreadKey); + setDragTransaction({ + ...input.transaction, + phase: "reconciling", + targetSection: input.destinationSection, + destinationSection: input.destinationSection, + pinnedOrder: input.pinnedOrder ?? null, + snoozedUntil: input.snoozedUntil ?? null, + receiptSequencesByEnvironment: input.receiptSequencesByEnvironment, + }); + }, + [retainSidebarLayoutAnchor, setDragTransaction], + ); + const reportSidebarDropFailure = useCallback( + ( + title: string, + result: Parameters[0] & { readonly _tag: "Failure" }, + ) => { + if (isAtomCommandInterrupted(result)) return; + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title, + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + }, + [], + ); + const sourceStillMatchesDragStart = useCallback((transaction: SidebarThreadDragTransaction) => { + const current = allThreadByKeyRef.current.get(transaction.sourceThreadKey); + return ( + current !== undefined && + current.archivedAt === null && + canonicalSectionByThreadKeyRef.current.get(transaction.sourceThreadKey) === + transaction.sourceSection + ); + }, []); + const commitSidebarLifecycleDrop = useCallback( + ( + transaction: SidebarThreadDragTransaction, + destinationSection: SidebarDndSection, + action: Exclude, + pinnedPlan: SidebarPinnedInsertionPlan | null, + ) => { + void (async () => { + if (!sourceStillMatchesDragStart(transaction)) { + finishSidebarDragTransaction(); + return; + } + setDragTransaction({ + ...transaction, + phase: "committing", + targetSection: destinationSection, + destinationSection, + pinnedOrder: pinnedPlan?.order ?? null, + snoozedUntil: null, + receiptSequencesByEnvironment: null, + }); + const threadRef = scopeThreadRef( + transaction.sourceThread.environmentId, + transaction.sourceThread.id, + ); + const receiptSequences = new Map(); + const recordReceipt = ( + environmentId: EnvironmentThreadShell["environmentId"], + sequence: number, + ) => { + receiptSequences.set( + environmentId, + Math.max(receiptSequences.get(environmentId) ?? 0, sequence), + ); + }; + if (action === "pin") { + if (pinnedPlan === null) { + finishSidebarDragTransaction(); + return; + } + for (const assignment of pinnedPlan.assignments) { + if (assignment.id === transaction.sourceThreadKey) continue; + const thread = pinnedPlan.threadByKey.get(assignment.id); + if (thread === undefined) { + finishSidebarDragTransaction(); + return; + } + const result = await reorderPinnedThread( + scopeThreadRef(thread.environmentId, thread.id), + assignment.orderKey, + ); + if (result._tag === "Failure") { + finishSidebarDragTransaction(); + reportSidebarDropFailure("Failed to prepare pinned order", result); + return; + } + recordReceipt(thread.environmentId, result.value.sequence); + } + const sourceAssignment = pinnedPlan.assignments.find( + (assignment) => assignment.id === transaction.sourceThreadKey, + ); + if (sourceAssignment === undefined) { + finishSidebarDragTransaction(); + return; + } + const result = await pinThread(threadRef, { orderKey: sourceAssignment.orderKey }); + if (result._tag === "Failure") { + finishSidebarDragTransaction(); + reportSidebarDropFailure("Failed to pin thread", result); + return; + } + recordReceipt(transaction.sourceThread.environmentId, result.value.sequence); + beginSidebarDropReconciliation({ + transaction, + destinationSection, + receiptSequencesByEnvironment: receiptSequences, + pinnedOrder: pinnedPlan.order, + }); + return; + } + + const navigateAfterSettle = + action === "settle" ? planForwardNavigation(transaction.sourceThreadKey) : null; + const result = + action === "unpin" + ? await unpinThread(threadRef) + : action === "unsettle" + ? await unsettleThread(threadRef) + : action === "unsnooze" + ? await unsnoozeThread(threadRef) + : await settleThread(threadRef); + if (result._tag === "Failure") { + finishSidebarDragTransaction(); + reportSidebarDropFailure( + action === "unpin" + ? "Failed to unpin thread" + : action === "unsettle" + ? "Failed to un-settle thread" + : action === "unsnooze" + ? "Failed to wake thread" + : "Failed to settle thread", + result, + ); + return; + } + if (action === "settle") { + if (routeThreadKeyRef.current === transaction.sourceThreadKey) { + navigateAfterSettle?.(); + } + } + recordReceipt(transaction.sourceThread.environmentId, result.value.sequence); + beginSidebarDropReconciliation({ + transaction, + destinationSection, + receiptSequencesByEnvironment: receiptSequences, + }); + })(); + }, + [ + beginSidebarDropReconciliation, + finishSidebarDragTransaction, + pinThread, + planForwardNavigation, + reorderPinnedThread, + reportSidebarDropFailure, + setDragTransaction, + settleThread, + sourceStillMatchesDragStart, + unpinThread, + unsettleThread, + unsnoozeThread, + ], + ); + const openSidebarSnoozeDropMenu = useCallback( + (transaction: SidebarThreadDragTransaction, position: { x: number; y: number }) => { + const epoch = snoozeDropEpochRef.current + 1; + snoozeDropEpochRef.current = epoch; + setDragTransaction({ + ...transaction, + phase: "awaiting-snooze-choice", + targetSection: "snoozed", + destinationSection: "snoozed", + pinnedOrder: null, + snoozedUntil: null, + receiptSequencesByEnvironment: null, + }); + void (async () => { + const api = readLocalApi(); + if (api === undefined) { + finishSidebarDragTransaction(); + return; + } + const menuPresets = resolveSnoozePresets(new Date(), timestampFormat); + const selected = await settlePromise(() => + api.contextMenu.show( + menuPresets.map((preset) => ({ + id: `snooze:${preset.id}`, + label: `${preset.label} (${preset.whenLabel})`, + })), + position, + ), + ); + if (snoozeDropEpochRef.current !== epoch) return; + if (selected._tag === "Failure" || selected.value === null) { + finishSidebarDragTransaction(); + return; + } + const selectedId = selected.value.startsWith("snooze:") + ? selected.value.slice("snooze:".length) + : null; + const preset = resolveSnoozePresets(new Date(), timestampFormat).find( + (candidate) => candidate.id === selectedId, + ); + if (preset === undefined || !sourceStillMatchesDragStart(transaction)) { + finishSidebarDragTransaction(); + return; + } + setDragTransaction({ + ...transaction, + phase: "committing", + targetSection: "snoozed", + destinationSection: "snoozed", + pinnedOrder: null, + snoozedUntil: preset.snoozedUntil, + receiptSequencesByEnvironment: null, + }); + const threadRef = scopeThreadRef( + transaction.sourceThread.environmentId, + transaction.sourceThread.id, + ); + const outcome = await performSnooze(threadRef, preset); + if (outcome.status === "failure") { + finishSidebarDragTransaction(); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to snooze thread", + description: + outcome.error instanceof Error ? outcome.error.message : "An error occurred.", + }), + ); + return; + } + if (outcome.status !== "success") { + finishSidebarDragTransaction(); + return; + } + toastManager.add( + stackedThreadToast({ + type: "success", + title: `Snoozed until ${snoozeWakeDescription(preset.snoozedUntil, new Date(), timestampFormat)}`, + timeout: 5_000, + actionProps: { + children: "Wake", + onClick: () => attemptUnsnooze(threadRef), + }, + }), + ); + beginSidebarDropReconciliation({ + transaction, + destinationSection: "snoozed", + receiptSequencesByEnvironment: new Map([ + [transaction.sourceThread.environmentId, outcome.sequence], + ]), + snoozedUntil: preset.snoozedUntil, + }); + })(); + }, + [ + attemptUnsnooze, + beginSidebarDropReconciliation, + finishSidebarDragTransaction, + performSnooze, + setDragTransaction, + sourceStillMatchesDragStart, + timestampFormat, + ], + ); + + const threadCollisionDetection = useCallback((args) => { + const pointerCollisions = pointerWithin(args); + if (pointerCollisions.length > 0) { + const viewportRailSections = viewportRailSectionsRef.current; + return pointerCollisions.toSorted((left, right) => { + const leftId = parseSidebarDndId(left.id); + const rightId = parseSidebarDndId(right.id); + const priority = (id: ReturnType) => { + if (id?.kind === "section" && viewportRailSections.has(id.section)) { + return 0; + } + return id?.kind === "section" ? 2 : 1; + }; + return priority(leftId) - priority(rightId); + }); + } + return closestCenter(args); + }, []); + const handleThreadDragStart = useCallback( + (event: DragStartEvent) => { + if (pinnedReorderInFlightRef.current) return; + const id = parseSidebarDndId(event.active.id); + if (id === null || id.kind !== "draggable") return; + const sourceThread = allThreadByKeyRef.current.get(id.threadKey); + const sourceNode = threadRowNodesRef.current.get(id.threadKey); + if (sourceThread === undefined || sourceNode === undefined) return; + const sourceSection = canonicalSectionByThreadKeyRef.current.get(id.threadKey); + if (sourceSection === undefined || !canDragThread(sourceThread, sourceSection)) return; + const sourceRect = sourceNode.getBoundingClientRect(); + const activator = event.activatorEvent instanceof PointerEvent ? event.activatorEvent : null; + const pointer = { + x: + activator !== null && Number.isFinite(activator.clientX) + ? activator.clientX + : sourceRect.left + sourceRect.width / 2, + y: + activator !== null && Number.isFinite(activator.clientY) + ? activator.clientY + : sourceRect.top + sourceRect.height / 2, + }; + cleanupTrackedPointer(); + rawPointerRef.current = pointer; + releasePointerRef.current = null; + activePointerIdRef.current = + activator !== null && Number.isFinite(activator.pointerId) ? activator.pointerId : null; + const updatePointer = (pointerEvent: PointerEvent) => { + if ( + activePointerIdRef.current !== null && + pointerEvent.pointerId !== activePointerIdRef.current + ) { + return; + } + rawPointerRef.current = { x: pointerEvent.clientX, y: pointerEvent.clientY }; + }; + const captureReleasePointer = (pointerEvent: PointerEvent) => { + if ( + activePointerIdRef.current !== null && + pointerEvent.pointerId !== activePointerIdRef.current + ) { + return; + } + releasePointerRef.current = { x: pointerEvent.clientX, y: pointerEvent.clientY }; + }; + window.addEventListener("pointermove", updatePointer, true); + window.addEventListener("pointerup", captureReleasePointer, true); + window.addEventListener("pointercancel", captureReleasePointer, true); + pointerListenerCleanupRef.current = () => { + window.removeEventListener("pointermove", updatePointer, true); + window.removeEventListener("pointerup", captureReleasePointer, true); + window.removeEventListener("pointercancel", captureReleasePointer, true); + }; + const sections = { + pinned: orderedPinnedThreads, + regular: activeThreads, + snoozed: visibleSnoozedThreads, + settled: renderedSettledThreads, + } satisfies Readonly>; + pauseSidebarLayoutMotion(); + holdSidebarScrollRange(); + retainSidebarLayoutAnchor(sourceNode); + setDragTransaction({ + phase: "dragging", + sourceThread, + sourceThreadKey: id.threadKey, + sourceSection, + sourceIndex: sidebarDndSectionIndex(sourceSection, id.threadKey, sections), + sourceRect: { + left: sourceRect.left, + top: sourceRect.top, + width: sourceRect.width, + height: sourceRect.height, + }, + pointerAnchor: captureSidebarDndPointerAnchor({ pointer, sourceRect }), + targetSection: sourceSection, + targetThreadKey: id.threadKey, + targetEdge: null, + destinationSection: null, + pinnedOrder: null, + snoozedUntil: null, + receiptSequencesByEnvironment: null, + viewportRailTopBySection: null, + }); + }, + [ + activeThreads, + canDragThread, + cleanupTrackedPointer, + holdSidebarScrollRange, + orderedPinnedThreads, + pauseSidebarLayoutMotion, + renderedSettledThreads, + retainSidebarLayoutAnchor, + setDragTransaction, + visibleSnoozedThreads, + ], + ); + const resolveThreadDropTarget = useCallback( + ( + current: SidebarThreadDragTransaction, + over: DragMoveEvent["over"], + ): SidebarThreadDropTarget | null => { + if (over === null) return null; + const overId = parseSidebarDndId(over.id); + if (overId === null) { + return null; + } + const destination = overId.section; + if (!canDropThreadInSection(current.sourceThread, current.sourceSection, destination)) { + return null; + } + let targetThreadKey = overId.kind === "section" ? null : overId.threadKey; + let targetEdge: "before" | "after" | null = null; + const pointerY = rawPointerRef.current?.y ?? over.rect.top + over.rect.height / 2; + if (targetThreadKey !== null) { + if (destination === "pinned" && !reorderablePinnedKeys.has(targetThreadKey)) return null; + targetEdge = pointerY < over.rect.top + over.rect.height / 2 ? "before" : "after"; + } else if (destination === "pinned" && orderedPinnedThreads.length > 0) { + const first = orderedPinnedThreads[0]; + const last = orderedPinnedThreads.at(-1); + const before = pointerY < over.rect.top + over.rect.height / 2; + const target = before ? first : last; + if (target !== undefined) { + targetThreadKey = sidebarThreadKey(target); + targetEdge = before ? "before" : "after"; + } + } + return { targetSection: destination, targetThreadKey, targetEdge }; + }, + [canDropThreadInSection, orderedPinnedThreads, reorderablePinnedKeys], + ); + const updateThreadDragTarget = useCallback( + (over: DragMoveEvent["over"]) => { + const current = dragTransactionRef.current; + if (current === null || current.phase !== "dragging") return; + const target = resolveThreadDropTarget(current, over); + if (target === null) { + if (current.targetSection === null) return; + setDragTransaction({ + ...current, + targetSection: null, + targetThreadKey: null, + targetEdge: null, + }); + return; + } + if ( + current.targetSection === target.targetSection && + current.targetThreadKey === target.targetThreadKey && + current.targetEdge === target.targetEdge + ) { + return; + } + setDragTransaction({ + ...current, + ...target, + }); + }, + [resolveThreadDropTarget, setDragTransaction], + ); + const handleThreadDragMove = useCallback( + (event: DragMoveEvent) => updateThreadDragTarget(event.over), + [updateThreadDragTarget], + ); + const handleThreadDragOver = useCallback( + (event: DragOverEvent) => updateThreadDragTarget(event.over), + [updateThreadDragTarget], + ); + const handleThreadDragCancel = useCallback( + (_event: DragCancelEvent) => finishSidebarDragTransaction(), + [finishSidebarDragTransaction], + ); + const handleThreadDragEnd = useCallback( + (event: DragEndEvent) => { + const current = dragTransactionRef.current; + const releasePoint = releasePointerRef.current ?? rawPointerRef.current; + const target = + current !== null && current.phase === "dragging" + ? resolveThreadDropTarget(current, event.over) + : null; + cleanupTrackedPointer(); + if (current === null || current.phase !== "dragging" || target === null) { + finishSidebarDragTransaction(); + return; + } + const finalized = { ...current, ...target }; + const action = resolveSidebarDndAction({ + source: finalized.sourceSection, + destination: finalized.targetSection, + }); + if (action === "noop") { + finishSidebarDragTransaction(); + return; + } + if (action === "reorder-pinned") { + handlePinnedReorder( + finalized.sourceThreadKey, + finalized.targetThreadKey, + finalized.targetEdge, + ); + finishSidebarDragTransaction(); + return; + } + if (action === "snooze") { + openSidebarSnoozeDropMenu( + finalized, + releasePoint ?? { + x: finalized.sourceRect.left + finalized.sourceRect.width / 2, + y: finalized.sourceRect.top + finalized.sourceRect.height / 2, + }, + ); + return; + } + const pinnedPlan = action === "pin" ? planPinnedInsertion(finalized) : null; + if (action === "pin" && pinnedPlan === null) { + finishSidebarDragTransaction(); + return; + } + commitSidebarLifecycleDrop(finalized, finalized.targetSection, action, pinnedPlan); + }, + [ + cleanupTrackedPointer, + commitSidebarLifecycleDrop, + finishSidebarDragTransaction, + handlePinnedReorder, + openSidebarSnoozeDropMenu, + planPinnedInsertion, + resolveThreadDropTarget, + ], + ); + useLayoutEffect(() => { + if ( + dragTransaction === null || + dragTransaction.phase !== "reconciling" || + dragTransaction.receiptSequencesByEnvironment === null + ) { + return; + } + for (const [environmentId, receiptSequence] of dragTransaction.receiptSequencesByEnvironment) { + const snapshot = appAtomRegistry.get(environmentSnapshotAtom(environmentId)); + if (snapshot === null || snapshot.snapshotSequence < receiptSequence) return; + } + // Once every owning shell has crossed its receipt, canonical state is + // authoritative. A different section here is a later writer, not a UI + // state the local drop should keep masking. + finishSidebarDragTransaction({ excludeSource: true }); + }, [dragTransaction, finishSidebarDragTransaction, threads]); + useLayoutEffect(() => { + if ( + dragTransaction === null || + (dragTransaction.phase !== "dragging" && dragTransaction.phase !== "awaiting-snooze-choice") + ) { + return; + } + if (isSearchingThreads || !sourceStillMatchesDragStart(dragTransaction)) { + finishSidebarDragTransaction(); + } + }, [ + dragTransaction, + finishSidebarDragTransaction, + isSearchingThreads, + projectScopeKey, + sourceStillMatchesDragStart, + threads, + ]); + const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection); const handleMultiSelectContextMenu = useCallback( async (position: { x: number; y: number }) => { @@ -2893,7 +4463,7 @@ export default function Sidebar() { : undefined, timeout: 5_000, actionProps: { - children: "Undo", + children: "Wake", onClick: () => { for (const threadRef of snoozedThreadRefs) attemptUnsnooze(threadRef); }, @@ -3033,9 +4603,9 @@ export default function Sidebar() { thread.worktreePath ?? projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null; - // Un-settle works on every settled row: for explicit settles it - // clears the override, for auto-settled rows it pins the thread - // active until real activity clears the pin. Environments without + // Un-settle works on every settled row. For explicit settles it + // clears the override; for auto-settled rows it keeps the thread + // active until real activity clears that choice. Environments without // the settlement capability get no lifecycle items at all. const supportsSettlement = serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === @@ -3325,10 +4895,178 @@ export default function Sidebar() { setShowJumpHints(shouldShowJumpHintsNow); }, [shouldShowJumpHintsNow]); - const attachListAutoAnimateRef = useCallback((node: HTMLUListElement | null) => { - if (!node) return; - autoAnimate(node, { duration: 150, easing: "ease-out" }); - }, []); + const boardSections = useMemo(() => { + let pinned = [...orderedPinnedThreads]; + let regular = [...activeThreads]; + let snoozed = [...visibleSnoozedThreads]; + let settled = [...renderedSettledThreads]; + const transaction = dragTransaction; + if (transaction === null) return { pinned, regular, snoozed, settled }; + + const withoutSource = (items: readonly EnvironmentThreadShell[]) => + items.filter((thread) => sidebarThreadKey(thread) !== transaction.sourceThreadKey); + pinned = withoutSource(pinned); + regular = withoutSource(regular); + snoozed = withoutSource(snoozed); + settled = withoutSource(settled); + + if (transaction.phase !== "reconciling" || transaction.destinationSection === null) { + switch (transaction.sourceSection) { + case "pinned": + pinned = insertSidebarThreadAt(pinned, transaction.sourceThread, transaction.sourceIndex); + break; + case "regular": + regular = insertSidebarThreadAt( + regular, + transaction.sourceThread, + transaction.sourceIndex, + ); + break; + case "snoozed": + snoozed = insertSidebarThreadAt( + snoozed, + transaction.sourceThread, + transaction.sourceIndex, + ); + break; + case "settled": + settled = insertSidebarThreadAt( + settled, + transaction.sourceThread, + transaction.sourceIndex, + ); + break; + } + return { pinned, regular, snoozed, settled }; + } + + const now = new Date().toISOString(); + switch (transaction.destinationSection) { + case "pinned": { + const optimistic = { + ...transaction.sourceThread, + pinnedAt: now, + settledOverride: "active" as const, + settledAt: null, + snoozedAt: null, + snoozedUntil: null, + }; + pinned = orderItemsByPreferredIds({ + items: [...pinned, optimistic], + preferredIds: transaction.pinnedOrder ?? [], + getId: sidebarThreadKey, + }); + break; + } + case "regular": + regular = sortThreadsForSidebar([ + ...regular, + { + ...transaction.sourceThread, + pinnedAt: null, + pinOrderKey: null, + settledOverride: "active" as const, + settledAt: null, + snoozedAt: null, + snoozedUntil: null, + }, + ]); + break; + case "snoozed": + snoozed = [ + ...snoozed, + { + ...transaction.sourceThread, + pinnedAt: null, + pinOrderKey: null, + settledOverride: "active" as const, + settledAt: null, + snoozedAt: now, + snoozedUntil: transaction.snoozedUntil, + }, + ].toSorted( + (left, right) => + firstValidTimestampMs(left.snoozedUntil ?? null) - + firstValidTimestampMs(right.snoozedUntil ?? null), + ); + break; + case "settled": + settled = sortSettledThreadsForSidebar([ + ...settled, + { + ...transaction.sourceThread, + pinnedAt: null, + pinOrderKey: null, + settledOverride: "settled" as const, + settledAt: now, + snoozedAt: null, + snoozedUntil: null, + }, + ]); + break; + } + return { pinned, regular, snoozed, settled }; + }, [ + activeThreads, + dragTransaction, + orderedPinnedThreads, + renderedSettledThreads, + visibleSnoozedThreads, + ]); + const dropIndicatorByThreadKey = useMemo(() => { + const indicators = new Map(); + const transaction = dragTransaction; + if ( + transaction === null || + transaction.phase === "reconciling" || + transaction.targetThreadKey === null || + transaction.targetEdge === null + ) { + return indicators; + } + indicators.set(transaction.targetThreadKey, transaction.targetEdge); + return indicators; + }, [dragTransaction]); + const isTemporarySectionRailVisible = useCallback( + (section: SidebarDndSection) => { + const transaction = dragTransaction; + if (transaction === null || transaction.phase === "reconciling") return false; + const sectionIsEmpty = + boardSections[section].length === 0 && + (section !== "snoozed" || snoozedThreads.length === 0) && + (section !== "settled" || settledThreads.length === 0); + return ( + sectionIsEmpty && + canDropThreadInSection(transaction.sourceThread, transaction.sourceSection, section) + ); + }, + [ + boardSections, + canDropThreadInSection, + dragTransaction, + settledThreads.length, + snoozedThreads.length, + ], + ); + const dragPreviewVariant = + dragTransaction?.phase === "dragging" + ? resolveSidebarDndPreviewVariant({ + source: dragTransaction.sourceSection, + destination: dragTransaction.targetSection, + }) + : null; + + const attachListAutoAnimateRef = useCallback( + (node: HTMLUListElement | null) => { + if (threadListNodeRef.current === node) return; + clearSidebarScrollRangeHold(); + autoAnimateControllerRef.current?.destroy?.(); + threadListNodeRef.current = node; + autoAnimateControllerRef.current = + node === null ? null : autoAnimate(node, { duration: 150, easing: "ease-out" }); + }, + [clearSidebarScrollRangeHold], + ); // New thread defaults to the project you're in (active thread's project, // falling back to the top project) — same resolution the command palette @@ -3371,6 +5109,8 @@ export default function Sidebar() { <> -
      - {(() => { - const renderThreadRow = ( - thread: EnvironmentThreadShell, - section: "pinned" | "active" | "snoozed" | "settled", - sortable?: SortablePinnedRowBag, - ) => { - const threadKey = scopedThreadKey( - scopeThreadRef(thread.environmentId, thread.id), - ); - // Settled and snoozed are the ONLY things that collapse a - // row: every other thread is a full card. Density comes - // from users (or the auto rules) actually parking work, - // not from the sidebar second-guessing what still matters. - const isCard = section === "active" || section === "pinned"; - const rowVariant = isCard ? "card" : "slim"; - return ( - - ); - }; - // Draft block above everything, then the pinned block: - // full cards above the inbox, closed by a thin divider (the - // pin glyphs carry the meaning, so no header text). Both - // vanish entirely at count 0. - // Pinned rows render in the one shared pinned order; only - // reorder-capable rows register as sortable (legacy-server - // pins render in place as plain rows). - const items: ReactNode[] = [ - , - pinnedThreads.length > 0 ? ( -
    • - element === sidebarViewportRef.current, + }} + onDragStart={handleThreadDragStart} + onDragMove={handleThreadDragMove} + onDragOver={handleThreadDragOver} + onDragCancel={handleThreadDragCancel} + onDragEnd={handleThreadDragEnd} + > +
        + {(() => { + const activeDropTransaction = + dragTransaction?.phase === "dragging" ? dragTransaction : null; + const sectionDropDisabled = (section: SidebarDndSection) => + activeDropTransaction === null || + !canDropThreadInSection( + activeDropTransaction.sourceThread, + activeDropTransaction.sourceSection, + section, + ); + const renderThreadRow = ( + thread: EnvironmentThreadShell, + section: SidebarDndSection, + ) => { + const threadKey = sidebarThreadKey(thread); + const isCard = section === "regular" || section === "pinned"; + const rowVariant = isCard ? "card" : "slim"; + const dndDimmed = + dragTransaction?.sourceThreadKey === threadKey && + dragTransaction.phase !== "reconciling"; + const dndInert = + dragTransaction?.sourceThreadKey === threadKey && + dragTransaction.phase !== "dragging"; + const renderVisualRow = (dnd: SidebarThreadDndRowBag) => ( + + ); + const dragDisabled = + optimisticPinnedOrder !== null || + !canDragThread(thread, section) || + (dragTransaction !== null && dragTransaction.phase !== "dragging"); + const dropDisabled = sectionDropDisabled(section); + const rowKey = `${threadKey}:${rowVariant}`; + return section === "pinned" && reorderablePinnedKeys.has(threadKey) ? ( + - - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ) - .filter((threadKey) => reorderablePinnedKeys.has(threadKey))} - strategy={verticalListSortingStrategy} - > -
          - {orderedPinnedThreads.map((thread) => { - const threadKey = scopedThreadKey( - scopeThreadRef(thread.environmentId, thread.id), - ); - if (!reorderablePinnedKeys.has(threadKey)) { - return renderThreadRow(thread, "pinned"); - } - return ( - - {(bag) => renderThreadRow(thread, "pinned", bag)} - - ); - })} -
        -
        - - - ) : null, - ]; - if (pinnedThreads.length > 0) { - items.push( -
      • , - ); - } - for (const thread of activeThreads) { - items.push(renderThreadRow(thread, "active")); - } - // Snoozed shelf: between the inbox and Settled — out of the - // way, never gone. The header always renders while anything - // is snoozed (the count is the whole footprint when - // collapsed); rows only when expanded. Vanishes entirely at - // count 0. - if (snoozedThreads.length > 0) { - items.push( -
      • - -
      • , + {renderVisualRow} + + ); + }; + const rail = (section: SidebarDndSection, label: string, isOver: boolean) => ( +
        +
        + {label} +
        +
        ); - for (const thread of visibleSnoozedThreads) { - items.push(renderThreadRow(thread, "snoozed")); + const showPinnedRail = isTemporarySectionRailVisible("pinned"); + const showRegularRail = isTemporarySectionRailVisible("regular"); + const showSnoozedRail = isTemporarySectionRailVisible("snoozed"); + const showSettledRail = isTemporarySectionRailVisible("settled"); + const visibleRailBySection = new Map([ + ["pinned", showPinnedRail], + ["regular", showRegularRail], + ["snoozed", showSnoozedRail], + ["settled", showSettledRail], + ]); + const viewportRailTopBySection = dragTransaction?.viewportRailTopBySection; + const viewportOverlayHost = sidebarViewportOverlayRef.current; + const viewportRailSections = new Set(); + if ( + viewportRailTopBySection !== null && + viewportRailTopBySection !== undefined + ) { + for (const section of viewportRailTopBySection.keys()) { + if ( + visibleRailBySection.get(section) === true && + viewportOverlayHost !== null + ) { + viewportRailSections.add(section); + } + } } - } - if (settledThreads.length > 0) { - items.push( -
      • - -
      • , + ) : null} + + {({ setNodeRef, isOver }) => { + const viewportRail = showRegularRail + ? renderViewportRail("regular", "Regular", isOver, setNodeRef) + : null; + if (viewportRail !== null) return viewportRail; + return ( +
      • +
          + {boardSections.regular.map((thread) => + renderThreadRow(thread, "regular"), + )} +
        + {showRegularRail ? rail("regular", "Regular", isOver) : null} +
      • + ); + }} +
        + + {({ setNodeRef, isOver }) => { + const collapsedHeaderDropOver = isOver && !snoozedShelfExpanded; + const viewportRail = showSnoozedRail + ? renderViewportRail("snoozed", "Snooze", isOver, setNodeRef) + : null; + if (viewportRail !== null) return viewportRail; + return ( +
      • + {snoozedThreads.length > 0 ? ( +
        + +
        + ) : null} +
          + {boardSections.snoozed.map((thread) => + renderThreadRow(thread, "snoozed"), + )} +
        + {showSnoozedRail ? rail("snoozed", "Snooze", isOver) : null} +
      • + ); + }} +
        + + {({ setNodeRef, isOver }) => { + const collapsedHeaderDropOver = isOver && !settledShelfExpanded; + const viewportRail = showSettledRail + ? renderViewportRail("settled", "Settled", isOver, setNodeRef) + : null; + if (viewportRail !== null) return viewportRail; + return ( +
      • + {settledThreads.length > 0 ? ( +
        + +
        + ) : null} +
          + {boardSections.settled.map((thread) => + renderThreadRow(thread, "settled"), + )} +
        + {showSettledRail ? rail("settled", "Settled", isOver) : null} + {settledShelfExpanded && hiddenSettledCount > 0 ? ( + + ) : null} +
      • + ); + }} +
        + ); - } - for (const thread of renderedSettledThreads) { - items.push(renderThreadRow(thread, "settled")); - } - return items; - })()} - {settledShelfExpanded && hiddenSettledCount > 0 ? ( -
      • - -
      • - ) : null} -
      + })()} +
    + + {dragTransaction?.phase === "dragging" && dragPreviewVariant !== null ? ( + + ) : null} + + ) : null} {!isSearchingThreads && diff --git a/apps/web/src/components/sidebar/SidebarThreadDragPreview.tsx b/apps/web/src/components/sidebar/SidebarThreadDragPreview.tsx new file mode 100644 index 000000000000..d41243281d8b --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarThreadDragPreview.tsx @@ -0,0 +1,77 @@ +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; +import { GitBranchIcon, MessageSquareIcon } from "lucide-react"; +import { memo } from "react"; + +import { ProjectFavicon } from "../ProjectFavicon"; + +export interface SidebarThreadDragPreviewProps { + readonly thread: EnvironmentThreadShell; + readonly variant: "card" | "slim"; + readonly projectTitle: string | null; + readonly projectCwd: string | null; + readonly projectFaviconPath: string | null; +} + +export const SidebarThreadDragPreview = memo(function SidebarThreadDragPreview( + props: SidebarThreadDragPreviewProps, +) { + const favicon = ( + + ); + + if (props.variant === "slim") { + return ( +
    + {favicon} + + {props.thread.title} + +
    + ); + } + + return ( +
    +
    +
    +
    + {favicon} + {props.projectTitle ? ( + + {props.projectTitle} + + ) : ( + + )} +
    +
    + + {props.thread.title} + +
    +
    + {props.thread.branch ? ( + <> + + + {props.thread.branch} + + + ) : ( + + )} +
    +
    +
    +
    + ); +}); diff --git a/apps/web/src/components/threadActionMenu.logic.ts b/apps/web/src/components/threadActionMenu.logic.ts index df983ee86773..520ad4710428 100644 --- a/apps/web/src/components/threadActionMenu.logic.ts +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -68,9 +68,8 @@ export function buildThreadActionMenuItems( : { id: "pin" as const, label: "Pin thread", icon: "pin" }, ] : []), - // Both lifecycle actions stay available on pinned threads: settling - // clears the pin ("done" beats "keep on top"), and snoozing hides the - // card until wake with the pin intact. + // Both lifecycle actions stay available on pinned threads. Settling and + // snoozing each move the thread out of Pinned. ...(state.supports.settlement ? [ state.isSettled diff --git a/apps/web/src/components/ui/scroll-area.tsx b/apps/web/src/components/ui/scroll-area.tsx index bfc10825b460..cc15d91c5ec3 100644 --- a/apps/web/src/components/ui/scroll-area.tsx +++ b/apps/web/src/components/ui/scroll-area.tsx @@ -1,9 +1,19 @@ "use client"; import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"; +import type { Ref } from "react"; import { cn } from "~/lib/utils"; +interface ScrollAreaProps extends ScrollAreaPrimitive.Root.Props { + readonly scrollFade?: boolean; + readonly scrollbarGutter?: boolean; + readonly hideScrollbars?: boolean; + readonly chainVerticalScroll?: boolean; + readonly viewportRef?: Ref | undefined; + readonly viewportOverlayRef?: Ref | undefined; +} + function getVirtualizedScrollFadeClassName({ top, bottom }: { top: boolean; bottom: boolean }) { if (!top && !bottom) return undefined; @@ -28,19 +38,17 @@ function ScrollArea({ scrollbarGutter = false, hideScrollbars = false, chainVerticalScroll = false, + viewportRef, + viewportOverlayRef, ...props -}: ScrollAreaPrimitive.Root.Props & { - scrollFade?: boolean; - scrollbarGutter?: boolean; - hideScrollbars?: boolean; - chainVerticalScroll?: boolean; -}) { +}: ScrollAreaProps) { return ( {children} + {viewportOverlayRef !== undefined ? ( +
    + ) : null} {!hideScrollbars && ( <> diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index ce6e4cc78ca9..75cba8953668 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -686,17 +686,29 @@ function SidebarSeparator({ className, ...props }: React.ComponentProps { + readonly fixedHeader?: React.ReactNode; + readonly viewportRef?: React.Ref | undefined; + readonly viewportOverlayRef?: React.Ref | undefined; +} + function SidebarContent({ className, fixedHeader, + viewportRef, + viewportOverlayRef, ...props -}: React.ComponentProps<"div"> & { - fixedHeader?: React.ReactNode; -}) { +}: SidebarContentProps) { return ( <> {fixedHeader ?
    {fixedHeader}
    : null} - +
    { void unsnoozeThread(threadRef).then((undone) => { if (undone._tag === "Failure" && !isAtomCommandInterrupted(undone)) { diff --git a/docs/internals/sidebar-thread-dnd.md b/docs/internals/sidebar-thread-dnd.md new file mode 100644 index 000000000000..c284a6c0ad31 --- /dev/null +++ b/docs/internals/sidebar-thread-dnd.md @@ -0,0 +1,32 @@ +# Sidebar thread drag and drop + +> For maintainers. Using T3 Code? See [docs/user](../user/). + +Status: accepted + +## Decision + +Web and desktop use one sidebar drag-and-drop board for Pinned, Regular, Snooze, and Settled. +Mobile keeps its existing menu actions; it does not add native drag and drop. + +The category part of a cross-section drop dispatches an existing lifecycle command. The decider makes +the category change atomic by emitting the required cleanup events with it. The categories are +exclusive. Pinning, settling, waking, un-settling, and snoozing clear conflicting state in the same +decision. The implementation adds no new command, event, capability, or protocol compatibility path +for drag and drop. + +`thread.pin.reorder` remains separate and key-only. Pinned insertion computes the order keys needed +for the dropped position, then pins the source. Pinned threads use those keys for manual order. +Regular, Snooze, and Settled use their existing sort rules. A cross-section drop into one of those +sections changes state but does not write an arbitrary list index. + +The client holds the source row and layout anchor until each affected environment's shell snapshot +reaches its receipt sequence. This keeps sorted lists from jumping during a drop and lets concurrent +canonical state win once the transaction completes. + +## Rationale + +Lifecycle commands already express the state transitions. Making their decisions atomic keeps a +cross-section move understandable to the server and avoids a temporary state where a thread appears +in two sections. Keeping pinned reorder key-only preserves its existing ordering model while the +other sections remain naturally sorted. diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 274f596bbc50..3c520704a896 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -1,15 +1,31 @@ # Organizing threads -Pin a thread from its context menu to keep it in the pinned section above your active work. -Pinned threads are shown independently of their project, including when you connect to more than -one environment. +The sidebar groups threads into Pinned, Regular, Snooze, and Settled. On web and desktop, drag +any thread between these sections. The sidebar keeps the dragged thread under the pointer and holds +your scroll position while a section appears or changes. +Empty Pinned, Regular, Snooze, and Settled sections appear as drop targets while you drag. -Pinned threads still move to **Settled** when they become inactive. They also move when their pull -request merges if **Auto-settle merged threads** is enabled. +The destination chooses the thread's state: -On web and desktop, drag a pinned thread to change its position. On mobile, open the thread's menu -and choose **Move up** or **Move down**. The order is stored by the server and appears on your -other connected devices. +- Drop in **Pinned** to pin it at that exact position. Pinned threads are shown independently of + their project, including when you connect to more than one environment. +- Drop in **Regular** to unpin, wake, or un-settle it as needed. Regular keeps its normal sort + order. +- Drop in **Settled** to settle it. Settled threads keep their normal history order. +- Drop in **Snooze** to open the usual snooze menu. Choose when the thread should wake. Snoozing + removes it from the other sections, and the row or confirmation toast can wake it again. + +Pinning, settling, waking, and un-settling happen automatically when you drop. Snooze and Settled +are sorted sections, so their order is not manually stored. Pinned is the only section with a +user-controlled order. + +Pinned threads can still move to **Settled** when they become inactive. They also move when their +pull request merges if **Auto-settle merged threads** is enabled. The pin remains visible there. + +You can also pin or settle a thread from its context menu. + +On mobile, open a pinned thread's menu and choose **Move up** or **Move down**. The order is stored +by the server and appears on your other connected devices. If reordering is unavailable for one environment, update the T3 Code server running in that environment. Older servers can still pin and unpin threads, but do not understand synced ordering; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 1c27e6d3c6b4..c7af1d9e4495 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -395,9 +395,8 @@ export const OrchestrationThread = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed(null)), ), settledAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))), - // Snooze is an overlay on the active lifecycle, not a fourth destination: - // a snoozed thread stays "active" in the model and is only suppressed from - // the inbox until snoozedUntil passes (or the thread raises its hand). + // Snooze parks a thread until snoozedUntil passes or the thread raises its + // hand. User lifecycle commands clear the other sidebar category fields. // Optional so payloads from pre-snooze servers still decode. snoozedUntil: Schema.optional(Schema.NullOr(IsoDateTime)), snoozedAt: Schema.optional(Schema.NullOr(IsoDateTime)), From bf2a2ebee866e58e2c3469babf42f1cd29f33983 Mon Sep 17 00:00:00 2001 From: Taras Date: Fri, 21 Aug 2026 19:52:40 +0300 Subject: [PATCH 02/26] refactor(web): split sidebar drag and drop --- apps/web/src/components/Sidebar.dnd.board.ts | 132 + apps/web/src/components/Sidebar.dnd.logic.ts | 43 + apps/web/src/components/Sidebar.tsx | 2467 ++--------------- .../components/sidebar/SidebarThreadBoard.tsx | 407 +++ .../components/sidebar/SidebarThreadDnd.tsx | 274 ++ apps/web/src/hooks/useSidebarDndLayout.ts | 443 +++ apps/web/src/hooks/useSidebarPinnedDnd.ts | 261 ++ apps/web/src/hooks/useSidebarThreadDnd.ts | 878 ++++++ 8 files changed, 2597 insertions(+), 2308 deletions(-) create mode 100644 apps/web/src/components/Sidebar.dnd.board.ts create mode 100644 apps/web/src/components/sidebar/SidebarThreadBoard.tsx create mode 100644 apps/web/src/components/sidebar/SidebarThreadDnd.tsx create mode 100644 apps/web/src/hooks/useSidebarDndLayout.ts create mode 100644 apps/web/src/hooks/useSidebarPinnedDnd.ts create mode 100644 apps/web/src/hooks/useSidebarThreadDnd.ts diff --git a/apps/web/src/components/Sidebar.dnd.board.ts b/apps/web/src/components/Sidebar.dnd.board.ts new file mode 100644 index 000000000000..94864c415597 --- /dev/null +++ b/apps/web/src/components/Sidebar.dnd.board.ts @@ -0,0 +1,132 @@ +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; + +import { sidebarThreadKey, type SidebarDndSection } from "./Sidebar.dnd.logic"; +import type { SidebarThreadDragTransaction } from "./Sidebar.dnd.logic"; +import { + firstValidTimestampMs, + orderItemsByPreferredIds, + sortSettledThreadsForSidebar, + sortThreadsForSidebar, +} from "./Sidebar.logic"; + +export type SidebarThreadBoardSections = Readonly< + Record +>; + +function insertThreadAt( + threads: readonly EnvironmentThreadShell[], + thread: EnvironmentThreadShell, + index: number, +): EnvironmentThreadShell[] { + const next = [...threads]; + next.splice(Math.min(Math.max(0, index), next.length), 0, thread); + return next; +} + +export function buildSidebarDndBoardSections(input: { + pinnedThreads: readonly EnvironmentThreadShell[]; + regularThreads: readonly EnvironmentThreadShell[]; + snoozedThreads: readonly EnvironmentThreadShell[]; + settledThreads: readonly EnvironmentThreadShell[]; + transaction: SidebarThreadDragTransaction | null; +}): SidebarThreadBoardSections { + let pinned = [...input.pinnedThreads]; + let regular = [...input.regularThreads]; + let snoozed = [...input.snoozedThreads]; + let settled = [...input.settledThreads]; + const { transaction } = input; + if (transaction === null) return { pinned, regular, snoozed, settled }; + + const withoutSource = (items: readonly EnvironmentThreadShell[]) => + items.filter((thread) => sidebarThreadKey(thread) !== transaction.sourceThreadKey); + pinned = withoutSource(pinned); + regular = withoutSource(regular); + snoozed = withoutSource(snoozed); + settled = withoutSource(settled); + + if (transaction.phase !== "reconciling" || transaction.destinationSection === null) { + switch (transaction.sourceSection) { + case "pinned": + pinned = insertThreadAt(pinned, transaction.sourceThread, transaction.sourceIndex); + break; + case "regular": + regular = insertThreadAt(regular, transaction.sourceThread, transaction.sourceIndex); + break; + case "snoozed": + snoozed = insertThreadAt(snoozed, transaction.sourceThread, transaction.sourceIndex); + break; + case "settled": + settled = insertThreadAt(settled, transaction.sourceThread, transaction.sourceIndex); + break; + } + return { pinned, regular, snoozed, settled }; + } + + const now = new Date().toISOString(); + switch (transaction.destinationSection) { + case "pinned": + pinned = orderItemsByPreferredIds({ + items: [ + ...pinned, + { + ...transaction.sourceThread, + pinnedAt: now, + settledOverride: "active" as const, + settledAt: null, + snoozedAt: null, + snoozedUntil: null, + }, + ], + preferredIds: transaction.pinnedOrder ?? [], + getId: sidebarThreadKey, + }); + break; + case "regular": + regular = sortThreadsForSidebar([ + ...regular, + { + ...transaction.sourceThread, + pinnedAt: null, + pinOrderKey: null, + settledOverride: "active" as const, + settledAt: null, + snoozedAt: null, + snoozedUntil: null, + }, + ]); + break; + case "snoozed": + snoozed = [ + ...snoozed, + { + ...transaction.sourceThread, + pinnedAt: null, + pinOrderKey: null, + settledOverride: "active" as const, + settledAt: null, + snoozedAt: now, + snoozedUntil: transaction.snoozedUntil, + }, + ].toSorted( + (left, right) => + firstValidTimestampMs(left.snoozedUntil ?? null) - + firstValidTimestampMs(right.snoozedUntil ?? null), + ); + break; + case "settled": + settled = sortSettledThreadsForSidebar([ + ...settled, + { + ...transaction.sourceThread, + pinnedAt: null, + pinOrderKey: null, + settledOverride: "settled" as const, + settledAt: now, + snoozedAt: null, + snoozedUntil: null, + }, + ]); + break; + } + return { pinned, regular, snoozed, settled }; +} diff --git a/apps/web/src/components/Sidebar.dnd.logic.ts b/apps/web/src/components/Sidebar.dnd.logic.ts index 585d398dd952..18ddc2057b85 100644 --- a/apps/web/src/components/Sidebar.dnd.logic.ts +++ b/apps/web/src/components/Sidebar.dnd.logic.ts @@ -1,5 +1,15 @@ +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; +import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment"; + export type SidebarDndSection = "pinned" | "regular" | "snoozed" | "settled"; +export const SIDEBAR_DND_SECTIONS = [ + "pinned", + "regular", + "snoozed", + "settled", +] satisfies ReadonlyArray; + export type SidebarDndAction = | "pin" | "unpin" @@ -29,6 +39,33 @@ export interface SidebarDndPointerAnchor { readonly y: number; } +export type SidebarThreadDragPhase = + | "dragging" + | "awaiting-snooze-choice" + | "committing" + | "reconciling"; + +export interface SidebarThreadDragTransaction { + readonly phase: SidebarThreadDragPhase; + readonly sourceThread: EnvironmentThreadShell; + readonly sourceThreadKey: string; + readonly sourceSection: SidebarDndSection; + readonly sourceIndex: number; + readonly sourceRect: SidebarDndRect; + readonly pointerAnchor: SidebarDndPointerAnchor; + readonly targetSection: SidebarDndSection | null; + readonly targetThreadKey: string | null; + readonly targetEdge: "before" | "after" | null; + readonly destinationSection: SidebarDndSection | null; + readonly pinnedOrder: readonly string[] | null; + readonly snoozedUntil: string | null; + readonly receiptSequencesByEnvironment: ReadonlyMap< + EnvironmentThreadShell["environmentId"], + number + > | null; + readonly viewportRailTopBySection: ReadonlyMap | null; +} + export interface SidebarDndDraggableId { readonly kind: "draggable"; readonly section: SidebarDndSection; @@ -50,6 +87,12 @@ export type SidebarDndId = SidebarDndDraggableId | SidebarDndRowId | SidebarDndS const DND_ID_PREFIX = "sidebar-thread-dnd"; +export function sidebarThreadKey( + thread: Pick, +): string { + return scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); +} + export function createSidebarDndDraggableId(input: { section: SidebarDndSection; threadKey: string; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index d45aaff81961..cc02d5634dda 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,34 +1,7 @@ -import { autoAnimate } from "@formkit/auto-animate"; import { useAtomValue } from "@effect/atom-react"; import * as Schema from "effect/Schema"; -import { - DndContext, - DragOverlay, - MeasuringStrategy, - PointerSensor, - closestCenter, - pointerWithin, - useDraggable, - useDroppable, - useSensor, - useSensors, - type CollisionDetection, - type DragCancelEvent, - type DragEndEvent, - type DragMoveEvent, - type DragOverEvent, - type DragStartEvent, -} from "@dnd-kit/core"; -import { - SortableContext, - useSortable, - verticalListSortingStrategy, - type SortingStrategy, -} from "@dnd-kit/sortable"; -import { restrictToVerticalAxis } from "@dnd-kit/modifiers"; import { CSS } from "@dnd-kit/utilities"; import { - canSettle, canSnooze, changeRequestAutoSettles, effectiveSettled, @@ -70,16 +43,13 @@ import { memo, useCallback, useEffect, - useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, - type ReactNode, } from "react"; -import { createPortal } from "react-dom"; import { useParams, useRouter } from "@tanstack/react-router"; import { @@ -119,6 +89,7 @@ import { useClientSettings } from "../hooks/useSettings"; import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; import { useLocalStorage } from "../hooks/useLocalStorage"; import { useNowMinute } from "../hooks/useNowMinute"; +import { useSidebarThreadDnd } from "../hooks/useSidebarThreadDnd"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { useProjects, useThreadShells } from "../state/entities"; import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server"; @@ -126,8 +97,6 @@ import { vcsEnvironment } from "../state/vcs"; import { threadEnvironment } from "../state/threads"; import { useEnvironmentQuery } from "../state/query"; import { useAtomCommand } from "../state/use-atom-command"; -import { environmentSnapshotAtom } from "../state/shell"; -import { appAtomRegistry } from "../rpc/atomRegistry"; import { buildThreadRouteParams, resolveActiveThreadRouteRef, @@ -138,7 +107,6 @@ import type { SidebarThreadSummary } from "../types"; import { cn } from "~/lib/utils"; import { buildThreadActionMenuItems } from "./threadActionMenu.logic"; import { - animatePinnedLayoutChanges, buildBulkTitleRegenerationContextMenuItem, formatWorkingDurationLabel, firstValidTimestampMs, @@ -146,7 +114,6 @@ import { isSidebarNestedLinkClick, isTrailingDoubleClick, orderItemsByPreferredIds, - planPinnedReorder, resolveAdjacentThreadId, resolveSettledTimestamp, resolveSidebarThreadStatus, @@ -158,19 +125,7 @@ import { sortSettledThreadsForSidebar, sortThreadsForSidebar, } from "./Sidebar.logic"; -import { - captureSidebarDndPointerAnchor, - createSidebarDndDraggableId, - createSidebarDndRowId, - createSidebarDndSectionId, - parseSidebarDndId, - resolveSidebarDndAction, - resolveSidebarDndPreviewVariant, - type SidebarDndAction, - type SidebarDndPointerAnchor, - type SidebarDndPreviewVariant, - type SidebarDndSection, -} from "./Sidebar.dnd.logic"; +import { sidebarThreadKey, type SidebarDndSection } from "./Sidebar.dnd.logic"; import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; import { ThreadWorktreeIndicator, @@ -206,7 +161,11 @@ import { Input } from "./ui/input"; import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu"; import { SidebarContent, SidebarGroup, SidebarMenuButton, useSidebar } from "./ui/sidebar"; import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; -import { SidebarThreadDragPreview } from "./sidebar/SidebarThreadDragPreview"; +import { + SidebarThreadDropIndicator, + type SidebarThreadDndRowBag, +} from "./sidebar/SidebarThreadDnd"; +import { SidebarThreadBoard, type SidebarThreadRenderState } from "./sidebar/SidebarThreadBoard"; import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; import { @@ -224,13 +183,6 @@ const SETTLED_TAIL_PAGE_COUNT = 25; // Keep the v2 key so existing preferences survive the v2-to-default rename. const SETTLED_SHELF_EXPANDED_KEY = "t3code:sidebar-v2:settled-expanded"; const SNOOZED_SHELF_EXPANDED_KEY = "t3code:sidebar-v2:snoozed-expanded"; -const SIDEBAR_DND_SECTION_ORDER = [ - "pinned", - "regular", - "snoozed", - "settled", -] satisfies ReadonlyArray; -const SIDEBAR_DND_EMPTY_RAIL_HEIGHT = 48; function compactSidebarTimeLabel(label: string): string { if (label === "just now") return "now"; @@ -472,153 +424,6 @@ function SnoozePopoverButton(props: { ); } -type SidebarThreadDndRowBag = { - readonly listeners: ReturnType["listeners"]; - readonly setNodeRef: (node: HTMLElement | null) => void; - readonly transform: ReturnType["transform"]; - readonly transition: string | undefined; - readonly isDragging: boolean; - readonly isSortable: boolean; -}; - -function SortableSidebarThreadRow(props: { - threadKey: string; - section: SidebarDndSection; - disabled: boolean; - onNodeChange: (threadKey: string, node: HTMLElement | null) => void; - children: (bag: SidebarThreadDndRowBag) => ReactNode; -}) { - const id = createSidebarDndDraggableId({ section: props.section, threadKey: props.threadKey }); - const sortable = useSortable({ - id, - disabled: props.disabled, - animateLayoutChanges: animatePinnedLayoutChanges, - data: { section: props.section, threadKey: props.threadKey }, - }); - const setNodeRef = useCallback( - (node: HTMLElement | null) => { - sortable.setNodeRef(node); - props.onNodeChange(props.threadKey, node); - }, - [props.onNodeChange, props.threadKey, sortable.setNodeRef], - ); - useEffect( - () => () => { - props.onNodeChange(props.threadKey, null); - }, - [props.onNodeChange, props.threadKey], - ); - return props.children({ - listeners: sortable.listeners, - setNodeRef, - transform: sortable.transform, - transition: sortable.transition, - isDragging: sortable.isDragging, - isSortable: true, - }); -} - -function DraggableSidebarThreadRow(props: { - threadKey: string; - section: SidebarDndSection; - dragDisabled: boolean; - dropDisabled: boolean; - onNodeChange: (threadKey: string, node: HTMLElement | null) => void; - children: (bag: SidebarThreadDndRowBag) => ReactNode; -}) { - const draggable = useDraggable({ - id: createSidebarDndDraggableId({ - section: props.section, - threadKey: props.threadKey, - }), - disabled: props.dragDisabled, - data: { section: props.section, threadKey: props.threadKey }, - }); - const droppable = useDroppable({ - id: createSidebarDndRowId({ section: props.section, threadKey: props.threadKey }), - disabled: props.dropDisabled, - data: { section: props.section, threadKey: props.threadKey }, - }); - const setNodeRef = useCallback( - (node: HTMLElement | null) => { - draggable.setNodeRef(node); - droppable.setNodeRef(node); - props.onNodeChange(props.threadKey, node); - }, - [draggable.setNodeRef, droppable.setNodeRef, props.onNodeChange, props.threadKey], - ); - useEffect( - () => () => { - props.onNodeChange(props.threadKey, null); - }, - [props.onNodeChange, props.threadKey], - ); - return props.children({ - listeners: draggable.listeners, - setNodeRef, - // Sorted lists never apply the draggable transform to their source row. - transform: null, - transition: undefined, - isDragging: draggable.isDragging, - isSortable: false, - }); -} - -function SidebarThreadSectionDropZone(props: { - section: SidebarDndSection; - disabled: boolean; - children: (bag: { - readonly setNodeRef: (node: HTMLElement | null) => void; - readonly isOver: boolean; - }) => ReactNode; -}) { - const droppable = useDroppable({ - id: createSidebarDndSectionId({ section: props.section }), - disabled: props.disabled, - data: { section: props.section }, - }); - return props.children({ setNodeRef: droppable.setNodeRef, isOver: droppable.isOver }); -} - -function SidebarThreadViewportDropRail(props: { - section: SidebarDndSection; - top: number; - setDropNodeRef: (node: HTMLElement | null) => void; - onNodeChange: (section: SidebarDndSection, node: HTMLElement | null) => void; - children: ReactNode; -}) { - const setNodeRef = useCallback( - (node: HTMLDivElement | null) => { - props.setDropNodeRef(node); - props.onNodeChange(props.section, node); - }, - [props.onNodeChange, props.section, props.setDropNodeRef], - ); - - return ( -
    - {props.children} -
    - ); -} - -function SidebarThreadDropIndicator(props: { edge: "before" | "after" }) { - return ( - - ); -} - // One unsent draft session the user has invested content in. Two lines, // nothing else: project name, then the typed prompt. All the draft's // settings (model, env mode, branch, worktree) still travel with it — @@ -1813,209 +1618,6 @@ function latestTurnDiff( return null; } -type SidebarThreadDragPhase = "dragging" | "awaiting-snooze-choice" | "committing" | "reconciling"; - -type SidebarThreadDragTransaction = { - readonly phase: SidebarThreadDragPhase; - readonly sourceThread: EnvironmentThreadShell; - readonly sourceThreadKey: string; - readonly sourceSection: SidebarDndSection; - readonly sourceIndex: number; - readonly sourceRect: { - readonly left: number; - readonly top: number; - readonly width: number; - readonly height: number; - }; - readonly pointerAnchor: SidebarDndPointerAnchor; - readonly targetSection: SidebarDndSection | null; - readonly targetThreadKey: string | null; - readonly targetEdge: "before" | "after" | null; - readonly destinationSection: SidebarDndSection | null; - readonly pinnedOrder: readonly string[] | null; - readonly snoozedUntil: string | null; - readonly receiptSequencesByEnvironment: ReadonlyMap< - EnvironmentThreadShell["environmentId"], - number - > | null; - readonly viewportRailTopBySection: ReadonlyMap | null; -}; - -type SidebarPinnedInsertionPlan = { - readonly order: readonly string[]; - readonly assignments: ReadonlyArray<{ readonly id: string; readonly orderKey: string }>; - readonly threadByKey: ReadonlyMap; -}; - -interface SidebarThreadDropTarget { - readonly targetSection: SidebarDndSection; - readonly targetThreadKey: string | null; - readonly targetEdge: "before" | "after" | null; -} - -type SidebarLayoutCorrection = - | { readonly kind: "stable" } - | { readonly kind: "corrected" } - | { - readonly kind: "clamped"; - readonly edge: "start" | "end"; - readonly missingScrollRange: number; - }; - -interface SidebarScrollRangeHold { - readonly node: HTMLUListElement; - readonly originalMinHeight: string; - readonly originalPaddingTop: string; - readonly originalPaddingBottom: string; - readonly height: number; - readonly topInset: number; - readonly bottomInset: number; -} - -type SidebarAutoAnimateController = ReturnType & { - readonly destroy?: () => void; -}; - -function sidebarThreadKey(thread: Pick): string { - return scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); -} - -function sidebarDndSectionIndex( - section: SidebarDndSection, - threadKey: string, - sections: Readonly>, -): number { - const index = sections[section].findIndex((thread) => sidebarThreadKey(thread) === threadKey); - return Math.max(0, index); -} - -function insertSidebarThreadAt( - threads: readonly EnvironmentThreadShell[], - thread: EnvironmentThreadShell, - index: number, -): EnvironmentThreadShell[] { - const next = [...threads]; - next.splice(Math.min(Math.max(0, index), next.length), 0, thread); - return next; -} - -function movePinnedThreadAtEdge(input: { - keys: readonly string[]; - activeKey: string; - overKey: string; - edge: "before" | "after"; -}): string[] | null { - if (!input.keys.includes(input.activeKey)) return null; - if (input.activeKey === input.overKey) return [...input.keys]; - - const next = input.keys.filter((key) => key !== input.activeKey); - const overIndex = next.indexOf(input.overKey); - if (overIndex === -1) return null; - const insertionIndex = overIndex + (input.edge === "after" ? 1 : 0); - next.splice(insertionIndex, 0, input.activeKey); - return next; -} - -function SidebarThreadDragOverlayContent(props: { - transaction: SidebarThreadDragTransaction; - variant: SidebarDndPreviewVariant; - projectTitle: string | null; - projectCwd: string | null; - projectFaviconPath: string | null; -}) { - const innerRef = useRef(null); - const animationRef = useRef(null); - const geometryRef = useRef<{ - readonly width: number; - readonly height: number; - } | null>(null); - const previewHeight = props.variant === "card" ? 82 : 36; - const previewWidth = props.transaction.sourceRect.width; - const left = - props.transaction.pointerAnchor.x * props.transaction.sourceRect.width - - props.transaction.pointerAnchor.x * previewWidth; - const top = - props.transaction.pointerAnchor.y * props.transaction.sourceRect.height - - props.transaction.pointerAnchor.y * previewHeight; - - useLayoutEffect(() => { - const node = innerRef.current; - if (node === null) return; - const nextGeometry = { width: previewWidth, height: previewHeight }; - const previousGeometry = geometryRef.current; - geometryRef.current = nextGeometry; - if (previousGeometry === null) { - return; - } - const interruptedRect = - animationRef.current?.playState === "running" ? node.getBoundingClientRect() : null; - animationRef.current?.cancel(); - const settledRect = node.getBoundingClientRect(); - if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; - const fromWidth = interruptedRect?.width ?? previousGeometry.width; - const fromHeight = interruptedRect?.height ?? previousGeometry.height; - const scaleX = settledRect.width > 0 ? fromWidth / settledRect.width : 1; - const scaleY = settledRect.height > 0 ? fromHeight / settledRect.height : 1; - const settledAnchorX = settledRect.left + props.transaction.pointerAnchor.x * settledRect.width; - const settledAnchorY = settledRect.top + props.transaction.pointerAnchor.y * settledRect.height; - const translateX = - interruptedRect === null - ? 0 - : interruptedRect.left + - props.transaction.pointerAnchor.x * interruptedRect.width - - settledAnchorX; - const translateY = - interruptedRect === null - ? 0 - : interruptedRect.top + - props.transaction.pointerAnchor.y * interruptedRect.height - - settledAnchorY; - animationRef.current = node.animate( - [ - { - transform: `translate(${translateX}px, ${translateY}px) scale(${scaleX}, ${scaleY})`, - opacity: 0.88, - }, - { transform: "translate(0, 0) scale(1, 1)", opacity: 1 }, - ], - { duration: 160, easing: "cubic-bezier(0.2, 0, 0, 1)", fill: "both" }, - ); - }, [left, previewHeight, previewWidth, props.variant]); - useEffect(() => () => animationRef.current?.cancel(), []); - - return ( -
    -
    - -
    -
    - ); -} - const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { thread: SidebarThreadSummary; projectCwd: string | null; @@ -2540,11 +2142,6 @@ export default function Sidebar() { ), [threads], ); - const allThreadByKeyRef = useRef(allThreadByKey); - allThreadByKeyRef.current = allThreadByKey; - const canonicalSectionByThreadKeyRef = useRef(canonicalSectionByThreadKey); - canonicalSectionByThreadKeyRef.current = canonicalSectionByThreadKey; - const threadSearchInputRef = useRef(null); const [threadSearchQuery, setThreadSearchQuery] = useState(""); const [activeSearchResultIndex, setActiveSearchResultIndex] = useState(0); @@ -2988,552 +2585,6 @@ export default function Sidebar() { }, [unsnoozeThread], ); - // Drag-to-reorder for the pinned block. A drop computes ONE fractional key - // for the moved thread and sends it to that thread's own server (see - // planPinnedReorder for the keyless-neighbor materialization case, which - // instead rewrites every key in the section). The optimistic order keeps - // the card where it was dropped until EVERY key the drop wrote is - // reflected in canonical state — a section rewrite is several sequential - // writes, and releasing on the first landed key would expose the - // half-written canonical order, reshuffling the block once per write. - // A failed write clears the override (the card snaps back) with a toast. - // A key we did NOT write landing (a concurrent client's reorder that must - // win) and ANY membership change (new pin, unpin, snooze/wake) also - // release it: the override can't say where members it never saw belong, - // and holding it would launder a stale order into later drags. - const threadDndSensors = useSensors( - useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), - ); - const [dragTransaction, setDragTransactionState] = useState( - null, - ); - const dragTransactionRef = useRef(null); - const setDragTransaction = useCallback( - ( - next: - | SidebarThreadDragTransaction - | null - | ((current: SidebarThreadDragTransaction | null) => SidebarThreadDragTransaction | null), - ) => { - const resolved = typeof next === "function" ? next(dragTransactionRef.current) : next; - dragTransactionRef.current = resolved; - setDragTransactionState(resolved); - }, - [], - ); - const sidebarViewportRef = useRef(null); - const sidebarViewportOverlayRef = useRef(null); - const viewportRailSectionsRef = useRef(new Set()); - const threadListNodeRef = useRef(null); - const sidebarScrollRangeHoldRef = useRef(null); - const threadRowNodesRef = useRef(new Map()); - const autoAnimateControllerRef = useRef(null); - const autoAnimatePausedRef = useRef(false); - const viewportOverflowAnchorRef = useRef(""); - const correctedScrollTopRef = useRef(null); - const retainedLayoutAnchorRef = useRef<{ - element: HTMLElement; - top: number; - } | null>(null); - const rawPointerRef = useRef<{ x: number; y: number } | null>(null); - const releasePointerRef = useRef<{ x: number; y: number } | null>(null); - const activePointerIdRef = useRef(null); - const pointerListenerCleanupRef = useRef<(() => void) | null>(null); - const pinnedReorderInFlightRef = useRef(false); - const snoozeDropEpochRef = useRef(0); - const handleViewportRailNodeChange = useCallback( - (section: SidebarDndSection, node: HTMLElement | null) => { - if (node === null) { - viewportRailSectionsRef.current.delete(section); - return; - } - viewportRailSectionsRef.current.add(section); - }, - [], - ); - const handleThreadRowNodeChange = useCallback((threadKey: string, node: HTMLElement | null) => { - if (node === null) { - threadRowNodesRef.current.delete(threadKey); - return; - } - threadRowNodesRef.current.set(threadKey, node); - }, []); - const pauseSidebarLayoutMotion = useCallback(() => { - if (autoAnimatePausedRef.current) return; - autoAnimatePausedRef.current = true; - autoAnimateControllerRef.current?.disable(); - const viewport = sidebarViewportRef.current; - if (viewport === null) return; - viewportOverflowAnchorRef.current = viewport.style.overflowAnchor; - viewport.style.overflowAnchor = "none"; - }, []); - const chooseSidebarLayoutAnchor = useCallback( - (preferred: HTMLElement | null, excludedThreadKey: string | null = null) => { - const viewport = sidebarViewportRef.current; - if (viewport === null) return null; - const canAnchor = (element: HTMLElement) => { - if (!element.isConnected || element.dataset.dndTransformed === "true") return false; - const rect = element.getBoundingClientRect(); - const viewportRect = viewport.getBoundingClientRect(); - return rect.bottom > viewportRect.top && rect.top < viewportRect.bottom; - }; - if (preferred !== null && canAnchor(preferred)) return preferred; - for (const [threadKey, element] of threadRowNodesRef.current) { - if (threadKey === excludedThreadKey) continue; - if (canAnchor(element)) return element; - } - return null; - }, - [], - ); - const retainSidebarLayoutAnchor = useCallback( - (preferred: HTMLElement | null = null, excludedThreadKey: string | null = null) => { - const anchor = chooseSidebarLayoutAnchor(preferred, excludedThreadKey); - retainedLayoutAnchorRef.current = - anchor === null ? null : { element: anchor, top: anchor.getBoundingClientRect().top }; - }, - [chooseSidebarLayoutAnchor], - ); - const correctSidebarLayoutAnchor = useCallback((): SidebarLayoutCorrection => { - const viewport = sidebarViewportRef.current; - const retained = retainedLayoutAnchorRef.current; - if ( - viewport === null || - retained === null || - !retained.element.isConnected || - retained.element.dataset.dndTransformed === "true" - ) { - retainSidebarLayoutAnchor(); - return { kind: "stable" }; - } - const nextTop = retained.element.getBoundingClientRect().top; - const delta = nextTop - retained.top; - if (Math.abs(delta) > 0.5) { - const maxScrollTop = Math.max(0, viewport.scrollHeight - viewport.clientHeight); - const previousScrollTop = viewport.scrollTop; - const requestedScrollTop = previousScrollTop + delta; - const nextScrollTop = Math.min(maxScrollTop, Math.max(0, requestedScrollTop)); - viewport.scrollTop = nextScrollTop; - const appliedScrollTop = viewport.scrollTop; - if (Math.abs(appliedScrollTop - previousScrollTop) > 0.5) { - correctedScrollTopRef.current = appliedScrollTop; - } - if (Math.abs(appliedScrollTop - requestedScrollTop) > 0.5) { - return { - kind: "clamped", - edge: requestedScrollTop < 0 ? "start" : "end", - missingScrollRange: Math.abs(appliedScrollTop - requestedScrollTop), - }; - } - } - retainedLayoutAnchorRef.current = { - element: retained.element, - top: retained.element.getBoundingClientRect().top, - }; - return { kind: Math.abs(delta) > 0.5 ? "corrected" : "stable" }; - }, [retainSidebarLayoutAnchor]); - const clearSidebarScrollRangeHold = useCallback(() => { - const hold = sidebarScrollRangeHoldRef.current; - if (hold === null) return; - hold.node.style.minHeight = hold.originalMinHeight; - hold.node.style.paddingTop = hold.originalPaddingTop; - hold.node.style.paddingBottom = hold.originalPaddingBottom; - sidebarScrollRangeHoldRef.current = null; - }, []); - const holdSidebarScrollRange = useCallback(() => { - const node = threadListNodeRef.current; - if (node === null) return; - const current = sidebarScrollRangeHoldRef.current; - if (current !== null && current.node !== node) { - current.node.style.minHeight = current.originalMinHeight; - current.node.style.paddingTop = current.originalPaddingTop; - current.node.style.paddingBottom = current.originalPaddingBottom; - sidebarScrollRangeHoldRef.current = null; - } - const activeHold = sidebarScrollRangeHoldRef.current; - const height = Math.max(activeHold?.height ?? 0, node.getBoundingClientRect().height); - const next = { - node, - originalMinHeight: activeHold?.originalMinHeight ?? node.style.minHeight, - originalPaddingTop: activeHold?.originalPaddingTop ?? node.style.paddingTop, - originalPaddingBottom: activeHold?.originalPaddingBottom ?? node.style.paddingBottom, - height, - topInset: activeHold?.topInset ?? 0, - bottomInset: activeHold?.bottomInset ?? 0, - } satisfies SidebarScrollRangeHold; - sidebarScrollRangeHoldRef.current = next; - node.style.minHeight = `${height}px`; - node.style.paddingTop = - next.topInset === 0 - ? next.originalPaddingTop - : `calc(${next.originalPaddingTop || "0px"} + ${next.topInset}px)`; - node.style.paddingBottom = - next.bottomInset === 0 - ? next.originalPaddingBottom - : `calc(${next.originalPaddingBottom || "0px"} + ${next.bottomInset}px)`; - }, []); - const extendSidebarScrollRange = useCallback( - (edge: "start" | "end", missingScrollRange: number) => { - const hold = sidebarScrollRangeHoldRef.current; - if (hold === null || missingScrollRange <= 0.5) return false; - const next = { - ...hold, - height: hold.height + missingScrollRange, - topInset: hold.topInset + (edge === "start" ? missingScrollRange : 0), - bottomInset: hold.bottomInset + (edge === "end" ? missingScrollRange : 0), - } satisfies SidebarScrollRangeHold; - sidebarScrollRangeHoldRef.current = next; - next.node.style.minHeight = `${next.height}px`; - next.node.style.paddingTop = - next.topInset === 0 - ? next.originalPaddingTop - : `calc(${next.originalPaddingTop || "0px"} + ${next.topInset}px)`; - next.node.style.paddingBottom = - next.bottomInset === 0 - ? next.originalPaddingBottom - : `calc(${next.originalPaddingBottom || "0px"} + ${next.bottomInset}px)`; - return true; - }, - [], - ); - const releaseSidebarScrollRangeIfSafe = useCallback(() => { - const hold = sidebarScrollRangeHoldRef.current; - if (hold === null) return true; - const viewport = sidebarViewportRef.current; - if (viewport === null || !hold.node.isConnected) { - clearSidebarScrollRangeHold(); - return true; - } - - const anchor = chooseSidebarLayoutAnchor(null); - const previousAnchorTop = anchor?.getBoundingClientRect().top ?? null; - const previousScrollTop = viewport.scrollTop; - - const previousOverflowAnchor = viewport.style.overflowAnchor; - viewport.style.overflowAnchor = "none"; - try { - if (hold.topInset > 0.5) { - viewport.scrollTop = Math.max(0, previousScrollTop - hold.topInset); - } - hold.node.style.minHeight = hold.originalMinHeight; - hold.node.style.paddingTop = hold.originalPaddingTop; - hold.node.style.paddingBottom = hold.originalPaddingBottom; - const naturalMaxScrollTop = Math.max(0, viewport.scrollHeight - viewport.clientHeight); - const anchorDelta = - anchor === null || previousAnchorTop === null - ? 0 - : anchor.getBoundingClientRect().top - previousAnchorTop; - const requestedScrollTop = viewport.scrollTop + anchorDelta; - const outsideNaturalRange = - requestedScrollTop < -0.5 || requestedScrollTop > naturalMaxScrollTop + 0.5; - const temporaryInsetReachedNaturalBoundary = - (requestedScrollTop < -0.5 && hold.topInset > 0.5) || - (requestedScrollTop > naturalMaxScrollTop + 0.5 && hold.bottomInset > 0.5); - if (outsideNaturalRange && !temporaryInsetReachedNaturalBoundary) { - hold.node.style.minHeight = `${hold.height}px`; - hold.node.style.paddingTop = - hold.topInset === 0 - ? hold.originalPaddingTop - : `calc(${hold.originalPaddingTop || "0px"} + ${hold.topInset}px)`; - hold.node.style.paddingBottom = - hold.bottomInset === 0 - ? hold.originalPaddingBottom - : `calc(${hold.originalPaddingBottom || "0px"} + ${hold.bottomInset}px)`; - viewport.scrollTop = previousScrollTop; - return false; - } - viewport.scrollTop = Math.min(naturalMaxScrollTop, Math.max(0, requestedScrollTop)); - correctedScrollTopRef.current = viewport.scrollTop; - sidebarScrollRangeHoldRef.current = null; - return true; - } finally { - viewport.style.overflowAnchor = previousOverflowAnchor; - } - }, [chooseSidebarLayoutAnchor, clearSidebarScrollRangeHold]); - const cleanupTrackedPointer = useCallback(() => { - pointerListenerCleanupRef.current?.(); - pointerListenerCleanupRef.current = null; - activePointerIdRef.current = null; - rawPointerRef.current = null; - releasePointerRef.current = null; - }, []); - const canDropThreadInSection = useCallback( - (thread: EnvironmentThreadShell, source: SidebarDndSection, destination: SidebarDndSection) => { - const capabilities = serverConfigs.get(thread.environmentId)?.environment.capabilities; - const action = resolveSidebarDndAction({ source, destination }); - switch (action) { - case "noop": - return true; - case "reorder-pinned": - return capabilities?.threadPinReorder === true; - case "pin": - // Exact placement needs both the category command and an order key. - return capabilities?.threadPinning === true && capabilities.threadPinReorder === true; - case "unpin": - return capabilities?.threadPinning === true; - case "unsettle": - return capabilities?.threadSettlement === true; - case "unsnooze": - return capabilities?.threadSnooze === true; - case "settle": - return ( - capabilities?.threadSettlement === true && - canSettle(thread, { now: new Date().toISOString() }) - ); - case "snooze": - return ( - capabilities?.threadSnooze === true && - canSnooze(thread, { now: new Date().toISOString() }) - ); - } - }, - [serverConfigs], - ); - const canDragThread = useCallback( - (thread: EnvironmentThreadShell, source: SidebarDndSection) => - SIDEBAR_DND_SECTION_ORDER.some((destination) => { - const action = resolveSidebarDndAction({ source, destination }); - return action !== "noop" && canDropThreadInSection(thread, source, destination); - }), - [canDropThreadInSection], - ); - const moveClampedEmptyRailsToViewport = useCallback( - (transaction: SidebarThreadDragTransaction) => { - if (transaction.phase !== "dragging" || transaction.viewportRailTopBySection !== null) { - return false; - } - const sourceOrderIndex = SIDEBAR_DND_SECTION_ORDER.indexOf(transaction.sourceSection); - const sections = [ - { section: "pinned", threads: pinnedThreads }, - { section: "regular", threads: activeThreads }, - { section: "snoozed", threads: snoozedThreads }, - { section: "settled", threads: settledThreads }, - ] satisfies ReadonlyArray<{ - readonly section: SidebarDndSection; - readonly threads: readonly EnvironmentThreadShell[]; - }>; - const overlaySections = sections - .slice(0, sourceOrderIndex) - .filter( - ({ section, threads }) => - threads.length === 0 && - canDropThreadInSection(transaction.sourceThread, transaction.sourceSection, section), - ) - .map(({ section }) => section); - if (overlaySections.length === 0) return false; - setDragTransaction((current) => { - if ( - current === null || - current.sourceThreadKey !== transaction.sourceThreadKey || - current.viewportRailTopBySection !== null - ) { - return current; - } - return { - ...current, - viewportRailTopBySection: new Map( - overlaySections.map((section, index) => [ - section, - index * SIDEBAR_DND_EMPTY_RAIL_HEIGHT, - ]), - ), - }; - }); - return true; - }, - [ - activeThreads, - canDropThreadInSection, - pinnedThreads, - setDragTransaction, - settledThreads, - snoozedThreads, - ], - ); - const correctSidebarDragLayout = useCallback( - (transaction: SidebarThreadDragTransaction) => { - const correction = correctSidebarLayoutAnchor(); - if (correction.kind !== "clamped") return; - if (correction.edge === "end" && moveClampedEmptyRailsToViewport(transaction)) return; - if (!extendSidebarScrollRange(correction.edge, correction.missingScrollRange)) { - retainSidebarLayoutAnchor(); - return; - } - if (correctSidebarLayoutAnchor().kind === "clamped") { - retainSidebarLayoutAnchor(); - } - }, - [ - correctSidebarLayoutAnchor, - extendSidebarScrollRange, - moveClampedEmptyRailsToViewport, - retainSidebarLayoutAnchor, - ], - ); - useLayoutEffect(() => { - if (dragTransaction !== null) { - holdSidebarScrollRange(); - correctSidebarDragLayout(dragTransaction); - return; - } - if (pinnedReorderInFlightRef.current) return; - if (!autoAnimatePausedRef.current) { - releaseSidebarScrollRangeIfSafe(); - return; - } - correctSidebarLayoutAnchor(); - autoAnimatePausedRef.current = false; - const viewport = sidebarViewportRef.current; - if (viewport !== null) { - viewport.style.overflowAnchor = viewportOverflowAnchorRef.current; - } - autoAnimateControllerRef.current?.enable(); - retainedLayoutAnchorRef.current = null; - releaseSidebarScrollRangeIfSafe(); - }); - useEffect(() => { - if (dragTransaction === null) return; - const viewport = sidebarViewportRef.current; - if (viewport === null) return; - const handleScroll = () => { - const correctedScrollTop = correctedScrollTopRef.current; - if (correctedScrollTop !== null && Math.abs(viewport.scrollTop - correctedScrollTop) <= 0.5) { - return; - } - correctedScrollTopRef.current = null; - const retained = retainedLayoutAnchorRef.current; - if (retained === null || !retained.element.isConnected) { - retainSidebarLayoutAnchor(); - return; - } - retainedLayoutAnchorRef.current = { - element: retained.element, - top: retained.element.getBoundingClientRect().top, - }; - }; - viewport.addEventListener("scroll", handleScroll, { passive: true }); - return () => viewport.removeEventListener("scroll", handleScroll); - }, [dragTransaction, retainSidebarLayoutAnchor]); - useEffect(() => { - if (dragTransaction !== null || sidebarScrollRangeHoldRef.current === null) return; - const viewport = sidebarViewportRef.current; - if (viewport === null) return; - const handleScroll = () => { - if (releaseSidebarScrollRangeIfSafe()) { - viewport.removeEventListener("scroll", handleScroll); - } - }; - viewport.addEventListener("scroll", handleScroll, { passive: true }); - return () => viewport.removeEventListener("scroll", handleScroll); - }, [dragTransaction, releaseSidebarScrollRangeIfSafe]); - useEffect(() => { - if (dragTransaction === null || typeof ResizeObserver === "undefined") return; - const observer = new ResizeObserver(() => { - const transaction = dragTransactionRef.current; - if (transaction !== null) { - holdSidebarScrollRange(); - correctSidebarDragLayout(transaction); - } - }); - if (sidebarViewportRef.current !== null) observer.observe(sidebarViewportRef.current); - if (threadListNodeRef.current !== null) observer.observe(threadListNodeRef.current); - return () => observer.disconnect(); - }, [correctSidebarDragLayout, dragTransaction, holdSidebarScrollRange]); - useEffect( - () => () => { - cleanupTrackedPointer(); - clearSidebarScrollRangeHold(); - autoAnimateControllerRef.current?.destroy?.(); - }, - [cleanupTrackedPointer, clearSidebarScrollRangeHold], - ); - const [optimisticPinnedOrder, setOptimisticPinnedOrder] = useState<{ - readonly order: readonly string[]; - /** pinOrderKey per thread as of the drop — the baseline that tells a - concurrent client's write apart from one of our own landing. */ - readonly keysAtDrop: ReadonlyMap; - /** The keys this drop writes (one per planned assignment). The - override holds until all of them appear in canonical state. */ - readonly assignedKeys: ReadonlyMap; - } | null>(null); - const orderedPinnedThreads = useMemo(() => { - if (optimisticPinnedOrder === null) return pinnedThreads; - return orderItemsByPreferredIds({ - items: pinnedThreads, - preferredIds: optimisticPinnedOrder.order, - getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - }); - }, [optimisticPinnedOrder, pinnedThreads]); - const pinnedSortingOverIndex = useMemo(() => { - const transaction = dragTransaction; - if ( - transaction === null || - transaction.phase !== "dragging" || - transaction.sourceSection !== "pinned" || - transaction.targetSection !== "pinned" || - transaction.targetThreadKey === null || - transaction.targetEdge === null - ) { - return null; - } - const keys = orderedPinnedThreads - .map(sidebarThreadKey) - .filter((threadKey) => reorderablePinnedKeys.has(threadKey)); - const previewOrder = movePinnedThreadAtEdge({ - keys, - activeKey: transaction.sourceThreadKey, - overKey: transaction.targetThreadKey, - edge: transaction.targetEdge, - }); - return previewOrder?.indexOf(transaction.sourceThreadKey) ?? null; - }, [dragTransaction, orderedPinnedThreads, reorderablePinnedKeys]); - const pinnedSortingStrategy = useCallback( - (args) => - verticalListSortingStrategy({ - ...args, - overIndex: pinnedSortingOverIndex ?? args.overIndex, - }), - [pinnedSortingOverIndex], - ); - useEffect(() => { - if (optimisticPinnedOrder === null) return; - const canonical = pinnedThreads.filter((thread) => - reorderablePinnedKeys.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), - ); - const canonicalKeys = canonical.map((thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ); - // The override represents one drop against one snapshot of the world. - // Release it when the world moves on: membership changed (pin/unpin/ - // snooze/wake — the override can't say where members it never saw - // belong), a key changed to something we did NOT write (a concurrent - // client's reorder that must win), every key we wrote has landed, or - // canonical already matches. Releasing on the FIRST landed key instead - // of the last exposes the half-written order mid-materialization and - // the block visibly reshuffles once per write. - const membershipChanged = - canonicalKeys.length !== optimisticPinnedOrder.order.length || - canonicalKeys.some((key) => !optimisticPinnedOrder.order.includes(key)); - const foreignKeyLanded = canonical.some((thread, index) => { - const threadKey = canonicalKeys[index]!; - const currentKey = thread.pinOrderKey ?? null; - if (currentKey === optimisticPinnedOrder.keysAtDrop.get(threadKey)) return false; - return currentKey !== optimisticPinnedOrder.assignedKeys.get(threadKey); - }); - const currentKeyByThreadKey = new Map( - canonical.map((thread, index) => [canonicalKeys[index]!, thread.pinOrderKey ?? null]), - ); - const allAssignmentsLanded = [...optimisticPinnedOrder.assignedKeys].every( - ([threadKey, orderKey]) => currentKeyByThreadKey.get(threadKey) === orderKey, - ); - const orderConfirmed = - !membershipChanged && - canonicalKeys.every((key, index) => key === optimisticPinnedOrder.order[index]); - if (membershipChanged || foreignKeyLanded || allAssignmentsLanded || orderConfirmed) { - pinnedReorderInFlightRef.current = false; - setOptimisticPinnedOrder(null); - } - }, [optimisticPinnedOrder, pinnedThreads, reorderablePinnedKeys]); const attemptPin = useCallback( (threadRef: ScopedThreadRef) => { void (async () => { @@ -3574,130 +2625,6 @@ export default function Sidebar() { [unpinThread], ); - const handlePinnedReorder = useCallback( - (activeKey: string, overKey: string | null, targetEdge: "before" | "after" | null) => { - if ( - pinnedReorderInFlightRef.current || - overKey === null || - targetEdge === null || - activeKey === overKey - ) { - return; - } - const reorderable = orderedPinnedThreads.filter((thread) => - reorderablePinnedKeys.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), - ); - const keys = reorderable.map((thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ); - const newOrder = movePinnedThreadAtEdge({ - keys, - activeKey, - overKey, - edge: targetEdge, - }); - if (newOrder === null) return; - if (newOrder.every((key, index) => key === keys[index])) return; - const threadByKey = new Map(reorderable.map((thread, index) => [keys[index]!, thread])); - const keysAtDrop = new Map( - reorderable.map((thread, index) => [keys[index]!, thread.pinOrderKey ?? null]), - ); - const assignments = planPinnedReorder({ - orderedIds: newOrder, - keysById: keysAtDrop, - movedId: activeKey, - }); - if (assignments.length === 0) return; - pinnedReorderInFlightRef.current = true; - setOptimisticPinnedOrder({ - order: newOrder, - keysAtDrop, - assignedKeys: new Map( - assignments.map((assignment) => [assignment.id, assignment.orderKey]), - ), - }); - void (async () => { - // Sequential, stop on first failure. There is deliberately no - // rollback: every key write is a complete, valid placement on its - // own, so a partial materialization leaves a sensible order (and - // the next drag repairs the rest) — unwinding writes across - // servers would trade that for real inconsistency windows. - for (const assignment of assignments) { - const thread = threadByKey.get(assignment.id); - if (thread === undefined) continue; - const result = await reorderPinnedThread( - scopeThreadRef(thread.environmentId, thread.id), - assignment.orderKey, - ); - if (result._tag === "Failure") { - // Any failure — interrupted included — releases the override: - // a key that never lands would otherwise hold it until some - // unrelated world change came along. - pinnedReorderInFlightRef.current = false; - setOptimisticPinnedOrder(null); - if (isAtomCommandInterrupted(result)) return; - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to reorder pinned threads", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - return; - } - } - })(); - }, - [orderedPinnedThreads, reorderPinnedThread, reorderablePinnedKeys], - ); - const planPinnedInsertion = useCallback( - (transaction: SidebarThreadDragTransaction): SidebarPinnedInsertionPlan | null => { - if (transaction.sourceSection === "pinned" || transaction.targetSection !== "pinned") { - return null; - } - const existingKeys = allPinnedThreads.map(sidebarThreadKey); - let insertionIndex = existingKeys.length; - if (transaction.targetThreadKey !== null) { - const targetIndex = existingKeys.indexOf(transaction.targetThreadKey); - if (targetIndex !== -1) { - insertionIndex = targetIndex + (transaction.targetEdge === "after" ? 1 : 0); - } - } else if (existingKeys.length === 0) { - insertionIndex = 0; - } - const order = [...existingKeys]; - order.splice(insertionIndex, 0, transaction.sourceThreadKey); - const threadByKey = new Map( - allPinnedThreads.map((thread) => [sidebarThreadKey(thread), thread] as const), - ); - threadByKey.set(transaction.sourceThreadKey, transaction.sourceThread); - const keysById = new Map( - allPinnedThreads.map((thread) => [sidebarThreadKey(thread), thread.pinOrderKey ?? null]), - ); - keysById.set(transaction.sourceThreadKey, null); - const assignments = planPinnedReorder({ - orderedIds: order, - keysById, - movedId: transaction.sourceThreadKey, - }); - if (assignments.length === 0) return null; - for (const assignment of assignments) { - const thread = threadByKey.get(assignment.id); - if (thread === undefined) return null; - const capabilities = serverConfigs.get(thread.environmentId)?.environment.capabilities; - if (assignment.id === transaction.sourceThreadKey) { - if (capabilities?.threadPinning !== true || capabilities.threadPinReorder !== true) { - return null; - } - } else if (capabilities?.threadPinReorder !== true) { - return null; - } - } - return { order, assignments, threadByKey }; - }, - [allPinnedThreads, serverConfigs], - ); // One snooze per thread at a time — same double-dispatch guard as settle. const snoozingThreadKeysRef = useRef(new Set()); const performSnooze = useCallback( @@ -3772,594 +2699,51 @@ export default function Sidebar() { [attemptUnsnooze, performSnooze, timestampFormat], ); - const finishSidebarDragTransaction = useCallback( - (options: { excludeSource?: boolean } = {}) => { - const transaction = dragTransactionRef.current; - snoozeDropEpochRef.current += 1; - if (transaction?.phase === "awaiting-snooze-choice") { - void readLocalApi()?.contextMenu.close(); - } - cleanupTrackedPointer(); - retainSidebarLayoutAnchor( - options.excludeSource || transaction === null - ? null - : (threadRowNodesRef.current.get(transaction.sourceThreadKey) ?? null), - options.excludeSource && transaction !== null ? transaction.sourceThreadKey : null, - ); - setDragTransaction(null); - }, - [cleanupTrackedPointer, retainSidebarLayoutAnchor, setDragTransaction], - ); - const beginSidebarDropReconciliation = useCallback( - (input: { - transaction: SidebarThreadDragTransaction; - destinationSection: SidebarDndSection; - receiptSequencesByEnvironment: ReadonlyMap; - pinnedOrder?: readonly string[] | null; - snoozedUntil?: string | null; - }) => { - retainSidebarLayoutAnchor(null, input.transaction.sourceThreadKey); - setDragTransaction({ - ...input.transaction, - phase: "reconciling", - targetSection: input.destinationSection, - destinationSection: input.destinationSection, - pinnedOrder: input.pinnedOrder ?? null, - snoozedUntil: input.snoozedUntil ?? null, - receiptSequencesByEnvironment: input.receiptSequencesByEnvironment, - }); - }, - [retainSidebarLayoutAnchor, setDragTransaction], - ); - const reportSidebarDropFailure = useCallback( - ( - title: string, - result: Parameters[0] & { readonly _tag: "Failure" }, - ) => { - if (isAtomCommandInterrupted(result)) return; - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title, - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - }, - [], - ); - const sourceStillMatchesDragStart = useCallback((transaction: SidebarThreadDragTransaction) => { - const current = allThreadByKeyRef.current.get(transaction.sourceThreadKey); - return ( - current !== undefined && - current.archivedAt === null && - canonicalSectionByThreadKeyRef.current.get(transaction.sourceThreadKey) === - transaction.sourceSection - ); - }, []); - const commitSidebarLifecycleDrop = useCallback( - ( - transaction: SidebarThreadDragTransaction, - destinationSection: SidebarDndSection, - action: Exclude, - pinnedPlan: SidebarPinnedInsertionPlan | null, - ) => { - void (async () => { - if (!sourceStillMatchesDragStart(transaction)) { - finishSidebarDragTransaction(); - return; - } - setDragTransaction({ - ...transaction, - phase: "committing", - targetSection: destinationSection, - destinationSection, - pinnedOrder: pinnedPlan?.order ?? null, - snoozedUntil: null, - receiptSequencesByEnvironment: null, - }); - const threadRef = scopeThreadRef( - transaction.sourceThread.environmentId, - transaction.sourceThread.id, - ); - const receiptSequences = new Map(); - const recordReceipt = ( - environmentId: EnvironmentThreadShell["environmentId"], - sequence: number, - ) => { - receiptSequences.set( - environmentId, - Math.max(receiptSequences.get(environmentId) ?? 0, sequence), - ); - }; - if (action === "pin") { - if (pinnedPlan === null) { - finishSidebarDragTransaction(); - return; - } - for (const assignment of pinnedPlan.assignments) { - if (assignment.id === transaction.sourceThreadKey) continue; - const thread = pinnedPlan.threadByKey.get(assignment.id); - if (thread === undefined) { - finishSidebarDragTransaction(); - return; - } - const result = await reorderPinnedThread( - scopeThreadRef(thread.environmentId, thread.id), - assignment.orderKey, - ); - if (result._tag === "Failure") { - finishSidebarDragTransaction(); - reportSidebarDropFailure("Failed to prepare pinned order", result); - return; - } - recordReceipt(thread.environmentId, result.value.sequence); - } - const sourceAssignment = pinnedPlan.assignments.find( - (assignment) => assignment.id === transaction.sourceThreadKey, - ); - if (sourceAssignment === undefined) { - finishSidebarDragTransaction(); - return; - } - const result = await pinThread(threadRef, { orderKey: sourceAssignment.orderKey }); - if (result._tag === "Failure") { - finishSidebarDragTransaction(); - reportSidebarDropFailure("Failed to pin thread", result); - return; - } - recordReceipt(transaction.sourceThread.environmentId, result.value.sequence); - beginSidebarDropReconciliation({ - transaction, - destinationSection, - receiptSequencesByEnvironment: receiptSequences, - pinnedOrder: pinnedPlan.order, - }); - return; - } - - const navigateAfterSettle = - action === "settle" ? planForwardNavigation(transaction.sourceThreadKey) : null; - const result = - action === "unpin" - ? await unpinThread(threadRef) - : action === "unsettle" - ? await unsettleThread(threadRef) - : action === "unsnooze" - ? await unsnoozeThread(threadRef) - : await settleThread(threadRef); - if (result._tag === "Failure") { - finishSidebarDragTransaction(); - reportSidebarDropFailure( - action === "unpin" - ? "Failed to unpin thread" - : action === "unsettle" - ? "Failed to un-settle thread" - : action === "unsnooze" - ? "Failed to wake thread" - : "Failed to settle thread", - result, - ); - return; - } - if (action === "settle") { - if (routeThreadKeyRef.current === transaction.sourceThreadKey) { - navigateAfterSettle?.(); - } - } - recordReceipt(transaction.sourceThread.environmentId, result.value.sequence); - beginSidebarDropReconciliation({ - transaction, - destinationSection, - receiptSequencesByEnvironment: receiptSequences, - }); - })(); - }, - [ - beginSidebarDropReconciliation, - finishSidebarDragTransaction, + const threadDndActions = useMemo( + () => ({ pinThread, - planForwardNavigation, + unpinThread, reorderPinnedThread, - reportSidebarDropFailure, - setDragTransaction, settleThread, - sourceStillMatchesDragStart, - unpinThread, unsettleThread, unsnoozeThread, - ], - ); - const openSidebarSnoozeDropMenu = useCallback( - (transaction: SidebarThreadDragTransaction, position: { x: number; y: number }) => { - const epoch = snoozeDropEpochRef.current + 1; - snoozeDropEpochRef.current = epoch; - setDragTransaction({ - ...transaction, - phase: "awaiting-snooze-choice", - targetSection: "snoozed", - destinationSection: "snoozed", - pinnedOrder: null, - snoozedUntil: null, - receiptSequencesByEnvironment: null, - }); - void (async () => { - const api = readLocalApi(); - if (api === undefined) { - finishSidebarDragTransaction(); - return; - } - const menuPresets = resolveSnoozePresets(new Date(), timestampFormat); - const selected = await settlePromise(() => - api.contextMenu.show( - menuPresets.map((preset) => ({ - id: `snooze:${preset.id}`, - label: `${preset.label} (${preset.whenLabel})`, - })), - position, - ), - ); - if (snoozeDropEpochRef.current !== epoch) return; - if (selected._tag === "Failure" || selected.value === null) { - finishSidebarDragTransaction(); - return; - } - const selectedId = selected.value.startsWith("snooze:") - ? selected.value.slice("snooze:".length) - : null; - const preset = resolveSnoozePresets(new Date(), timestampFormat).find( - (candidate) => candidate.id === selectedId, - ); - if (preset === undefined || !sourceStillMatchesDragStart(transaction)) { - finishSidebarDragTransaction(); - return; - } - setDragTransaction({ - ...transaction, - phase: "committing", - targetSection: "snoozed", - destinationSection: "snoozed", - pinnedOrder: null, - snoozedUntil: preset.snoozedUntil, - receiptSequencesByEnvironment: null, - }); - const threadRef = scopeThreadRef( - transaction.sourceThread.environmentId, - transaction.sourceThread.id, - ); - const outcome = await performSnooze(threadRef, preset); - if (outcome.status === "failure") { - finishSidebarDragTransaction(); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to snooze thread", - description: - outcome.error instanceof Error ? outcome.error.message : "An error occurred.", - }), - ); - return; - } - if (outcome.status !== "success") { - finishSidebarDragTransaction(); - return; - } - toastManager.add( - stackedThreadToast({ - type: "success", - title: `Snoozed until ${snoozeWakeDescription(preset.snoozedUntil, new Date(), timestampFormat)}`, - timeout: 5_000, - actionProps: { - children: "Wake", - onClick: () => attemptUnsnooze(threadRef), - }, - }), - ); - beginSidebarDropReconciliation({ - transaction, - destinationSection: "snoozed", - receiptSequencesByEnvironment: new Map([ - [transaction.sourceThread.environmentId, outcome.sequence], - ]), - snoozedUntil: preset.snoozedUntil, - }); - })(); - }, - [ - attemptUnsnooze, - beginSidebarDropReconciliation, - finishSidebarDragTransaction, - performSnooze, - setDragTransaction, - sourceStillMatchesDragStart, - timestampFormat, - ], - ); - - const threadCollisionDetection = useCallback((args) => { - const pointerCollisions = pointerWithin(args); - if (pointerCollisions.length > 0) { - const viewportRailSections = viewportRailSectionsRef.current; - return pointerCollisions.toSorted((left, right) => { - const leftId = parseSidebarDndId(left.id); - const rightId = parseSidebarDndId(right.id); - const priority = (id: ReturnType) => { - if (id?.kind === "section" && viewportRailSections.has(id.section)) { - return 0; - } - return id?.kind === "section" ? 2 : 1; - }; - return priority(leftId) - priority(rightId); - }); - } - return closestCenter(args); - }, []); - const handleThreadDragStart = useCallback( - (event: DragStartEvent) => { - if (pinnedReorderInFlightRef.current) return; - const id = parseSidebarDndId(event.active.id); - if (id === null || id.kind !== "draggable") return; - const sourceThread = allThreadByKeyRef.current.get(id.threadKey); - const sourceNode = threadRowNodesRef.current.get(id.threadKey); - if (sourceThread === undefined || sourceNode === undefined) return; - const sourceSection = canonicalSectionByThreadKeyRef.current.get(id.threadKey); - if (sourceSection === undefined || !canDragThread(sourceThread, sourceSection)) return; - const sourceRect = sourceNode.getBoundingClientRect(); - const activator = event.activatorEvent instanceof PointerEvent ? event.activatorEvent : null; - const pointer = { - x: - activator !== null && Number.isFinite(activator.clientX) - ? activator.clientX - : sourceRect.left + sourceRect.width / 2, - y: - activator !== null && Number.isFinite(activator.clientY) - ? activator.clientY - : sourceRect.top + sourceRect.height / 2, - }; - cleanupTrackedPointer(); - rawPointerRef.current = pointer; - releasePointerRef.current = null; - activePointerIdRef.current = - activator !== null && Number.isFinite(activator.pointerId) ? activator.pointerId : null; - const updatePointer = (pointerEvent: PointerEvent) => { - if ( - activePointerIdRef.current !== null && - pointerEvent.pointerId !== activePointerIdRef.current - ) { - return; - } - rawPointerRef.current = { x: pointerEvent.clientX, y: pointerEvent.clientY }; - }; - const captureReleasePointer = (pointerEvent: PointerEvent) => { - if ( - activePointerIdRef.current !== null && - pointerEvent.pointerId !== activePointerIdRef.current - ) { - return; - } - releasePointerRef.current = { x: pointerEvent.clientX, y: pointerEvent.clientY }; - }; - window.addEventListener("pointermove", updatePointer, true); - window.addEventListener("pointerup", captureReleasePointer, true); - window.addEventListener("pointercancel", captureReleasePointer, true); - pointerListenerCleanupRef.current = () => { - window.removeEventListener("pointermove", updatePointer, true); - window.removeEventListener("pointerup", captureReleasePointer, true); - window.removeEventListener("pointercancel", captureReleasePointer, true); - }; - const sections = { - pinned: orderedPinnedThreads, - regular: activeThreads, - snoozed: visibleSnoozedThreads, - settled: renderedSettledThreads, - } satisfies Readonly>; - pauseSidebarLayoutMotion(); - holdSidebarScrollRange(); - retainSidebarLayoutAnchor(sourceNode); - setDragTransaction({ - phase: "dragging", - sourceThread, - sourceThreadKey: id.threadKey, - sourceSection, - sourceIndex: sidebarDndSectionIndex(sourceSection, id.threadKey, sections), - sourceRect: { - left: sourceRect.left, - top: sourceRect.top, - width: sourceRect.width, - height: sourceRect.height, - }, - pointerAnchor: captureSidebarDndPointerAnchor({ pointer, sourceRect }), - targetSection: sourceSection, - targetThreadKey: id.threadKey, - targetEdge: null, - destinationSection: null, - pinnedOrder: null, - snoozedUntil: null, - receiptSequencesByEnvironment: null, - viewportRailTopBySection: null, - }); - }, - [ - activeThreads, - canDragThread, - cleanupTrackedPointer, - holdSidebarScrollRange, - orderedPinnedThreads, - pauseSidebarLayoutMotion, - renderedSettledThreads, - retainSidebarLayoutAnchor, - setDragTransaction, - visibleSnoozedThreads, - ], - ); - const resolveThreadDropTarget = useCallback( - ( - current: SidebarThreadDragTransaction, - over: DragMoveEvent["over"], - ): SidebarThreadDropTarget | null => { - if (over === null) return null; - const overId = parseSidebarDndId(over.id); - if (overId === null) { - return null; - } - const destination = overId.section; - if (!canDropThreadInSection(current.sourceThread, current.sourceSection, destination)) { - return null; - } - let targetThreadKey = overId.kind === "section" ? null : overId.threadKey; - let targetEdge: "before" | "after" | null = null; - const pointerY = rawPointerRef.current?.y ?? over.rect.top + over.rect.height / 2; - if (targetThreadKey !== null) { - if (destination === "pinned" && !reorderablePinnedKeys.has(targetThreadKey)) return null; - targetEdge = pointerY < over.rect.top + over.rect.height / 2 ? "before" : "after"; - } else if (destination === "pinned" && orderedPinnedThreads.length > 0) { - const first = orderedPinnedThreads[0]; - const last = orderedPinnedThreads.at(-1); - const before = pointerY < over.rect.top + over.rect.height / 2; - const target = before ? first : last; - if (target !== undefined) { - targetThreadKey = sidebarThreadKey(target); - targetEdge = before ? "before" : "after"; - } - } - return { targetSection: destination, targetThreadKey, targetEdge }; - }, - [canDropThreadInSection, orderedPinnedThreads, reorderablePinnedKeys], - ); - const updateThreadDragTarget = useCallback( - (over: DragMoveEvent["over"]) => { - const current = dragTransactionRef.current; - if (current === null || current.phase !== "dragging") return; - const target = resolveThreadDropTarget(current, over); - if (target === null) { - if (current.targetSection === null) return; - setDragTransaction({ - ...current, - targetSection: null, - targetThreadKey: null, - targetEdge: null, - }); - return; - } - if ( - current.targetSection === target.targetSection && - current.targetThreadKey === target.targetThreadKey && - current.targetEdge === target.targetEdge - ) { - return; - } - setDragTransaction({ - ...current, - ...target, - }); - }, - [resolveThreadDropTarget, setDragTransaction], - ); - const handleThreadDragMove = useCallback( - (event: DragMoveEvent) => updateThreadDragTarget(event.over), - [updateThreadDragTarget], - ); - const handleThreadDragOver = useCallback( - (event: DragOverEvent) => updateThreadDragTarget(event.over), - [updateThreadDragTarget], + }), + [pinThread, reorderPinnedThread, settleThread, unpinThread, unsettleThread, unsnoozeThread], ); - const handleThreadDragCancel = useCallback( - (_event: DragCancelEvent) => finishSidebarDragTransaction(), - [finishSidebarDragTransaction], + const getThreadDndCapabilities = useCallback( + (thread: EnvironmentThreadShell) => + serverConfigs.get(thread.environmentId)?.environment.capabilities, + [serverConfigs], ); - const handleThreadDragEnd = useCallback( - (event: DragEndEvent) => { - const current = dragTransactionRef.current; - const releasePoint = releasePointerRef.current ?? rawPointerRef.current; - const target = - current !== null && current.phase === "dragging" - ? resolveThreadDropTarget(current, event.over) - : null; - cleanupTrackedPointer(); - if (current === null || current.phase !== "dragging" || target === null) { - finishSidebarDragTransaction(); - return; - } - const finalized = { ...current, ...target }; - const action = resolveSidebarDndAction({ - source: finalized.sourceSection, - destination: finalized.targetSection, - }); - if (action === "noop") { - finishSidebarDragTransaction(); - return; - } - if (action === "reorder-pinned") { - handlePinnedReorder( - finalized.sourceThreadKey, - finalized.targetThreadKey, - finalized.targetEdge, - ); - finishSidebarDragTransaction(); - return; - } - if (action === "snooze") { - openSidebarSnoozeDropMenu( - finalized, - releasePoint ?? { - x: finalized.sourceRect.left + finalized.sourceRect.width / 2, - y: finalized.sourceRect.top + finalized.sourceRect.height / 2, - }, - ); - return; - } - const pinnedPlan = action === "pin" ? planPinnedInsertion(finalized) : null; - if (action === "pin" && pinnedPlan === null) { - finishSidebarDragTransaction(); - return; - } - commitSidebarLifecycleDrop(finalized, finalized.targetSection, action, pinnedPlan); - }, - [ - cleanupTrackedPointer, - commitSidebarLifecycleDrop, - finishSidebarDragTransaction, - handlePinnedReorder, - openSidebarSnoozeDropMenu, - planPinnedInsertion, - resolveThreadDropTarget, - ], + const isRouteThread = useCallback( + (threadKey: string) => routeThreadKeyRef.current === threadKey, + [], ); - useLayoutEffect(() => { - if ( - dragTransaction === null || - dragTransaction.phase !== "reconciling" || - dragTransaction.receiptSequencesByEnvironment === null - ) { - return; - } - for (const [environmentId, receiptSequence] of dragTransaction.receiptSequencesByEnvironment) { - const snapshot = appAtomRegistry.get(environmentSnapshotAtom(environmentId)); - if (snapshot === null || snapshot.snapshotSequence < receiptSequence) return; - } - // Once every owning shell has crossed its receipt, canonical state is - // authoritative. A different section here is a later writer, not a UI - // state the local drop should keep masking. - finishSidebarDragTransaction({ excludeSource: true }); - }, [dragTransaction, finishSidebarDragTransaction, threads]); - useLayoutEffect(() => { - if ( - dragTransaction === null || - (dragTransaction.phase !== "dragging" && dragTransaction.phase !== "awaiting-snooze-choice") - ) { - return; - } - if (isSearchingThreads || !sourceStillMatchesDragStart(dragTransaction)) { - finishSidebarDragTransaction(); - } - }, [ - dragTransaction, - finishSidebarDragTransaction, - isSearchingThreads, - projectScopeKey, - sourceStillMatchesDragStart, + const threadDnd = useSidebarThreadDnd({ threads, - ]); + pinnedThreads, + allPinnedThreads, + activeThreads, + snoozedThreads, + visibleSnoozedThreads, + settledThreads, + renderedSettledThreads, + reorderablePinnedKeys, + allThreadByKey, + canonicalSectionByThreadKey, + isSearchingThreads, + scopeKey: projectScopeKey, + timestampFormat, + getCapabilities: getThreadDndCapabilities, + actions: threadDndActions, + performSnooze, + attemptUnsnooze, + planForwardNavigation, + isRouteThread, + }); + const dragTransaction = threadDnd.transaction; + const sidebarViewportRef = threadDnd.viewportRef; + const sidebarViewportOverlayRef = threadDnd.viewportOverlayRef; const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection); const handleMultiSelectContextMenu = useCallback( @@ -4895,178 +3279,97 @@ export default function Sidebar() { setShowJumpHints(shouldShowJumpHintsNow); }, [shouldShowJumpHintsNow]); - const boardSections = useMemo(() => { - let pinned = [...orderedPinnedThreads]; - let regular = [...activeThreads]; - let snoozed = [...visibleSnoozedThreads]; - let settled = [...renderedSettledThreads]; - const transaction = dragTransaction; - if (transaction === null) return { pinned, regular, snoozed, settled }; - - const withoutSource = (items: readonly EnvironmentThreadShell[]) => - items.filter((thread) => sidebarThreadKey(thread) !== transaction.sourceThreadKey); - pinned = withoutSource(pinned); - regular = withoutSource(regular); - snoozed = withoutSource(snoozed); - settled = withoutSource(settled); - - if (transaction.phase !== "reconciling" || transaction.destinationSection === null) { - switch (transaction.sourceSection) { - case "pinned": - pinned = insertSidebarThreadAt(pinned, transaction.sourceThread, transaction.sourceIndex); - break; - case "regular": - regular = insertSidebarThreadAt( - regular, - transaction.sourceThread, - transaction.sourceIndex, - ); - break; - case "snoozed": - snoozed = insertSidebarThreadAt( - snoozed, - transaction.sourceThread, - transaction.sourceIndex, - ); - break; - case "settled": - settled = insertSidebarThreadAt( - settled, - transaction.sourceThread, - transaction.sourceIndex, - ); - break; - } - return { pinned, regular, snoozed, settled }; - } - - const now = new Date().toISOString(); - switch (transaction.destinationSection) { - case "pinned": { - const optimistic = { - ...transaction.sourceThread, - pinnedAt: now, - settledOverride: "active" as const, - settledAt: null, - snoozedAt: null, - snoozedUntil: null, - }; - pinned = orderItemsByPreferredIds({ - items: [...pinned, optimistic], - preferredIds: transaction.pinnedOrder ?? [], - getId: sidebarThreadKey, - }); - break; - } - case "regular": - regular = sortThreadsForSidebar([ - ...regular, - { - ...transaction.sourceThread, - pinnedAt: null, - pinOrderKey: null, - settledOverride: "active" as const, - settledAt: null, - snoozedAt: null, - snoozedUntil: null, - }, - ]); - break; - case "snoozed": - snoozed = [ - ...snoozed, - { - ...transaction.sourceThread, - pinnedAt: null, - pinOrderKey: null, - settledOverride: "active" as const, - settledAt: null, - snoozedAt: now, - snoozedUntil: transaction.snoozedUntil, - }, - ].toSorted( - (left, right) => - firstValidTimestampMs(left.snoozedUntil ?? null) - - firstValidTimestampMs(right.snoozedUntil ?? null), - ); - break; - case "settled": - settled = sortSettledThreadsForSidebar([ - ...settled, - { - ...transaction.sourceThread, - pinnedAt: null, - pinOrderKey: null, - settledOverride: "settled" as const, - settledAt: now, - snoozedAt: null, - snoozedUntil: null, - }, - ]); - break; - } - return { pinned, regular, snoozed, settled }; - }, [ - activeThreads, - dragTransaction, - orderedPinnedThreads, - renderedSettledThreads, - visibleSnoozedThreads, - ]); - const dropIndicatorByThreadKey = useMemo(() => { - const indicators = new Map(); - const transaction = dragTransaction; - if ( - transaction === null || - transaction.phase === "reconciling" || - transaction.targetThreadKey === null || - transaction.targetEdge === null - ) { - return indicators; - } - indicators.set(transaction.targetThreadKey, transaction.targetEdge); - return indicators; - }, [dragTransaction]); - const isTemporarySectionRailVisible = useCallback( - (section: SidebarDndSection) => { - const transaction = dragTransaction; - if (transaction === null || transaction.phase === "reconciling") return false; - const sectionIsEmpty = - boardSections[section].length === 0 && - (section !== "snoozed" || snoozedThreads.length === 0) && - (section !== "settled" || settledThreads.length === 0); - return ( - sectionIsEmpty && - canDropThreadInSection(transaction.sourceThread, transaction.sourceSection, section) - ); - }, - [ - boardSections, - canDropThreadInSection, - dragTransaction, - settledThreads.length, - snoozedThreads.length, - ], - ); - const dragPreviewVariant = + const dragPreviewProject = dragTransaction?.phase === "dragging" - ? resolveSidebarDndPreviewVariant({ - source: dragTransaction.sourceSection, - destination: dragTransaction.targetSection, - }) + ? { + title: + projectDisplayNameByKey.get( + `${dragTransaction.sourceThread.environmentId}:${dragTransaction.sourceThread.projectId}`, + ) ?? null, + cwd: + projectCwdByKey.get( + `${dragTransaction.sourceThread.environmentId}:${dragTransaction.sourceThread.projectId}`, + ) ?? null, + faviconPath: + projectFaviconPathByKey.get( + `${dragTransaction.sourceThread.environmentId}:${dragTransaction.sourceThread.projectId}`, + ) ?? null, + } : null; - - const attachListAutoAnimateRef = useCallback( - (node: HTMLUListElement | null) => { - if (threadListNodeRef.current === node) return; - clearSidebarScrollRangeHold(); - autoAnimateControllerRef.current?.destroy?.(); - threadListNodeRef.current = node; - autoAnimateControllerRef.current = - node === null ? null : autoAnimate(node, { duration: 150, easing: "ease-out" }); - }, - [clearSidebarScrollRangeHold], - ); + const renderBoardThread = ( + thread: EnvironmentThreadShell, + section: SidebarDndSection, + state: SidebarThreadRenderState, + ) => { + const threadKey = sidebarThreadKey(thread); + const rowVariant = section === "regular" || section === "pinned" ? "card" : "slim"; + return ( + + ); + }; // New thread defaults to the project you're in (active thread's project, // falling back to the top project) — same resolution the command palette @@ -5375,486 +3678,34 @@ export default function Sidebar() { closeDelay={0} timeout={400} > - element === sidebarViewportRef.current, + + } + renderThread={renderBoardThread} + snoozedShelf={{ + threadCount: snoozedThreads.length, + expanded: snoozedShelfExpanded, + onToggle: toggleSnoozedShelf, }} - onDragStart={handleThreadDragStart} - onDragMove={handleThreadDragMove} - onDragOver={handleThreadDragOver} - onDragCancel={handleThreadDragCancel} - onDragEnd={handleThreadDragEnd} - > -
      - {(() => { - const activeDropTransaction = - dragTransaction?.phase === "dragging" ? dragTransaction : null; - const sectionDropDisabled = (section: SidebarDndSection) => - activeDropTransaction === null || - !canDropThreadInSection( - activeDropTransaction.sourceThread, - activeDropTransaction.sourceSection, - section, - ); - const renderThreadRow = ( - thread: EnvironmentThreadShell, - section: SidebarDndSection, - ) => { - const threadKey = sidebarThreadKey(thread); - const isCard = section === "regular" || section === "pinned"; - const rowVariant = isCard ? "card" : "slim"; - const dndDimmed = - dragTransaction?.sourceThreadKey === threadKey && - dragTransaction.phase !== "reconciling"; - const dndInert = - dragTransaction?.sourceThreadKey === threadKey && - dragTransaction.phase !== "dragging"; - const renderVisualRow = (dnd: SidebarThreadDndRowBag) => ( - - ); - const dragDisabled = - optimisticPinnedOrder !== null || - !canDragThread(thread, section) || - (dragTransaction !== null && dragTransaction.phase !== "dragging"); - const dropDisabled = sectionDropDisabled(section); - const rowKey = `${threadKey}:${rowVariant}`; - return section === "pinned" && reorderablePinnedKeys.has(threadKey) ? ( - - {renderVisualRow} - - ) : ( - - {renderVisualRow} - - ); - }; - const rail = (section: SidebarDndSection, label: string, isOver: boolean) => ( -
      -
      - {label} -
      -
      - ); - const showPinnedRail = isTemporarySectionRailVisible("pinned"); - const showRegularRail = isTemporarySectionRailVisible("regular"); - const showSnoozedRail = isTemporarySectionRailVisible("snoozed"); - const showSettledRail = isTemporarySectionRailVisible("settled"); - const visibleRailBySection = new Map([ - ["pinned", showPinnedRail], - ["regular", showRegularRail], - ["snoozed", showSnoozedRail], - ["settled", showSettledRail], - ]); - const viewportRailTopBySection = dragTransaction?.viewportRailTopBySection; - const viewportOverlayHost = sidebarViewportOverlayRef.current; - const viewportRailSections = new Set(); - if ( - viewportRailTopBySection !== null && - viewportRailTopBySection !== undefined - ) { - for (const section of viewportRailTopBySection.keys()) { - if ( - visibleRailBySection.get(section) === true && - viewportOverlayHost !== null - ) { - viewportRailSections.add(section); - } - } - } - const renderViewportRail = ( - section: SidebarDndSection, - label: string, - isOver: boolean, - setNodeRef: (node: HTMLElement | null) => void, - ) => { - const top = viewportRailTopBySection?.get(section); - if ( - top === undefined || - viewportOverlayHost === null || - !viewportRailSections.has(section) - ) { - return null; - } - return createPortal( - - {rail(section, label, isOver)} - , - viewportOverlayHost, - `sidebar-${section}-viewport-drop-rail`, - ); - }; - return ( - <> - - - {({ setNodeRef, isOver }) => { - const viewportRail = showPinnedRail - ? renderViewportRail("pinned", "Pinned", isOver, setNodeRef) - : null; - if (viewportRail !== null) return viewportRail; - return ( -
    • - sidebarThreadKey(thread)) - .filter((threadKey) => reorderablePinnedKeys.has(threadKey)) - .map((threadKey) => - createSidebarDndDraggableId({ - section: "pinned", - threadKey, - }), - )} - strategy={pinnedSortingStrategy} - > -
        - {boardSections.pinned.map((thread) => - renderThreadRow(thread, "pinned"), - )} -
      -
      - {showPinnedRail ? rail("pinned", "Pinned", isOver) : null} -
    • - ); - }} -
      - {(boardSections.pinned.length > 0 || showPinnedRail) && - !viewportRailSections.has("pinned") ? ( -
    • - ) : null} - - {({ setNodeRef, isOver }) => { - const viewportRail = showRegularRail - ? renderViewportRail("regular", "Regular", isOver, setNodeRef) - : null; - if (viewportRail !== null) return viewportRail; - return ( -
    • -
        - {boardSections.regular.map((thread) => - renderThreadRow(thread, "regular"), - )} -
      - {showRegularRail ? rail("regular", "Regular", isOver) : null} -
    • - ); - }} - - - {({ setNodeRef, isOver }) => { - const collapsedHeaderDropOver = isOver && !snoozedShelfExpanded; - const viewportRail = showSnoozedRail - ? renderViewportRail("snoozed", "Snooze", isOver, setNodeRef) - : null; - if (viewportRail !== null) return viewportRail; - return ( -
    • - {snoozedThreads.length > 0 ? ( -
      - -
      - ) : null} -
        - {boardSections.snoozed.map((thread) => - renderThreadRow(thread, "snoozed"), - )} -
      - {showSnoozedRail ? rail("snoozed", "Snooze", isOver) : null} -
    • - ); - }} -
      - - {({ setNodeRef, isOver }) => { - const collapsedHeaderDropOver = isOver && !settledShelfExpanded; - const viewportRail = showSettledRail - ? renderViewportRail("settled", "Settled", isOver, setNodeRef) - : null; - if (viewportRail !== null) return viewportRail; - return ( -
    • - {settledThreads.length > 0 ? ( -
      - -
      - ) : null} -
        - {boardSections.settled.map((thread) => - renderThreadRow(thread, "settled"), - )} -
      - {showSettledRail ? rail("settled", "Settled", isOver) : null} - {settledShelfExpanded && hiddenSettledCount > 0 ? ( - - ) : null} -
    • - ); - }} -
      - - ); - })()} -
    - - {dragTransaction?.phase === "dragging" && dragPreviewVariant !== null ? ( - - ) : null} - -
    + settledShelf={{ + threadCount: settledThreads.length, + expanded: settledShelfExpanded, + hiddenCount: hiddenSettledCount, + showMoreCount: Math.min(hiddenSettledCount, SETTLED_TAIL_PAGE_COUNT), + onToggle: toggleSettledShelf, + onShowMore: showMoreSettled, + }} + dragPreviewProject={dragPreviewProject} + /> ) : null} {!isSearchingThreads && diff --git a/apps/web/src/components/sidebar/SidebarThreadBoard.tsx b/apps/web/src/components/sidebar/SidebarThreadBoard.tsx new file mode 100644 index 000000000000..e0f20ad5fdaa --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarThreadBoard.tsx @@ -0,0 +1,407 @@ +import { DndContext, DragOverlay, MeasuringStrategy, type DndContextProps } from "@dnd-kit/core"; +import { restrictToVerticalAxis } from "@dnd-kit/modifiers"; +import { SortableContext, type SortingStrategy } from "@dnd-kit/sortable"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; +import { ChevronDownIcon, PlusIcon } from "lucide-react"; +import { createPortal } from "react-dom"; +import type { ReactNode } from "react"; + +import { cn } from "~/lib/utils"; +import type { SidebarDndLayout } from "../../hooks/useSidebarDndLayout"; +import type { SidebarThreadBoardSections } from "../Sidebar.dnd.board"; +import { + createSidebarDndDraggableId, + sidebarThreadKey, + SIDEBAR_DND_SECTIONS, + type SidebarDndPreviewVariant, + type SidebarDndSection, + type SidebarThreadDragTransaction, +} from "../Sidebar.dnd.logic"; +import { + DraggableSidebarThreadRow, + SidebarThreadDragOverlayContent, + SidebarThreadSectionDropZone, + SidebarThreadViewportDropRail, + SortableSidebarThreadRow, + type SidebarThreadDndRowBag, +} from "./SidebarThreadDnd"; + +type SidebarThreadDndContextProps = Pick< + DndContextProps, + | "sensors" + | "collisionDetection" + | "onDragStart" + | "onDragMove" + | "onDragOver" + | "onDragCancel" + | "onDragEnd" +>; + +export interface SidebarThreadRenderState { + readonly dnd: SidebarThreadDndRowBag; + readonly dimmed: boolean; + readonly inert: boolean; + readonly dropIndicator: "before" | "after" | null; +} + +export interface SidebarThreadBoardDnd { + readonly contextProps: SidebarThreadDndContextProps; + readonly layout: SidebarDndLayout; + readonly transaction: SidebarThreadDragTransaction | null; + readonly sections: SidebarThreadBoardSections; + readonly reorderablePinnedKeys: ReadonlySet; + readonly pinnedSortingStrategy: SortingStrategy; + readonly optimisticPinnedOrderActive: boolean; + readonly dropIndicatorByThreadKey: ReadonlyMap; + readonly dragPreviewVariant: SidebarDndPreviewVariant | null; + readonly canDragThread: (thread: EnvironmentThreadShell, source: SidebarDndSection) => boolean; + readonly canDropThreadInSection: ( + thread: EnvironmentThreadShell, + source: SidebarDndSection, + destination: SidebarDndSection, + ) => boolean; + readonly isTemporarySectionRailVisible: (section: SidebarDndSection) => boolean; +} + +export function SidebarThreadBoard(props: { + dnd: SidebarThreadBoardDnd; + drafts: ReactNode; + renderThread: ( + thread: EnvironmentThreadShell, + section: SidebarDndSection, + state: SidebarThreadRenderState, + ) => ReactNode; + snoozedShelf: { + readonly threadCount: number; + readonly expanded: boolean; + readonly onToggle: () => void; + }; + settledShelf: { + readonly threadCount: number; + readonly expanded: boolean; + readonly hiddenCount: number; + readonly showMoreCount: number; + readonly onToggle: () => void; + readonly onShowMore: () => void; + }; + dragPreviewProject: { + readonly title: string | null; + readonly cwd: string | null; + readonly faviconPath: string | null; + } | null; +}) { + const { dnd } = props; + const activeDropTransaction = dnd.transaction?.phase === "dragging" ? dnd.transaction : null; + const sectionDropDisabled = (section: SidebarDndSection) => + activeDropTransaction === null || + !dnd.canDropThreadInSection( + activeDropTransaction.sourceThread, + activeDropTransaction.sourceSection, + section, + ); + const renderThread = (thread: EnvironmentThreadShell, section: SidebarDndSection) => { + const threadKey = sidebarThreadKey(thread); + const rowVariant = section === "regular" || section === "pinned" ? "card" : "slim"; + const dragDisabled = + dnd.optimisticPinnedOrderActive || + !dnd.canDragThread(thread, section) || + (dnd.transaction !== null && dnd.transaction.phase !== "dragging"); + const renderVisualRow = (rowDnd: SidebarThreadDndRowBag) => + props.renderThread(thread, section, { + dnd: rowDnd, + dimmed: + dnd.transaction?.sourceThreadKey === threadKey && dnd.transaction.phase !== "reconciling", + inert: + dnd.transaction?.sourceThreadKey === threadKey && dnd.transaction.phase !== "dragging", + dropIndicator: dnd.dropIndicatorByThreadKey.get(threadKey) ?? null, + }); + const rowKey = `${threadKey}:${rowVariant}`; + return section === "pinned" && dnd.reorderablePinnedKeys.has(threadKey) ? ( + + {renderVisualRow} + + ) : ( + + {renderVisualRow} + + ); + }; + const rail = (section: SidebarDndSection, label: string, isOver: boolean) => ( +
    +
    + {label} +
    +
    + ); + const visibleRailBySection = new Map( + SIDEBAR_DND_SECTIONS.map((section) => [section, dnd.isTemporarySectionRailVisible(section)]), + ); + const viewportRailTopBySection = dnd.transaction?.viewportRailTopBySection; + const viewportOverlayHost = dnd.layout.viewportOverlayRef.current; + const viewportRailSections = new Set(); + if (viewportRailTopBySection !== null && viewportRailTopBySection !== undefined) { + for (const section of viewportRailTopBySection.keys()) { + if (visibleRailBySection.get(section) === true && viewportOverlayHost !== null) { + viewportRailSections.add(section); + } + } + } + const renderViewportRail = ( + section: SidebarDndSection, + label: string, + isOver: boolean, + setNodeRef: (node: HTMLElement | null) => void, + ) => { + const top = viewportRailTopBySection?.get(section); + if (top === undefined || viewportOverlayHost === null || !viewportRailSections.has(section)) { + return null; + } + return createPortal( + + {rail(section, label, isOver)} + , + viewportOverlayHost, + `sidebar-${section}-viewport-drop-rail`, + ); + }; + + return ( + element === dnd.layout.viewportRef.current, + }} + > +
      + {props.drafts} + + {({ setNodeRef, isOver }) => { + const showRail = visibleRailBySection.get("pinned") === true; + const viewportRail = showRail + ? renderViewportRail("pinned", "Pinned", isOver, setNodeRef) + : null; + if (viewportRail !== null) return viewportRail; + return ( +
    • + dnd.reorderablePinnedKeys.has(threadKey)) + .map((threadKey) => + createSidebarDndDraggableId({ section: "pinned", threadKey }), + )} + strategy={dnd.pinnedSortingStrategy} + > +
        + {dnd.sections.pinned.map((thread) => renderThread(thread, "pinned"))} +
      +
      + {showRail ? rail("pinned", "Pinned", isOver) : null} +
    • + ); + }} +
      + {(dnd.sections.pinned.length > 0 || visibleRailBySection.get("pinned") === true) && + !viewportRailSections.has("pinned") ? ( +
    • + ) : null} + + {({ setNodeRef, isOver }) => { + const showRail = visibleRailBySection.get("regular") === true; + const viewportRail = showRail + ? renderViewportRail("regular", "Regular", isOver, setNodeRef) + : null; + if (viewportRail !== null) return viewportRail; + return ( +
    • +
        + {dnd.sections.regular.map((thread) => renderThread(thread, "regular"))} +
      + {showRail ? rail("regular", "Regular", isOver) : null} +
    • + ); + }} + + + {({ setNodeRef, isOver }) => { + const collapsedHeaderDropOver = isOver && !props.snoozedShelf.expanded; + const showRail = visibleRailBySection.get("snoozed") === true; + const viewportRail = showRail + ? renderViewportRail("snoozed", "Snooze", isOver, setNodeRef) + : null; + if (viewportRail !== null) return viewportRail; + return ( +
    • + {props.snoozedShelf.threadCount > 0 ? ( +
      + +
      + ) : null} +
        + {dnd.sections.snoozed.map((thread) => renderThread(thread, "snoozed"))} +
      + {showRail ? rail("snoozed", "Snooze", isOver) : null} +
    • + ); + }} +
      + + {({ setNodeRef, isOver }) => { + const collapsedHeaderDropOver = isOver && !props.settledShelf.expanded; + const showRail = visibleRailBySection.get("settled") === true; + const viewportRail = showRail + ? renderViewportRail("settled", "Settled", isOver, setNodeRef) + : null; + if (viewportRail !== null) return viewportRail; + return ( +
    • + {props.settledShelf.threadCount > 0 ? ( +
      + +
      + ) : null} +
        + {dnd.sections.settled.map((thread) => renderThread(thread, "settled"))} +
      + {showRail ? rail("settled", "Settled", isOver) : null} + {props.settledShelf.expanded && props.settledShelf.hiddenCount > 0 ? ( + + ) : null} +
    • + ); + }} +
      +
    + + {dnd.transaction?.phase === "dragging" && + dnd.dragPreviewVariant !== null && + props.dragPreviewProject !== null ? ( + + ) : null} + +
    + ); +} diff --git a/apps/web/src/components/sidebar/SidebarThreadDnd.tsx b/apps/web/src/components/sidebar/SidebarThreadDnd.tsx new file mode 100644 index 000000000000..95bb876ddcf4 --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarThreadDnd.tsx @@ -0,0 +1,274 @@ +import { useDraggable, useDroppable } from "@dnd-kit/core"; +import { useSortable } from "@dnd-kit/sortable"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; +import { useCallback, useEffect, useLayoutEffect, useRef, type ReactNode } from "react"; + +import { cn } from "~/lib/utils"; +import { + createSidebarDndDraggableId, + createSidebarDndRowId, + createSidebarDndSectionId, + type SidebarDndPreviewVariant, + type SidebarDndSection, +} from "../Sidebar.dnd.logic"; +import { animatePinnedLayoutChanges } from "../Sidebar.logic"; +import { SidebarThreadDragPreview } from "./SidebarThreadDragPreview"; + +export type SidebarThreadDndRowBag = { + readonly listeners: ReturnType["listeners"]; + readonly setNodeRef: (node: HTMLElement | null) => void; + readonly transform: ReturnType["transform"]; + readonly transition: string | undefined; + readonly isDragging: boolean; + readonly isSortable: boolean; +}; + +export function SortableSidebarThreadRow(props: { + threadKey: string; + section: SidebarDndSection; + disabled: boolean; + onNodeChange: (threadKey: string, node: HTMLElement | null) => void; + children: (bag: SidebarThreadDndRowBag) => ReactNode; +}) { + const id = createSidebarDndDraggableId({ section: props.section, threadKey: props.threadKey }); + const sortable = useSortable({ + id, + disabled: props.disabled, + animateLayoutChanges: animatePinnedLayoutChanges, + data: { section: props.section, threadKey: props.threadKey }, + }); + const setNodeRef = useCallback( + (node: HTMLElement | null) => { + sortable.setNodeRef(node); + props.onNodeChange(props.threadKey, node); + }, + [props.onNodeChange, props.threadKey, sortable.setNodeRef], + ); + useEffect( + () => () => { + props.onNodeChange(props.threadKey, null); + }, + [props.onNodeChange, props.threadKey], + ); + return props.children({ + listeners: sortable.listeners, + setNodeRef, + transform: sortable.transform, + transition: sortable.transition, + isDragging: sortable.isDragging, + isSortable: true, + }); +} + +export function DraggableSidebarThreadRow(props: { + threadKey: string; + section: SidebarDndSection; + dragDisabled: boolean; + dropDisabled: boolean; + onNodeChange: (threadKey: string, node: HTMLElement | null) => void; + children: (bag: SidebarThreadDndRowBag) => ReactNode; +}) { + const draggable = useDraggable({ + id: createSidebarDndDraggableId({ + section: props.section, + threadKey: props.threadKey, + }), + disabled: props.dragDisabled, + data: { section: props.section, threadKey: props.threadKey }, + }); + const droppable = useDroppable({ + id: createSidebarDndRowId({ section: props.section, threadKey: props.threadKey }), + disabled: props.dropDisabled, + data: { section: props.section, threadKey: props.threadKey }, + }); + const setNodeRef = useCallback( + (node: HTMLElement | null) => { + draggable.setNodeRef(node); + droppable.setNodeRef(node); + props.onNodeChange(props.threadKey, node); + }, + [draggable.setNodeRef, droppable.setNodeRef, props.onNodeChange, props.threadKey], + ); + useEffect( + () => () => { + props.onNodeChange(props.threadKey, null); + }, + [props.onNodeChange, props.threadKey], + ); + return props.children({ + listeners: draggable.listeners, + setNodeRef, + // Sorted lists never apply the draggable transform to their source row. + transform: null, + transition: undefined, + isDragging: draggable.isDragging, + isSortable: false, + }); +} + +export function SidebarThreadSectionDropZone(props: { + section: SidebarDndSection; + disabled: boolean; + children: (bag: { + readonly setNodeRef: (node: HTMLElement | null) => void; + readonly isOver: boolean; + }) => ReactNode; +}) { + const droppable = useDroppable({ + id: createSidebarDndSectionId({ section: props.section }), + disabled: props.disabled, + data: { section: props.section }, + }); + return props.children({ setNodeRef: droppable.setNodeRef, isOver: droppable.isOver }); +} + +export function SidebarThreadViewportDropRail(props: { + section: SidebarDndSection; + top: number; + setDropNodeRef: (node: HTMLElement | null) => void; + onNodeChange: (section: SidebarDndSection, node: HTMLElement | null) => void; + children: ReactNode; +}) { + const setNodeRef = useCallback( + (node: HTMLDivElement | null) => { + props.setDropNodeRef(node); + props.onNodeChange(props.section, node); + }, + [props.onNodeChange, props.section, props.setDropNodeRef], + ); + + return ( +
    + {props.children} +
    + ); +} + +export function SidebarThreadDropIndicator(props: { edge: "before" | "after" }) { + return ( + + ); +} + +export interface SidebarThreadDragOverlayTransaction { + readonly sourceThread: EnvironmentThreadShell; + readonly sourceRect: { + readonly width: number; + readonly height: number; + }; + readonly pointerAnchor: { + readonly x: number; + readonly y: number; + }; +} + +export function SidebarThreadDragOverlayContent(props: { + transaction: SidebarThreadDragOverlayTransaction; + variant: SidebarDndPreviewVariant; + projectTitle: string | null; + projectCwd: string | null; + projectFaviconPath: string | null; +}) { + const innerRef = useRef(null); + const animationRef = useRef(null); + const geometryRef = useRef<{ + readonly width: number; + readonly height: number; + } | null>(null); + const previewHeight = props.variant === "card" ? 82 : 36; + const previewWidth = props.transaction.sourceRect.width; + const left = + props.transaction.pointerAnchor.x * props.transaction.sourceRect.width - + props.transaction.pointerAnchor.x * previewWidth; + const top = + props.transaction.pointerAnchor.y * props.transaction.sourceRect.height - + props.transaction.pointerAnchor.y * previewHeight; + + useLayoutEffect(() => { + const node = innerRef.current; + if (node === null) return; + const nextGeometry = { width: previewWidth, height: previewHeight }; + const previousGeometry = geometryRef.current; + geometryRef.current = nextGeometry; + if (previousGeometry === null) return; + + const interruptedRect = + animationRef.current?.playState === "running" ? node.getBoundingClientRect() : null; + animationRef.current?.cancel(); + const settledRect = node.getBoundingClientRect(); + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; + + const fromWidth = interruptedRect?.width ?? previousGeometry.width; + const fromHeight = interruptedRect?.height ?? previousGeometry.height; + const scaleX = settledRect.width > 0 ? fromWidth / settledRect.width : 1; + const scaleY = settledRect.height > 0 ? fromHeight / settledRect.height : 1; + const settledAnchorX = settledRect.left + props.transaction.pointerAnchor.x * settledRect.width; + const settledAnchorY = settledRect.top + props.transaction.pointerAnchor.y * settledRect.height; + const translateX = + interruptedRect === null + ? 0 + : interruptedRect.left + + props.transaction.pointerAnchor.x * interruptedRect.width - + settledAnchorX; + const translateY = + interruptedRect === null + ? 0 + : interruptedRect.top + + props.transaction.pointerAnchor.y * interruptedRect.height - + settledAnchorY; + animationRef.current = node.animate( + [ + { + transform: `translate(${translateX}px, ${translateY}px) scale(${scaleX}, ${scaleY})`, + opacity: 0.88, + }, + { transform: "translate(0, 0) scale(1, 1)", opacity: 1 }, + ], + { duration: 160, easing: "cubic-bezier(0.2, 0, 0, 1)", fill: "both" }, + ); + }, [previewHeight, previewWidth, props.transaction.pointerAnchor]); + useEffect(() => () => animationRef.current?.cancel(), []); + + return ( +
    +
    + +
    +
    + ); +} diff --git a/apps/web/src/hooks/useSidebarDndLayout.ts b/apps/web/src/hooks/useSidebarDndLayout.ts new file mode 100644 index 000000000000..bec15171ebfe --- /dev/null +++ b/apps/web/src/hooks/useSidebarDndLayout.ts @@ -0,0 +1,443 @@ +import { useAutoAnimate } from "@formkit/auto-animate/react"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; +import { useCallback, useEffect, useLayoutEffect, useRef, type RefObject } from "react"; + +import type { + SidebarDndSection, + SidebarThreadDragTransaction, +} from "../components/Sidebar.dnd.logic"; +import { SIDEBAR_DND_SECTIONS } from "../components/Sidebar.dnd.logic"; + +const SIDEBAR_DND_EMPTY_RAIL_HEIGHT = 48; + +type SidebarLayoutCorrection = + | { readonly kind: "stable" } + | { readonly kind: "corrected" } + | { + readonly kind: "clamped"; + readonly edge: "start" | "end"; + readonly missingScrollRange: number; + }; + +interface SidebarScrollRangeHold { + readonly node: HTMLUListElement; + readonly originalMinHeight: string; + readonly originalPaddingTop: string; + readonly originalPaddingBottom: string; + readonly height: number; + readonly topInset: number; + readonly bottomInset: number; +} + +export type SidebarThreadDragStateSetter = ( + next: + | SidebarThreadDragTransaction + | null + | ((current: SidebarThreadDragTransaction | null) => SidebarThreadDragTransaction | null), +) => void; + +export interface SidebarDndLayout { + readonly viewportRef: RefObject; + readonly viewportOverlayRef: RefObject; + readonly viewportRailSectionsRef: RefObject>; + readonly attachListRef: (node: HTMLUListElement | null) => void; + readonly handleViewportRailNodeChange: ( + section: SidebarDndSection, + node: HTMLElement | null, + ) => void; + readonly handleThreadRowNodeChange: (threadKey: string, node: HTMLElement | null) => void; + readonly getThreadRowNode: (threadKey: string) => HTMLElement | null; + readonly pauseLayoutMotion: () => void; + readonly holdScrollRange: () => void; + readonly retainLayoutAnchor: ( + preferred?: HTMLElement | null, + excludedThreadKey?: string | null, + ) => void; +} + +export function useSidebarDndLayout(input: { + transaction: SidebarThreadDragTransaction | null; + transactionRef: RefObject; + setTransaction: SidebarThreadDragStateSetter; + pinnedReorderInFlightRef: RefObject; + sectionThreadCounts: Readonly>; + canDropThreadInSection: ( + thread: EnvironmentThreadShell, + source: SidebarDndSection, + destination: SidebarDndSection, + ) => boolean; +}): SidebarDndLayout { + const [autoAnimateRef, setAutoAnimateEnabled] = useAutoAnimate({ + duration: 150, + easing: "ease-out", + }); + const viewportRef = useRef(null); + const viewportOverlayRef = useRef(null); + const viewportRailSectionsRef = useRef(new Set()); + const threadListNodeRef = useRef(null); + const scrollRangeHoldRef = useRef(null); + const threadRowNodesRef = useRef(new Map()); + const autoAnimatePausedRef = useRef(false); + const viewportOverflowAnchorRef = useRef(""); + const correctedScrollTopRef = useRef(null); + const retainedLayoutAnchorRef = useRef<{ + element: HTMLElement; + top: number; + } | null>(null); + + const handleViewportRailNodeChange = useCallback( + (section: SidebarDndSection, node: HTMLElement | null) => { + if (node === null) { + viewportRailSectionsRef.current.delete(section); + return; + } + viewportRailSectionsRef.current.add(section); + }, + [], + ); + const handleThreadRowNodeChange = useCallback((threadKey: string, node: HTMLElement | null) => { + if (node === null) { + threadRowNodesRef.current.delete(threadKey); + return; + } + threadRowNodesRef.current.set(threadKey, node); + }, []); + const getThreadRowNode = useCallback( + (threadKey: string) => threadRowNodesRef.current.get(threadKey) ?? null, + [], + ); + const pauseLayoutMotion = useCallback(() => { + if (autoAnimatePausedRef.current) return; + autoAnimatePausedRef.current = true; + setAutoAnimateEnabled(false); + const viewport = viewportRef.current; + if (viewport === null) return; + viewportOverflowAnchorRef.current = viewport.style.overflowAnchor; + viewport.style.overflowAnchor = "none"; + }, [setAutoAnimateEnabled]); + const chooseLayoutAnchor = useCallback( + (preferred: HTMLElement | null, excludedThreadKey: string | null = null) => { + const viewport = viewportRef.current; + if (viewport === null) return null; + const canAnchor = (element: HTMLElement) => { + if (!element.isConnected || element.dataset.dndTransformed === "true") return false; + const rect = element.getBoundingClientRect(); + const viewportRect = viewport.getBoundingClientRect(); + return rect.bottom > viewportRect.top && rect.top < viewportRect.bottom; + }; + if (preferred !== null && canAnchor(preferred)) return preferred; + for (const [threadKey, element] of threadRowNodesRef.current) { + if (threadKey === excludedThreadKey) continue; + if (canAnchor(element)) return element; + } + return null; + }, + [], + ); + const retainLayoutAnchor = useCallback( + (preferred: HTMLElement | null = null, excludedThreadKey: string | null = null) => { + const anchor = chooseLayoutAnchor(preferred, excludedThreadKey); + retainedLayoutAnchorRef.current = + anchor === null ? null : { element: anchor, top: anchor.getBoundingClientRect().top }; + }, + [chooseLayoutAnchor], + ); + const correctLayoutAnchor = useCallback((): SidebarLayoutCorrection => { + const viewport = viewportRef.current; + const retained = retainedLayoutAnchorRef.current; + if ( + viewport === null || + retained === null || + !retained.element.isConnected || + retained.element.dataset.dndTransformed === "true" + ) { + retainLayoutAnchor(); + return { kind: "stable" }; + } + const nextTop = retained.element.getBoundingClientRect().top; + const delta = nextTop - retained.top; + if (Math.abs(delta) > 0.5) { + const maxScrollTop = Math.max(0, viewport.scrollHeight - viewport.clientHeight); + const previousScrollTop = viewport.scrollTop; + const requestedScrollTop = previousScrollTop + delta; + const nextScrollTop = Math.min(maxScrollTop, Math.max(0, requestedScrollTop)); + viewport.scrollTop = nextScrollTop; + const appliedScrollTop = viewport.scrollTop; + if (Math.abs(appliedScrollTop - previousScrollTop) > 0.5) { + correctedScrollTopRef.current = appliedScrollTop; + } + if (Math.abs(appliedScrollTop - requestedScrollTop) > 0.5) { + return { + kind: "clamped", + edge: requestedScrollTop < 0 ? "start" : "end", + missingScrollRange: Math.abs(appliedScrollTop - requestedScrollTop), + }; + } + } + retainedLayoutAnchorRef.current = { + element: retained.element, + top: retained.element.getBoundingClientRect().top, + }; + return { kind: Math.abs(delta) > 0.5 ? "corrected" : "stable" }; + }, [retainLayoutAnchor]); + const clearScrollRangeHold = useCallback(() => { + const hold = scrollRangeHoldRef.current; + if (hold === null) return; + hold.node.style.minHeight = hold.originalMinHeight; + hold.node.style.paddingTop = hold.originalPaddingTop; + hold.node.style.paddingBottom = hold.originalPaddingBottom; + scrollRangeHoldRef.current = null; + }, []); + const holdScrollRange = useCallback(() => { + const node = threadListNodeRef.current; + if (node === null) return; + const current = scrollRangeHoldRef.current; + if (current !== null && current.node !== node) { + current.node.style.minHeight = current.originalMinHeight; + current.node.style.paddingTop = current.originalPaddingTop; + current.node.style.paddingBottom = current.originalPaddingBottom; + scrollRangeHoldRef.current = null; + } + const activeHold = scrollRangeHoldRef.current; + const height = Math.max(activeHold?.height ?? 0, node.getBoundingClientRect().height); + const next = { + node, + originalMinHeight: activeHold?.originalMinHeight ?? node.style.minHeight, + originalPaddingTop: activeHold?.originalPaddingTop ?? node.style.paddingTop, + originalPaddingBottom: activeHold?.originalPaddingBottom ?? node.style.paddingBottom, + height, + topInset: activeHold?.topInset ?? 0, + bottomInset: activeHold?.bottomInset ?? 0, + } satisfies SidebarScrollRangeHold; + scrollRangeHoldRef.current = next; + node.style.minHeight = `${height}px`; + node.style.paddingTop = + next.topInset === 0 + ? next.originalPaddingTop + : `calc(${next.originalPaddingTop || "0px"} + ${next.topInset}px)`; + node.style.paddingBottom = + next.bottomInset === 0 + ? next.originalPaddingBottom + : `calc(${next.originalPaddingBottom || "0px"} + ${next.bottomInset}px)`; + }, []); + const extendScrollRange = useCallback((edge: "start" | "end", missingScrollRange: number) => { + const hold = scrollRangeHoldRef.current; + if (hold === null || missingScrollRange <= 0.5) return false; + const next = { + ...hold, + height: hold.height + missingScrollRange, + topInset: hold.topInset + (edge === "start" ? missingScrollRange : 0), + bottomInset: hold.bottomInset + (edge === "end" ? missingScrollRange : 0), + } satisfies SidebarScrollRangeHold; + scrollRangeHoldRef.current = next; + next.node.style.minHeight = `${next.height}px`; + next.node.style.paddingTop = + next.topInset === 0 + ? next.originalPaddingTop + : `calc(${next.originalPaddingTop || "0px"} + ${next.topInset}px)`; + next.node.style.paddingBottom = + next.bottomInset === 0 + ? next.originalPaddingBottom + : `calc(${next.originalPaddingBottom || "0px"} + ${next.bottomInset}px)`; + return true; + }, []); + const releaseScrollRangeIfSafe = useCallback(() => { + const hold = scrollRangeHoldRef.current; + if (hold === null) return true; + const viewport = viewportRef.current; + if (viewport === null || !hold.node.isConnected) { + clearScrollRangeHold(); + return true; + } + + const anchor = chooseLayoutAnchor(null); + const previousAnchorTop = anchor?.getBoundingClientRect().top ?? null; + const previousScrollTop = viewport.scrollTop; + const previousOverflowAnchor = viewport.style.overflowAnchor; + viewport.style.overflowAnchor = "none"; + try { + if (hold.topInset > 0.5) { + viewport.scrollTop = Math.max(0, previousScrollTop - hold.topInset); + } + hold.node.style.minHeight = hold.originalMinHeight; + hold.node.style.paddingTop = hold.originalPaddingTop; + hold.node.style.paddingBottom = hold.originalPaddingBottom; + const naturalMaxScrollTop = Math.max(0, viewport.scrollHeight - viewport.clientHeight); + const anchorDelta = + anchor === null || previousAnchorTop === null + ? 0 + : anchor.getBoundingClientRect().top - previousAnchorTop; + const requestedScrollTop = viewport.scrollTop + anchorDelta; + const outsideNaturalRange = + requestedScrollTop < -0.5 || requestedScrollTop > naturalMaxScrollTop + 0.5; + const temporaryInsetReachedNaturalBoundary = + (requestedScrollTop < -0.5 && hold.topInset > 0.5) || + (requestedScrollTop > naturalMaxScrollTop + 0.5 && hold.bottomInset > 0.5); + if (outsideNaturalRange && !temporaryInsetReachedNaturalBoundary) { + hold.node.style.minHeight = `${hold.height}px`; + hold.node.style.paddingTop = + hold.topInset === 0 + ? hold.originalPaddingTop + : `calc(${hold.originalPaddingTop || "0px"} + ${hold.topInset}px)`; + hold.node.style.paddingBottom = + hold.bottomInset === 0 + ? hold.originalPaddingBottom + : `calc(${hold.originalPaddingBottom || "0px"} + ${hold.bottomInset}px)`; + viewport.scrollTop = previousScrollTop; + return false; + } + viewport.scrollTop = Math.min(naturalMaxScrollTop, Math.max(0, requestedScrollTop)); + correctedScrollTopRef.current = viewport.scrollTop; + scrollRangeHoldRef.current = null; + return true; + } finally { + viewport.style.overflowAnchor = previousOverflowAnchor; + } + }, [chooseLayoutAnchor, clearScrollRangeHold]); + const moveClampedEmptyRailsToViewport = useCallback( + (transaction: SidebarThreadDragTransaction) => { + if (transaction.phase !== "dragging" || transaction.viewportRailTopBySection !== null) { + return false; + } + const sourceOrderIndex = SIDEBAR_DND_SECTIONS.indexOf(transaction.sourceSection); + const overlaySections = SIDEBAR_DND_SECTIONS.slice(0, sourceOrderIndex).filter( + (section) => + input.sectionThreadCounts[section] === 0 && + input.canDropThreadInSection( + transaction.sourceThread, + transaction.sourceSection, + section, + ), + ); + if (overlaySections.length === 0) return false; + input.setTransaction((current) => { + if ( + current === null || + current.sourceThreadKey !== transaction.sourceThreadKey || + current.viewportRailTopBySection !== null + ) { + return current; + } + return { + ...current, + viewportRailTopBySection: new Map( + overlaySections.map((section, index) => [ + section, + index * SIDEBAR_DND_EMPTY_RAIL_HEIGHT, + ]), + ), + }; + }); + return true; + }, + [input.canDropThreadInSection, input.sectionThreadCounts, input.setTransaction], + ); + const correctDragLayout = useCallback( + (transaction: SidebarThreadDragTransaction) => { + const correction = correctLayoutAnchor(); + if (correction.kind !== "clamped") return; + if (correction.edge === "end" && moveClampedEmptyRailsToViewport(transaction)) return; + if (!extendScrollRange(correction.edge, correction.missingScrollRange)) { + retainLayoutAnchor(); + return; + } + if (correctLayoutAnchor().kind === "clamped") { + retainLayoutAnchor(); + } + }, + [correctLayoutAnchor, extendScrollRange, moveClampedEmptyRailsToViewport, retainLayoutAnchor], + ); + const attachListRef = useCallback( + (node: HTMLUListElement | null) => { + if (threadListNodeRef.current === node) return; + clearScrollRangeHold(); + threadListNodeRef.current = node; + autoAnimateRef(node); + }, + [autoAnimateRef, clearScrollRangeHold], + ); + + useLayoutEffect(() => { + if (input.transaction !== null) { + holdScrollRange(); + correctDragLayout(input.transaction); + return; + } + if (input.pinnedReorderInFlightRef.current) return; + if (!autoAnimatePausedRef.current) { + releaseScrollRangeIfSafe(); + return; + } + correctLayoutAnchor(); + autoAnimatePausedRef.current = false; + const viewport = viewportRef.current; + if (viewport !== null) { + viewport.style.overflowAnchor = viewportOverflowAnchorRef.current; + } + setAutoAnimateEnabled(true); + retainedLayoutAnchorRef.current = null; + releaseScrollRangeIfSafe(); + }); + useEffect(() => { + if (input.transaction === null) return; + const viewport = viewportRef.current; + if (viewport === null) return; + const handleScroll = () => { + const correctedScrollTop = correctedScrollTopRef.current; + if (correctedScrollTop !== null && Math.abs(viewport.scrollTop - correctedScrollTop) <= 0.5) { + return; + } + correctedScrollTopRef.current = null; + const retained = retainedLayoutAnchorRef.current; + if (retained === null || !retained.element.isConnected) { + retainLayoutAnchor(); + return; + } + retainedLayoutAnchorRef.current = { + element: retained.element, + top: retained.element.getBoundingClientRect().top, + }; + }; + viewport.addEventListener("scroll", handleScroll, { passive: true }); + return () => viewport.removeEventListener("scroll", handleScroll); + }, [input.transaction, retainLayoutAnchor]); + useEffect(() => { + if (input.transaction !== null || scrollRangeHoldRef.current === null) return; + const viewport = viewportRef.current; + if (viewport === null) return; + const handleScroll = () => { + if (releaseScrollRangeIfSafe()) { + viewport.removeEventListener("scroll", handleScroll); + } + }; + viewport.addEventListener("scroll", handleScroll, { passive: true }); + return () => viewport.removeEventListener("scroll", handleScroll); + }, [input.transaction, releaseScrollRangeIfSafe]); + useEffect(() => { + if (input.transaction === null || typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(() => { + const transaction = input.transactionRef.current; + if (transaction !== null) { + holdScrollRange(); + correctDragLayout(transaction); + } + }); + if (viewportRef.current !== null) observer.observe(viewportRef.current); + if (threadListNodeRef.current !== null) observer.observe(threadListNodeRef.current); + return () => observer.disconnect(); + }, [correctDragLayout, holdScrollRange, input.transaction, input.transactionRef]); + useEffect(() => () => clearScrollRangeHold(), [clearScrollRangeHold]); + + return { + viewportRef, + viewportOverlayRef, + viewportRailSectionsRef, + attachListRef, + handleViewportRailNodeChange, + handleThreadRowNodeChange, + getThreadRowNode, + pauseLayoutMotion, + holdScrollRange, + retainLayoutAnchor, + }; +} diff --git a/apps/web/src/hooks/useSidebarPinnedDnd.ts b/apps/web/src/hooks/useSidebarPinnedDnd.ts new file mode 100644 index 000000000000..b2e0a85005c2 --- /dev/null +++ b/apps/web/src/hooks/useSidebarPinnedDnd.ts @@ -0,0 +1,261 @@ +import type { SortingStrategy } from "@dnd-kit/sortable"; +import { verticalListSortingStrategy } from "@dnd-kit/sortable"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { + sidebarThreadKey, + type SidebarThreadDragTransaction, +} from "../components/Sidebar.dnd.logic"; +import { orderItemsByPreferredIds, planPinnedReorder } from "../components/Sidebar.logic"; +import { stackedThreadToast, toastManager } from "../components/ui/toast"; +import type { useThreadActions } from "./useThreadActions"; + +export interface SidebarPinnedInsertionPlan { + readonly order: readonly string[]; + readonly assignments: ReadonlyArray<{ readonly id: string; readonly orderKey: string }>; + readonly threadByKey: ReadonlyMap; +} + +interface OptimisticPinnedOrder { + readonly order: readonly string[]; + /** The pinOrderKey for each thread when the drop started. */ + readonly keysAtDrop: ReadonlyMap; + /** The keys written by this drop. */ + readonly assignedKeys: ReadonlyMap; +} + +function movePinnedThreadAtEdge(input: { + keys: readonly string[]; + activeKey: string; + overKey: string; + edge: "before" | "after"; +}): string[] | null { + if (!input.keys.includes(input.activeKey)) return null; + if (input.activeKey === input.overKey) return [...input.keys]; + + const next = input.keys.filter((key) => key !== input.activeKey); + const overIndex = next.indexOf(input.overKey); + if (overIndex === -1) return null; + const insertionIndex = overIndex + (input.edge === "after" ? 1 : 0); + next.splice(insertionIndex, 0, input.activeKey); + return next; +} + +export function useSidebarPinnedDnd(input: { + pinnedThreads: readonly EnvironmentThreadShell[]; + allPinnedThreads: readonly EnvironmentThreadShell[]; + reorderablePinnedKeys: ReadonlySet; + transaction: SidebarThreadDragTransaction | null; + reorderPinnedThread: ReturnType["reorderPinnedThread"]; + canPinWithOrder: (thread: EnvironmentThreadShell) => boolean; + canReorder: (thread: EnvironmentThreadShell) => boolean; +}) { + const pinnedReorderInFlightRef = useRef(false); + const [optimisticPinnedOrder, setOptimisticPinnedOrder] = useState( + null, + ); + const orderedPinnedThreads = useMemo(() => { + if (optimisticPinnedOrder === null) return input.pinnedThreads; + return orderItemsByPreferredIds({ + items: input.pinnedThreads, + preferredIds: optimisticPinnedOrder.order, + getId: sidebarThreadKey, + }); + }, [input.pinnedThreads, optimisticPinnedOrder]); + const pinnedSortingOverIndex = useMemo(() => { + const transaction = input.transaction; + if ( + transaction === null || + transaction.phase !== "dragging" || + transaction.sourceSection !== "pinned" || + transaction.targetSection !== "pinned" || + transaction.targetThreadKey === null || + transaction.targetEdge === null + ) { + return null; + } + const keys = orderedPinnedThreads + .map(sidebarThreadKey) + .filter((threadKey) => input.reorderablePinnedKeys.has(threadKey)); + const previewOrder = movePinnedThreadAtEdge({ + keys, + activeKey: transaction.sourceThreadKey, + overKey: transaction.targetThreadKey, + edge: transaction.targetEdge, + }); + return previewOrder?.indexOf(transaction.sourceThreadKey) ?? null; + }, [input.reorderablePinnedKeys, input.transaction, orderedPinnedThreads]); + const pinnedSortingStrategy = useCallback( + (args) => + verticalListSortingStrategy({ + ...args, + overIndex: pinnedSortingOverIndex ?? args.overIndex, + }), + [pinnedSortingOverIndex], + ); + + useEffect(() => { + if (optimisticPinnedOrder === null) return; + const canonical = input.pinnedThreads.filter((thread) => + input.reorderablePinnedKeys.has(sidebarThreadKey(thread)), + ); + const canonicalKeys = canonical.map(sidebarThreadKey); + const membershipChanged = + canonicalKeys.length !== optimisticPinnedOrder.order.length || + canonicalKeys.some((key) => !optimisticPinnedOrder.order.includes(key)); + const foreignKeyLanded = canonical.some((thread) => { + const threadKey = sidebarThreadKey(thread); + const currentKey = thread.pinOrderKey ?? null; + if (currentKey === optimisticPinnedOrder.keysAtDrop.get(threadKey)) return false; + return currentKey !== optimisticPinnedOrder.assignedKeys.get(threadKey); + }); + const currentKeyByThreadKey = new Map( + canonical.map((thread) => [sidebarThreadKey(thread), thread.pinOrderKey ?? null] as const), + ); + const allAssignmentsLanded = [...optimisticPinnedOrder.assignedKeys].every( + ([threadKey, orderKey]) => currentKeyByThreadKey.get(threadKey) === orderKey, + ); + const orderConfirmed = + !membershipChanged && + canonicalKeys.every((key, index) => key === optimisticPinnedOrder.order[index]); + if (membershipChanged || foreignKeyLanded || allAssignmentsLanded || orderConfirmed) { + pinnedReorderInFlightRef.current = false; + setOptimisticPinnedOrder(null); + } + }, [input.pinnedThreads, input.reorderablePinnedKeys, optimisticPinnedOrder]); + + const handlePinnedReorder = useCallback( + (activeKey: string, overKey: string | null, targetEdge: "before" | "after" | null) => { + if ( + pinnedReorderInFlightRef.current || + overKey === null || + targetEdge === null || + activeKey === overKey + ) { + return; + } + const reorderable = orderedPinnedThreads.filter((thread) => + input.reorderablePinnedKeys.has(sidebarThreadKey(thread)), + ); + const keys = reorderable.map(sidebarThreadKey); + const newOrder = movePinnedThreadAtEdge({ + keys, + activeKey, + overKey, + edge: targetEdge, + }); + if (newOrder === null || newOrder.every((key, index) => key === keys[index])) return; + + const threadByKey = new Map( + reorderable.map((thread) => [sidebarThreadKey(thread), thread] as const), + ); + const keysAtDrop = new Map( + reorderable.map( + (thread) => [sidebarThreadKey(thread), thread.pinOrderKey ?? null] as const, + ), + ); + const assignments = planPinnedReorder({ + orderedIds: newOrder, + keysById: keysAtDrop, + movedId: activeKey, + }); + if (assignments.length === 0) return; + + pinnedReorderInFlightRef.current = true; + setOptimisticPinnedOrder({ + order: newOrder, + keysAtDrop, + assignedKeys: new Map( + assignments.map((assignment) => [assignment.id, assignment.orderKey]), + ), + }); + void (async () => { + for (const assignment of assignments) { + const thread = threadByKey.get(assignment.id); + if (thread === undefined) continue; + const result = await input.reorderPinnedThread( + scopeThreadRef(thread.environmentId, thread.id), + assignment.orderKey, + ); + if (result._tag === "Failure") { + pinnedReorderInFlightRef.current = false; + setOptimisticPinnedOrder(null); + if (isAtomCommandInterrupted(result)) return; + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to reorder pinned threads", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + return; + } + } + })(); + }, + [input.reorderPinnedThread, input.reorderablePinnedKeys, orderedPinnedThreads], + ); + const planPinnedInsertion = useCallback( + (transaction: SidebarThreadDragTransaction): SidebarPinnedInsertionPlan | null => { + if (transaction.sourceSection === "pinned" || transaction.targetSection !== "pinned") { + return null; + } + const existingKeys = input.allPinnedThreads.map(sidebarThreadKey); + let insertionIndex = existingKeys.length; + if (transaction.targetThreadKey !== null) { + const targetIndex = existingKeys.indexOf(transaction.targetThreadKey); + if (targetIndex !== -1) { + insertionIndex = targetIndex + (transaction.targetEdge === "after" ? 1 : 0); + } + } else if (existingKeys.length === 0) { + insertionIndex = 0; + } + const order = [...existingKeys]; + order.splice(insertionIndex, 0, transaction.sourceThreadKey); + const threadByKey = new Map( + input.allPinnedThreads.map((thread) => [sidebarThreadKey(thread), thread] as const), + ); + threadByKey.set(transaction.sourceThreadKey, transaction.sourceThread); + const keysById = new Map( + input.allPinnedThreads.map((thread) => [ + sidebarThreadKey(thread), + thread.pinOrderKey ?? null, + ]), + ); + keysById.set(transaction.sourceThreadKey, null); + const assignments = planPinnedReorder({ + orderedIds: order, + keysById, + movedId: transaction.sourceThreadKey, + }); + if (assignments.length === 0) return null; + for (const assignment of assignments) { + const thread = threadByKey.get(assignment.id); + if (thread === undefined) return null; + if (assignment.id === transaction.sourceThreadKey) { + if (!input.canPinWithOrder(thread)) return null; + } else if (!input.canReorder(thread)) { + return null; + } + } + return { order, assignments, threadByKey }; + }, + [input.allPinnedThreads, input.canPinWithOrder, input.canReorder], + ); + + return { + optimisticPinnedOrder, + orderedPinnedThreads, + pinnedSortingStrategy, + pinnedReorderInFlightRef, + handlePinnedReorder, + planPinnedInsertion, + }; +} diff --git a/apps/web/src/hooks/useSidebarThreadDnd.ts b/apps/web/src/hooks/useSidebarThreadDnd.ts new file mode 100644 index 000000000000..6b3c66eb1da7 --- /dev/null +++ b/apps/web/src/hooks/useSidebarThreadDnd.ts @@ -0,0 +1,878 @@ +import { + PointerSensor, + closestCenter, + pointerWithin, + useSensor, + useSensors, + type CollisionDetection, + type DragCancelEvent, + type DragEndEvent, + type DragMoveEvent, + type DragOverEvent, + type DragStartEvent, +} from "@dnd-kit/core"; +import { getEventCoordinates } from "@dnd-kit/utilities"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; +import { canSettle, canSnooze } from "@t3tools/client-runtime/state/thread-settled"; +import { + isAtomCommandInterrupted, + settlePromise, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import type { ScopedThreadRef } from "@t3tools/contracts"; +import type { TimestampFormat } from "@t3tools/contracts/settings"; +import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; + +import { readLocalApi } from "../localApi"; +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { environmentSnapshotAtom } from "../state/shell"; +import { buildSidebarDndBoardSections } from "../components/Sidebar.dnd.board"; +import { + captureSidebarDndPointerAnchor, + parseSidebarDndId, + resolveSidebarDndAction, + resolveSidebarDndPreviewVariant, + sidebarThreadKey, + SIDEBAR_DND_SECTIONS, + type SidebarDndAction, + type SidebarDndSection, + type SidebarThreadDragTransaction, +} from "../components/Sidebar.dnd.logic"; +import { + resolveSnoozePresets, + snoozeWakeDescription, + type SnoozePreset, +} from "../components/Sidebar.snooze"; +import { stackedThreadToast, toastManager } from "../components/ui/toast"; +import { useSidebarDndLayout } from "./useSidebarDndLayout"; +import { useSidebarPinnedDnd, type SidebarPinnedInsertionPlan } from "./useSidebarPinnedDnd"; +import type { useThreadActions } from "./useThreadActions"; + +interface SidebarThreadDropTarget { + readonly targetSection: SidebarDndSection; + readonly targetThreadKey: string | null; + readonly targetEdge: "before" | "after" | null; +} + +interface SidebarThreadDndCapabilities { + readonly threadPinning?: boolean; + readonly threadPinReorder?: boolean; + readonly threadSettlement?: boolean; + readonly threadSnooze?: boolean; +} + +type SidebarThreadDndActions = Pick< + ReturnType, + | "pinThread" + | "unpinThread" + | "reorderPinnedThread" + | "settleThread" + | "unsettleThread" + | "unsnoozeThread" +>; + +type SidebarSnoozeOutcome = + | { readonly status: "skipped" | "interrupted" } + | { readonly status: "failure"; readonly error: unknown } + | { readonly status: "success"; readonly sequence: number }; + +function sectionIndex( + section: SidebarDndSection, + threadKey: string, + sections: Readonly>, +): number { + const index = sections[section].findIndex((thread) => sidebarThreadKey(thread) === threadKey); + return Math.max(0, index); +} + +export function useSidebarThreadDnd(input: { + threads: readonly EnvironmentThreadShell[]; + pinnedThreads: readonly EnvironmentThreadShell[]; + allPinnedThreads: readonly EnvironmentThreadShell[]; + activeThreads: readonly EnvironmentThreadShell[]; + snoozedThreads: readonly EnvironmentThreadShell[]; + visibleSnoozedThreads: readonly EnvironmentThreadShell[]; + settledThreads: readonly EnvironmentThreadShell[]; + renderedSettledThreads: readonly EnvironmentThreadShell[]; + reorderablePinnedKeys: ReadonlySet; + allThreadByKey: ReadonlyMap; + canonicalSectionByThreadKey: ReadonlyMap; + isSearchingThreads: boolean; + scopeKey: string | null; + timestampFormat: TimestampFormat; + getCapabilities: (thread: EnvironmentThreadShell) => SidebarThreadDndCapabilities | undefined; + actions: SidebarThreadDndActions; + performSnooze: ( + threadRef: ScopedThreadRef, + preset: SnoozePreset, + ) => Promise; + attemptUnsnooze: (threadRef: ScopedThreadRef) => void; + planForwardNavigation: (threadKey: string) => (() => void) | null; + isRouteThread: (threadKey: string) => boolean; +}) { + const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } })); + const [transaction, setTransactionState] = useState(null); + const transactionRef = useRef(null); + const setTransaction = useCallback( + ( + next: + | SidebarThreadDragTransaction + | null + | ((current: SidebarThreadDragTransaction | null) => SidebarThreadDragTransaction | null), + ) => { + const resolved = typeof next === "function" ? next(transactionRef.current) : next; + transactionRef.current = resolved; + setTransactionState(resolved); + }, + [], + ); + const allThreadByKeyRef = useRef(input.allThreadByKey); + allThreadByKeyRef.current = input.allThreadByKey; + const canonicalSectionByThreadKeyRef = useRef(input.canonicalSectionByThreadKey); + canonicalSectionByThreadKeyRef.current = input.canonicalSectionByThreadKey; + const pointerCoordinatesRef = useRef<{ x: number; y: number } | null>(null); + const snoozeDropEpochRef = useRef(0); + + const canPinWithOrder = useCallback( + (thread: EnvironmentThreadShell) => { + const capabilities = input.getCapabilities(thread); + return capabilities?.threadPinning === true && capabilities.threadPinReorder === true; + }, + [input.getCapabilities], + ); + const canReorderPinnedThread = useCallback( + (thread: EnvironmentThreadShell) => input.getCapabilities(thread)?.threadPinReorder === true, + [input.getCapabilities], + ); + const canDropThreadInSection = useCallback( + (thread: EnvironmentThreadShell, source: SidebarDndSection, destination: SidebarDndSection) => { + const capabilities = input.getCapabilities(thread); + const action = resolveSidebarDndAction({ source, destination }); + switch (action) { + case "noop": + return true; + case "reorder-pinned": + return canReorderPinnedThread(thread); + case "pin": + return canPinWithOrder(thread); + case "unpin": + return capabilities?.threadPinning === true; + case "unsettle": + return capabilities?.threadSettlement === true; + case "unsnooze": + return capabilities?.threadSnooze === true; + case "settle": + return ( + capabilities?.threadSettlement === true && + canSettle(thread, { now: new Date().toISOString() }) + ); + case "snooze": + return ( + capabilities?.threadSnooze === true && + canSnooze(thread, { now: new Date().toISOString() }) + ); + } + }, + [canPinWithOrder, canReorderPinnedThread, input.getCapabilities], + ); + const canDragThread = useCallback( + (thread: EnvironmentThreadShell, source: SidebarDndSection) => + SIDEBAR_DND_SECTIONS.some((destination) => { + const action = resolveSidebarDndAction({ source, destination }); + return action !== "noop" && canDropThreadInSection(thread, source, destination); + }), + [canDropThreadInSection], + ); + const pinnedDnd = useSidebarPinnedDnd({ + pinnedThreads: input.pinnedThreads, + allPinnedThreads: input.allPinnedThreads, + reorderablePinnedKeys: input.reorderablePinnedKeys, + transaction, + reorderPinnedThread: input.actions.reorderPinnedThread, + canPinWithOrder, + canReorder: canReorderPinnedThread, + }); + const { + optimisticPinnedOrder, + orderedPinnedThreads, + pinnedSortingStrategy, + pinnedReorderInFlightRef, + handlePinnedReorder, + planPinnedInsertion, + } = pinnedDnd; + const sectionThreadCounts = useMemo( + () => ({ + pinned: input.pinnedThreads.length, + regular: input.activeThreads.length, + snoozed: input.snoozedThreads.length, + settled: input.settledThreads.length, + }), + [ + input.activeThreads.length, + input.pinnedThreads.length, + input.settledThreads.length, + input.snoozedThreads.length, + ], + ); + const layout = useSidebarDndLayout({ + transaction, + transactionRef, + setTransaction, + pinnedReorderInFlightRef, + sectionThreadCounts, + canDropThreadInSection, + }); + const { + viewportRef, + viewportOverlayRef, + viewportRailSectionsRef, + getThreadRowNode, + pauseLayoutMotion, + holdScrollRange, + retainLayoutAnchor, + } = layout; + + const sourceStillMatchesDragStart = useCallback((current: SidebarThreadDragTransaction) => { + const source = allThreadByKeyRef.current.get(current.sourceThreadKey); + return ( + source !== undefined && + source.archivedAt === null && + canonicalSectionByThreadKeyRef.current.get(current.sourceThreadKey) === current.sourceSection + ); + }, []); + const finishTransaction = useCallback( + (options: { excludeSource?: boolean } = {}) => { + const current = transactionRef.current; + snoozeDropEpochRef.current += 1; + if (current?.phase === "awaiting-snooze-choice") { + void readLocalApi()?.contextMenu.close(); + } + pointerCoordinatesRef.current = null; + retainLayoutAnchor( + options.excludeSource || current === null + ? null + : getThreadRowNode(current.sourceThreadKey), + options.excludeSource && current !== null ? current.sourceThreadKey : null, + ); + setTransaction(null); + }, + [getThreadRowNode, retainLayoutAnchor, setTransaction], + ); + const beginReconciliation = useCallback( + (reconciliation: { + transaction: SidebarThreadDragTransaction; + destinationSection: SidebarDndSection; + receiptSequencesByEnvironment: ReadonlyMap; + pinnedOrder?: readonly string[] | null; + snoozedUntil?: string | null; + }) => { + retainLayoutAnchor(null, reconciliation.transaction.sourceThreadKey); + setTransaction({ + ...reconciliation.transaction, + phase: "reconciling", + targetSection: reconciliation.destinationSection, + destinationSection: reconciliation.destinationSection, + pinnedOrder: reconciliation.pinnedOrder ?? null, + snoozedUntil: reconciliation.snoozedUntil ?? null, + receiptSequencesByEnvironment: reconciliation.receiptSequencesByEnvironment, + }); + }, + [retainLayoutAnchor, setTransaction], + ); + const reportDropFailure = useCallback( + ( + title: string, + result: Parameters[0] & { readonly _tag: "Failure" }, + ) => { + if (isAtomCommandInterrupted(result)) return; + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title, + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + }, + [], + ); + const commitLifecycleDrop = useCallback( + ( + current: SidebarThreadDragTransaction, + destinationSection: SidebarDndSection, + action: Exclude, + pinnedPlan: SidebarPinnedInsertionPlan | null, + ) => { + void (async () => { + if (!sourceStillMatchesDragStart(current)) { + finishTransaction(); + return; + } + setTransaction({ + ...current, + phase: "committing", + targetSection: destinationSection, + destinationSection, + pinnedOrder: pinnedPlan?.order ?? null, + snoozedUntil: null, + receiptSequencesByEnvironment: null, + }); + const threadRef = scopeThreadRef( + current.sourceThread.environmentId, + current.sourceThread.id, + ); + const receiptSequences = new Map(); + const recordReceipt = ( + environmentId: EnvironmentThreadShell["environmentId"], + sequence: number, + ) => { + receiptSequences.set( + environmentId, + Math.max(receiptSequences.get(environmentId) ?? 0, sequence), + ); + }; + if (action === "pin") { + if (pinnedPlan === null) { + finishTransaction(); + return; + } + for (const assignment of pinnedPlan.assignments) { + if (assignment.id === current.sourceThreadKey) continue; + const thread = pinnedPlan.threadByKey.get(assignment.id); + if (thread === undefined) { + finishTransaction(); + return; + } + const result = await input.actions.reorderPinnedThread( + scopeThreadRef(thread.environmentId, thread.id), + assignment.orderKey, + ); + if (result._tag === "Failure") { + finishTransaction(); + reportDropFailure("Failed to prepare pinned order", result); + return; + } + recordReceipt(thread.environmentId, result.value.sequence); + } + const sourceAssignment = pinnedPlan.assignments.find( + (assignment) => assignment.id === current.sourceThreadKey, + ); + if (sourceAssignment === undefined) { + finishTransaction(); + return; + } + const result = await input.actions.pinThread(threadRef, { + orderKey: sourceAssignment.orderKey, + }); + if (result._tag === "Failure") { + finishTransaction(); + reportDropFailure("Failed to pin thread", result); + return; + } + recordReceipt(current.sourceThread.environmentId, result.value.sequence); + beginReconciliation({ + transaction: current, + destinationSection, + receiptSequencesByEnvironment: receiptSequences, + pinnedOrder: pinnedPlan.order, + }); + return; + } + + const navigateAfterSettle = + action === "settle" ? input.planForwardNavigation(current.sourceThreadKey) : null; + const result = + action === "unpin" + ? await input.actions.unpinThread(threadRef) + : action === "unsettle" + ? await input.actions.unsettleThread(threadRef) + : action === "unsnooze" + ? await input.actions.unsnoozeThread(threadRef) + : await input.actions.settleThread(threadRef); + if (result._tag === "Failure") { + finishTransaction(); + reportDropFailure( + action === "unpin" + ? "Failed to unpin thread" + : action === "unsettle" + ? "Failed to un-settle thread" + : action === "unsnooze" + ? "Failed to wake thread" + : "Failed to settle thread", + result, + ); + return; + } + if (action === "settle" && input.isRouteThread(current.sourceThreadKey)) { + navigateAfterSettle?.(); + } + recordReceipt(current.sourceThread.environmentId, result.value.sequence); + beginReconciliation({ + transaction: current, + destinationSection, + receiptSequencesByEnvironment: receiptSequences, + }); + })(); + }, + [ + beginReconciliation, + finishTransaction, + input.actions, + input.isRouteThread, + input.planForwardNavigation, + reportDropFailure, + setTransaction, + sourceStillMatchesDragStart, + ], + ); + const openSnoozeDropMenu = useCallback( + (current: SidebarThreadDragTransaction, position: { x: number; y: number }) => { + const epoch = snoozeDropEpochRef.current + 1; + snoozeDropEpochRef.current = epoch; + setTransaction({ + ...current, + phase: "awaiting-snooze-choice", + targetSection: "snoozed", + destinationSection: "snoozed", + pinnedOrder: null, + snoozedUntil: null, + receiptSequencesByEnvironment: null, + }); + void (async () => { + const api = readLocalApi(); + if (api === undefined) { + finishTransaction(); + return; + } + const menuPresets = resolveSnoozePresets(new Date(), input.timestampFormat); + const selected = await settlePromise(() => + api.contextMenu.show( + menuPresets.map((preset) => ({ + id: `snooze:${preset.id}`, + label: `${preset.label} (${preset.whenLabel})`, + })), + position, + ), + ); + if (snoozeDropEpochRef.current !== epoch) return; + if (selected._tag === "Failure" || selected.value === null) { + finishTransaction(); + return; + } + const selectedId = selected.value.startsWith("snooze:") + ? selected.value.slice("snooze:".length) + : null; + const preset = resolveSnoozePresets(new Date(), input.timestampFormat).find( + (candidate) => candidate.id === selectedId, + ); + if (preset === undefined || !sourceStillMatchesDragStart(current)) { + finishTransaction(); + return; + } + setTransaction({ + ...current, + phase: "committing", + targetSection: "snoozed", + destinationSection: "snoozed", + pinnedOrder: null, + snoozedUntil: preset.snoozedUntil, + receiptSequencesByEnvironment: null, + }); + const threadRef = scopeThreadRef( + current.sourceThread.environmentId, + current.sourceThread.id, + ); + const outcome = await input.performSnooze(threadRef, preset); + if (outcome.status === "failure") { + finishTransaction(); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to snooze thread", + description: + outcome.error instanceof Error ? outcome.error.message : "An error occurred.", + }), + ); + return; + } + if (outcome.status !== "success") { + finishTransaction(); + return; + } + toastManager.add( + stackedThreadToast({ + type: "success", + title: `Snoozed until ${snoozeWakeDescription(preset.snoozedUntil, new Date(), input.timestampFormat)}`, + timeout: 5_000, + actionProps: { + children: "Wake", + onClick: () => input.attemptUnsnooze(threadRef), + }, + }), + ); + beginReconciliation({ + transaction: current, + destinationSection: "snoozed", + receiptSequencesByEnvironment: new Map([ + [current.sourceThread.environmentId, outcome.sequence], + ]), + snoozedUntil: preset.snoozedUntil, + }); + })(); + }, + [ + beginReconciliation, + finishTransaction, + input.attemptUnsnooze, + input.performSnooze, + input.timestampFormat, + setTransaction, + sourceStillMatchesDragStart, + ], + ); + + const collisionDetection = useCallback( + (args) => { + if (args.pointerCoordinates !== null) pointerCoordinatesRef.current = args.pointerCoordinates; + const pointerCollisions = pointerWithin(args); + if (pointerCollisions.length > 0) { + const viewportRailSections = viewportRailSectionsRef.current; + return pointerCollisions.toSorted((left, right) => { + const priority = (id: ReturnType) => { + if (id?.kind === "section" && viewportRailSections.has(id.section)) return 0; + return id?.kind === "section" ? 2 : 1; + }; + return priority(parseSidebarDndId(left.id)) - priority(parseSidebarDndId(right.id)); + }); + } + return closestCenter(args); + }, + [viewportRailSectionsRef], + ); + const handleDragStart = useCallback( + (event: DragStartEvent) => { + if (pinnedReorderInFlightRef.current) return; + const id = parseSidebarDndId(event.active.id); + if (id === null || id.kind !== "draggable") return; + const sourceThread = allThreadByKeyRef.current.get(id.threadKey); + const sourceNode = getThreadRowNode(id.threadKey); + if (sourceThread === undefined || sourceNode === null) return; + const sourceSection = canonicalSectionByThreadKeyRef.current.get(id.threadKey); + if (sourceSection === undefined || !canDragThread(sourceThread, sourceSection)) return; + const sourceRect = sourceNode.getBoundingClientRect(); + const pointer = getEventCoordinates(event.activatorEvent) ?? { + x: sourceRect.left + sourceRect.width / 2, + y: sourceRect.top + sourceRect.height / 2, + }; + pointerCoordinatesRef.current = pointer; + const sections = { + pinned: orderedPinnedThreads, + regular: input.activeThreads, + snoozed: input.visibleSnoozedThreads, + settled: input.renderedSettledThreads, + } satisfies Readonly>; + pauseLayoutMotion(); + holdScrollRange(); + retainLayoutAnchor(sourceNode); + setTransaction({ + phase: "dragging", + sourceThread, + sourceThreadKey: id.threadKey, + sourceSection, + sourceIndex: sectionIndex(sourceSection, id.threadKey, sections), + sourceRect: { + left: sourceRect.left, + top: sourceRect.top, + width: sourceRect.width, + height: sourceRect.height, + }, + pointerAnchor: captureSidebarDndPointerAnchor({ pointer, sourceRect }), + targetSection: sourceSection, + targetThreadKey: id.threadKey, + targetEdge: null, + destinationSection: null, + pinnedOrder: null, + snoozedUntil: null, + receiptSequencesByEnvironment: null, + viewportRailTopBySection: null, + }); + }, + [ + canDragThread, + getThreadRowNode, + holdScrollRange, + input.activeThreads, + input.renderedSettledThreads, + input.visibleSnoozedThreads, + orderedPinnedThreads, + pauseLayoutMotion, + pinnedReorderInFlightRef, + retainLayoutAnchor, + setTransaction, + ], + ); + const resolveDropTarget = useCallback( + ( + current: SidebarThreadDragTransaction, + over: DragMoveEvent["over"], + ): SidebarThreadDropTarget | null => { + if (over === null) return null; + const overId = parseSidebarDndId(over.id); + if (overId === null) return null; + const destination = overId.section; + if (!canDropThreadInSection(current.sourceThread, current.sourceSection, destination)) { + return null; + } + let targetThreadKey = overId.kind === "section" ? null : overId.threadKey; + let targetEdge: "before" | "after" | null = null; + const pointerY = pointerCoordinatesRef.current?.y ?? over.rect.top + over.rect.height / 2; + if (targetThreadKey !== null) { + if (destination === "pinned" && !input.reorderablePinnedKeys.has(targetThreadKey)) { + return null; + } + targetEdge = pointerY < over.rect.top + over.rect.height / 2 ? "before" : "after"; + } else if (destination === "pinned" && orderedPinnedThreads.length > 0) { + const before = pointerY < over.rect.top + over.rect.height / 2; + const target = before ? orderedPinnedThreads[0] : orderedPinnedThreads.at(-1); + if (target !== undefined) { + targetThreadKey = sidebarThreadKey(target); + targetEdge = before ? "before" : "after"; + } + } + return { targetSection: destination, targetThreadKey, targetEdge }; + }, + [canDropThreadInSection, input.reorderablePinnedKeys, orderedPinnedThreads], + ); + const capturePointerFromDragEvent = useCallback((event: DragMoveEvent) => { + const activationCoordinates = getEventCoordinates(event.activatorEvent); + if (activationCoordinates === null) return pointerCoordinatesRef.current; + const pointer = { + x: activationCoordinates.x + event.delta.x, + y: activationCoordinates.y + event.delta.y, + }; + pointerCoordinatesRef.current = pointer; + return pointer; + }, []); + const updateDragTarget = useCallback( + (over: DragMoveEvent["over"]) => { + const current = transactionRef.current; + if (current === null || current.phase !== "dragging") return; + const target = resolveDropTarget(current, over); + if (target === null) { + if (current.targetSection === null) return; + setTransaction({ + ...current, + targetSection: null, + targetThreadKey: null, + targetEdge: null, + }); + return; + } + if ( + current.targetSection === target.targetSection && + current.targetThreadKey === target.targetThreadKey && + current.targetEdge === target.targetEdge + ) { + return; + } + setTransaction({ ...current, ...target }); + }, + [resolveDropTarget, setTransaction], + ); + const handleDragMove = useCallback( + (event: DragMoveEvent) => { + capturePointerFromDragEvent(event); + updateDragTarget(event.over); + }, + [capturePointerFromDragEvent, updateDragTarget], + ); + const handleDragOver = useCallback( + (event: DragOverEvent) => { + capturePointerFromDragEvent(event); + updateDragTarget(event.over); + }, + [capturePointerFromDragEvent, updateDragTarget], + ); + const handleDragCancel = useCallback( + (_event: DragCancelEvent) => finishTransaction(), + [finishTransaction], + ); + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const current = transactionRef.current; + const releasePoint = capturePointerFromDragEvent(event); + const target = + current !== null && current.phase === "dragging" + ? resolveDropTarget(current, event.over) + : null; + pointerCoordinatesRef.current = null; + if (current === null || current.phase !== "dragging" || target === null) { + finishTransaction(); + return; + } + const finalized = { ...current, ...target }; + const action = resolveSidebarDndAction({ + source: finalized.sourceSection, + destination: finalized.targetSection, + }); + if (action === "noop") { + finishTransaction(); + return; + } + if (action === "reorder-pinned") { + handlePinnedReorder( + finalized.sourceThreadKey, + finalized.targetThreadKey, + finalized.targetEdge, + ); + finishTransaction(); + return; + } + if (action === "snooze") { + openSnoozeDropMenu( + finalized, + releasePoint ?? { + x: finalized.sourceRect.left + finalized.sourceRect.width / 2, + y: finalized.sourceRect.top + finalized.sourceRect.height / 2, + }, + ); + return; + } + const pinnedPlan = action === "pin" ? planPinnedInsertion(finalized) : null; + if (action === "pin" && pinnedPlan === null) { + finishTransaction(); + return; + } + commitLifecycleDrop(finalized, finalized.targetSection, action, pinnedPlan); + }, + [ + capturePointerFromDragEvent, + commitLifecycleDrop, + finishTransaction, + handlePinnedReorder, + openSnoozeDropMenu, + planPinnedInsertion, + resolveDropTarget, + ], + ); + + useLayoutEffect(() => { + if ( + transaction === null || + transaction.phase !== "reconciling" || + transaction.receiptSequencesByEnvironment === null + ) { + return; + } + for (const [environmentId, receiptSequence] of transaction.receiptSequencesByEnvironment) { + const snapshot = appAtomRegistry.get(environmentSnapshotAtom(environmentId)); + if (snapshot === null || snapshot.snapshotSequence < receiptSequence) return; + } + finishTransaction({ excludeSource: true }); + }, [finishTransaction, input.threads, transaction]); + useLayoutEffect(() => { + if ( + transaction === null || + (transaction.phase !== "dragging" && transaction.phase !== "awaiting-snooze-choice") + ) { + return; + } + if (input.isSearchingThreads || !sourceStillMatchesDragStart(transaction)) { + finishTransaction(); + } + }, [ + finishTransaction, + input.isSearchingThreads, + input.scopeKey, + input.threads, + sourceStillMatchesDragStart, + transaction, + ]); + + const sections = useMemo( + () => + buildSidebarDndBoardSections({ + pinnedThreads: orderedPinnedThreads, + regularThreads: input.activeThreads, + snoozedThreads: input.visibleSnoozedThreads, + settledThreads: input.renderedSettledThreads, + transaction, + }), + [ + input.activeThreads, + input.renderedSettledThreads, + input.visibleSnoozedThreads, + orderedPinnedThreads, + transaction, + ], + ); + const dropIndicatorByThreadKey = useMemo(() => { + const indicators = new Map(); + if ( + transaction === null || + transaction.phase === "reconciling" || + transaction.targetThreadKey === null || + transaction.targetEdge === null + ) { + return indicators; + } + indicators.set(transaction.targetThreadKey, transaction.targetEdge); + return indicators; + }, [transaction]); + const isTemporarySectionRailVisible = useCallback( + (section: SidebarDndSection) => { + if (transaction === null || transaction.phase === "reconciling") return false; + const sectionIsEmpty = + sections[section].length === 0 && + (section !== "snoozed" || input.snoozedThreads.length === 0) && + (section !== "settled" || input.settledThreads.length === 0); + return ( + sectionIsEmpty && + canDropThreadInSection(transaction.sourceThread, transaction.sourceSection, section) + ); + }, + [ + canDropThreadInSection, + input.settledThreads.length, + input.snoozedThreads.length, + sections, + transaction, + ], + ); + const dragPreviewVariant = + transaction?.phase === "dragging" + ? resolveSidebarDndPreviewVariant({ + source: transaction.sourceSection, + destination: transaction.targetSection, + }) + : null; + + return { + transaction, + viewportRef, + viewportOverlayRef, + boardDnd: { + contextProps: { + sensors, + collisionDetection, + onDragStart: handleDragStart, + onDragMove: handleDragMove, + onDragOver: handleDragOver, + onDragCancel: handleDragCancel, + onDragEnd: handleDragEnd, + }, + layout, + transaction, + sections, + reorderablePinnedKeys: input.reorderablePinnedKeys, + pinnedSortingStrategy, + optimisticPinnedOrderActive: optimisticPinnedOrder !== null, + dropIndicatorByThreadKey, + dragPreviewVariant, + canDragThread, + canDropThreadInSection, + isTemporarySectionRailVisible, + }, + }; +} From 5c6f9040a0a66f653059914df411af0e1bd7b17c Mon Sep 17 00:00:00 2001 From: Taras Date: Fri, 21 Aug 2026 20:22:58 +0300 Subject: [PATCH 03/26] refactor: simplify sidebar drag and drop --- apps/server/src/orchestration/decider.ts | 332 +++++--------- apps/web/src/components/Sidebar.dnd.board.ts | 132 +----- apps/web/src/components/Sidebar.dnd.logic.ts | 176 +------- .../components/sidebar/SidebarThreadBoard.tsx | 375 ++++++---------- .../components/sidebar/SidebarThreadDnd.tsx | 101 +---- apps/web/src/hooks/useSidebarDndLayout.ts | 411 ++++-------------- apps/web/src/hooks/useSidebarPinnedDnd.ts | 36 +- apps/web/src/hooks/useSidebarThreadDnd.ts | 304 +++++-------- 8 files changed, 504 insertions(+), 1363 deletions(-) diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index c116a4fd610b..030908375b53 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -1,8 +1,11 @@ import { EventId, + type CommandId, type OrchestrationCommand, type OrchestrationEvent, type OrchestrationReadModel, + type OrchestrationThread, + type ThreadId, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Crypto from "effect/Crypto"; @@ -178,6 +181,59 @@ type DecideOrchestrationCommandResult = | PlannedOrchestrationEvent | ReadonlyArray; +type ThreadCategoryCleanup = "pinned" | "settled" | "snoozed"; + +const planThreadCategoryCleanup = Effect.fn("planThreadCategoryCleanup")(function* (input: { + thread: Pick; + threadId: ThreadId; + commandId: CommandId; + occurredAt: string; + clear: readonly ThreadCategoryCleanup[]; +}): Effect.fn.Return< + ReadonlyArray, + PlatformError.PlatformError, + Crypto.Crypto +> { + const events: PlannedOrchestrationEvent[] = []; + const eventBase = () => + withEventBase({ + aggregateKind: "thread", + aggregateId: input.threadId, + occurredAt: input.occurredAt, + commandId: input.commandId, + }); + if ( + input.clear.includes("pinned") && + input.thread.pinnedAt !== null && + input.thread.pinnedAt !== undefined + ) { + events.push({ + ...(yield* eventBase()), + type: "thread.unpinned", + payload: { threadId: input.threadId, updatedAt: input.occurredAt }, + }); + } + if (input.clear.includes("settled") && input.thread.settledOverride !== "active") { + events.push({ + ...(yield* eventBase()), + type: "thread.unsettled", + payload: { threadId: input.threadId, reason: "user", updatedAt: input.occurredAt }, + }); + } + if ( + input.clear.includes("snoozed") && + input.thread.snoozedUntil !== null && + input.thread.snoozedUntil !== undefined + ) { + events.push({ + ...(yield* eventBase()), + type: "thread.unsnoozed", + payload: { threadId: input.threadId, reason: "user", updatedAt: input.occurredAt }, + }); + } + return events; +}); + const decideCommandSequence = Effect.fn("decideCommandSequence")(function* ({ commands, readModel, @@ -511,38 +567,13 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; // Settling is "I'm done with this": clear states that would keep the // row pinned or snoozed instead of showing the new settled state. - const companionEvents: Array> = []; - if (thread.pinnedAt != null) { - companionEvents.push({ - ...(yield* withEventBase({ - aggregateKind: "thread", - aggregateId: command.threadId, - occurredAt, - commandId: command.commandId, - })), - type: "thread.unpinned" as const, - payload: { - threadId: command.threadId, - updatedAt: occurredAt, - }, - }); - } - if (thread.snoozedUntil != null) { - companionEvents.push({ - ...(yield* withEventBase({ - aggregateKind: "thread", - aggregateId: command.threadId, - occurredAt, - commandId: command.commandId, - })), - type: "thread.unsnoozed", - payload: { - threadId: command.threadId, - reason: "user", - updatedAt: occurredAt, - }, - }); - } + const companionEvents = yield* planThreadCategoryCleanup({ + thread, + threadId: command.threadId, + commandId: command.commandId, + occurredAt, + clear: ["pinned", "snoozed"], + }); return companionEvents.length > 0 ? [settledEvent, ...companionEvents] : settledEvent; } @@ -571,38 +602,13 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" updatedAt: alreadyActive ? thread.updatedAt : occurredAt, }, }; - const cleanupEvents: Array> = []; - if (thread.pinnedAt != null) { - cleanupEvents.push({ - ...(yield* withEventBase({ - aggregateKind: "thread", - aggregateId: command.threadId, - occurredAt, - commandId: command.commandId, - })), - type: "thread.unpinned", - payload: { - threadId: command.threadId, - updatedAt: occurredAt, - }, - }); - } - if (thread.snoozedUntil != null) { - cleanupEvents.push({ - ...(yield* withEventBase({ - aggregateKind: "thread", - aggregateId: command.threadId, - occurredAt, - commandId: command.commandId, - })), - type: "thread.unsnoozed", - payload: { - threadId: command.threadId, - reason: "user", - updatedAt: occurredAt, - }, - }); - } + const cleanupEvents = yield* planThreadCategoryCleanup({ + thread, + threadId: command.threadId, + commandId: command.commandId, + occurredAt, + clear: ["pinned", "snoozed"], + }); return cleanupEvents.length > 0 ? [unsettledEvent, ...cleanupEvents] : unsettledEvent; } @@ -620,36 +626,30 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" // structurally just a string): NaN fails every comparison, and an // unparseable snoozedUntil must never persist. if (!(Date.parse(command.snoozedUntil) > Date.parse(occurredAt))) { - return yield* Effect.fail( - new OrchestrationCommandInvariantError({ - commandType: command.type, - detail: `thread ${command.threadId} snooze wake time ${command.snoozedUntil} is not in the future`, - }), - ); + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} snooze wake time ${command.snoozedUntil} is not in the future`, + }); } // Blocked-on-you work must not be snoozed away: a pending approval or // user-input request is the agent waiting on the user, and hiding it // defeats the request. (A running session IS snoozable — snooze only // affects visibility, never the agent.) if (hasOpenBlockingRequest(thread)) { - return yield* Effect.fail( - new OrchestrationCommandInvariantError({ - commandType: command.type, - detail: `thread ${command.threadId} has a pending approval or user-input request and cannot be snoozed`, - }), - ); + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} has a pending approval or user-input request and cannot be snoozed`, + }); } // A queued turn start — a user message no turn has adopted yet — is // invisible pending work: no session, no pending flags. Snoozing in // that window would hide a just-requested turn exactly the way settle // would. if (threadHasQueuedTurnStart(thread, occurredAt)) { - return yield* Effect.fail( - new OrchestrationCommandInvariantError({ - commandType: command.type, - detail: `thread ${command.threadId} has a queued turn start and cannot be snoozed`, - }), - ); + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} has a queued turn start and cannot be snoozed`, + }); } // Re-snoozing an already-snoozed thread to the SAME wake time is a // duplicate (double-click, raced clients): re-emit with the original @@ -676,38 +676,13 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; // Snooze is a destination, not a deferred restoration marker. Clearing // the other exclusive categories keeps Wake's meaning unambiguous. - const cleanupEvents: Array> = []; - if (thread.pinnedAt != null) { - cleanupEvents.push({ - ...(yield* withEventBase({ - aggregateKind: "thread", - aggregateId: command.threadId, - occurredAt, - commandId: command.commandId, - })), - type: "thread.unpinned", - payload: { - threadId: command.threadId, - updatedAt: occurredAt, - }, - }); - } - if (thread.settledOverride !== "active") { - cleanupEvents.push({ - ...(yield* withEventBase({ - aggregateKind: "thread", - aggregateId: command.threadId, - occurredAt, - commandId: command.commandId, - })), - type: "thread.unsettled", - payload: { - threadId: command.threadId, - reason: "user", - updatedAt: occurredAt, - }, - }); - } + const cleanupEvents = yield* planThreadCategoryCleanup({ + thread, + threadId: command.threadId, + commandId: command.commandId, + occurredAt, + clear: ["pinned", "settled"], + }); return cleanupEvents.length > 0 ? [snoozedEvent, ...cleanupEvents] : snoozedEvent; } @@ -735,38 +710,13 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; // Activity-driven wakes are emitted elsewhere. This user command // reaches Regular, including its explicit active override. - const cleanupEvents: Array> = []; - if (thread.pinnedAt != null) { - cleanupEvents.push({ - ...(yield* withEventBase({ - aggregateKind: "thread", - aggregateId: command.threadId, - occurredAt, - commandId: command.commandId, - })), - type: "thread.unpinned", - payload: { - threadId: command.threadId, - updatedAt: occurredAt, - }, - }); - } - if (thread.settledOverride !== "active") { - cleanupEvents.push({ - ...(yield* withEventBase({ - aggregateKind: "thread", - aggregateId: command.threadId, - occurredAt, - commandId: command.commandId, - })), - type: "thread.unsettled", - payload: { - threadId: command.threadId, - reason: "user", - updatedAt: occurredAt, - }, - }); - } + const cleanupEvents = yield* planThreadCategoryCleanup({ + thread, + threadId: command.threadId, + commandId: command.commandId, + occurredAt, + clear: ["pinned", "settled"], + }); return cleanupEvents.length > 0 ? [unsnoozedEvent, ...cleanupEvents] : unsnoozedEvent; } @@ -798,39 +748,13 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }, }; // Pinning clears settled and snoozed state. - const cleanupEvents: Array> = []; - if (thread.settledOverride !== "active") { - cleanupEvents.push({ - ...(yield* withEventBase({ - aggregateKind: "thread", - aggregateId: command.threadId, - occurredAt, - commandId: command.commandId, - })), - type: "thread.unsettled", - payload: { - threadId: command.threadId, - reason: "user", - updatedAt: occurredAt, - }, - }); - } - if (thread.snoozedUntil != null) { - cleanupEvents.push({ - ...(yield* withEventBase({ - aggregateKind: "thread", - aggregateId: command.threadId, - occurredAt, - commandId: command.commandId, - })), - type: "thread.unsnoozed", - payload: { - threadId: command.threadId, - reason: "user", - updatedAt: occurredAt, - }, - }); - } + const cleanupEvents = yield* planThreadCategoryCleanup({ + thread, + threadId: command.threadId, + commandId: command.commandId, + occurredAt, + clear: ["settled", "snoozed"], + }); return cleanupEvents.length > 0 ? [pinnedEvent, ...cleanupEvents] : pinnedEvent; } @@ -858,39 +782,13 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" // Unpin is an explicit move to Regular. It spends a snooze and stamps // the active override so automatic settlement cannot immediately move // the thread away again. - const cleanupEvents: Array> = []; - if (thread.snoozedUntil != null) { - cleanupEvents.push({ - ...(yield* withEventBase({ - aggregateKind: "thread", - aggregateId: command.threadId, - occurredAt, - commandId: command.commandId, - })), - type: "thread.unsnoozed", - payload: { - threadId: command.threadId, - reason: "user", - updatedAt: occurredAt, - }, - }); - } - if (thread.settledOverride !== "active") { - cleanupEvents.push({ - ...(yield* withEventBase({ - aggregateKind: "thread", - aggregateId: command.threadId, - occurredAt, - commandId: command.commandId, - })), - type: "thread.unsettled", - payload: { - threadId: command.threadId, - reason: "user", - updatedAt: occurredAt, - }, - }); - } + const cleanupEvents = yield* planThreadCategoryCleanup({ + thread, + threadId: command.threadId, + commandId: command.commandId, + occurredAt, + clear: ["settled", "snoozed"], + }); return cleanupEvents.length > 0 ? [unpinnedEvent, ...cleanupEvents] : unpinnedEvent; } @@ -904,12 +802,10 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" // (rather than silently pinning) keeps a raced reorder-after-unpin // from resurrecting a pin the user just cleared. if (thread.pinnedAt == null) { - return yield* Effect.fail( - new OrchestrationCommandInvariantError({ - commandType: command.type, - detail: `thread ${command.threadId} is not pinned and cannot be reordered`, - }), - ); + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} is not pinned and cannot be reordered`, + }); } // Idempotent by re-emission (see thread.settle): a duplicate drop on // the same slot keeps the existing updatedAt so it projects as a no-op. diff --git a/apps/web/src/components/Sidebar.dnd.board.ts b/apps/web/src/components/Sidebar.dnd.board.ts index 94864c415597..3748c1b98743 100644 --- a/apps/web/src/components/Sidebar.dnd.board.ts +++ b/apps/web/src/components/Sidebar.dnd.board.ts @@ -1,28 +1,16 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; -import { sidebarThreadKey, type SidebarDndSection } from "./Sidebar.dnd.logic"; -import type { SidebarThreadDragTransaction } from "./Sidebar.dnd.logic"; import { - firstValidTimestampMs, - orderItemsByPreferredIds, - sortSettledThreadsForSidebar, - sortThreadsForSidebar, -} from "./Sidebar.logic"; + sidebarThreadKey, + SIDEBAR_DND_SECTIONS, + type SidebarDndSection, + type SidebarThreadDragTransaction, +} from "./Sidebar.dnd.logic"; export type SidebarThreadBoardSections = Readonly< Record >; -function insertThreadAt( - threads: readonly EnvironmentThreadShell[], - thread: EnvironmentThreadShell, - index: number, -): EnvironmentThreadShell[] { - const next = [...threads]; - next.splice(Math.min(Math.max(0, index), next.length), 0, thread); - return next; -} - export function buildSidebarDndBoardSections(input: { pinnedThreads: readonly EnvironmentThreadShell[]; regularThreads: readonly EnvironmentThreadShell[]; @@ -30,103 +18,21 @@ export function buildSidebarDndBoardSections(input: { settledThreads: readonly EnvironmentThreadShell[]; transaction: SidebarThreadDragTransaction | null; }): SidebarThreadBoardSections { - let pinned = [...input.pinnedThreads]; - let regular = [...input.regularThreads]; - let snoozed = [...input.snoozedThreads]; - let settled = [...input.settledThreads]; + const sections: Record = { + pinned: [...input.pinnedThreads], + regular: [...input.regularThreads], + snoozed: [...input.snoozedThreads], + settled: [...input.settledThreads], + }; const { transaction } = input; - if (transaction === null) return { pinned, regular, snoozed, settled }; - - const withoutSource = (items: readonly EnvironmentThreadShell[]) => - items.filter((thread) => sidebarThreadKey(thread) !== transaction.sourceThreadKey); - pinned = withoutSource(pinned); - regular = withoutSource(regular); - snoozed = withoutSource(snoozed); - settled = withoutSource(settled); - - if (transaction.phase !== "reconciling" || transaction.destinationSection === null) { - switch (transaction.sourceSection) { - case "pinned": - pinned = insertThreadAt(pinned, transaction.sourceThread, transaction.sourceIndex); - break; - case "regular": - regular = insertThreadAt(regular, transaction.sourceThread, transaction.sourceIndex); - break; - case "snoozed": - snoozed = insertThreadAt(snoozed, transaction.sourceThread, transaction.sourceIndex); - break; - case "settled": - settled = insertThreadAt(settled, transaction.sourceThread, transaction.sourceIndex); - break; - } - return { pinned, regular, snoozed, settled }; - } + if (transaction === null) return sections; - const now = new Date().toISOString(); - switch (transaction.destinationSection) { - case "pinned": - pinned = orderItemsByPreferredIds({ - items: [ - ...pinned, - { - ...transaction.sourceThread, - pinnedAt: now, - settledOverride: "active" as const, - settledAt: null, - snoozedAt: null, - snoozedUntil: null, - }, - ], - preferredIds: transaction.pinnedOrder ?? [], - getId: sidebarThreadKey, - }); - break; - case "regular": - regular = sortThreadsForSidebar([ - ...regular, - { - ...transaction.sourceThread, - pinnedAt: null, - pinOrderKey: null, - settledOverride: "active" as const, - settledAt: null, - snoozedAt: null, - snoozedUntil: null, - }, - ]); - break; - case "snoozed": - snoozed = [ - ...snoozed, - { - ...transaction.sourceThread, - pinnedAt: null, - pinOrderKey: null, - settledOverride: "active" as const, - settledAt: null, - snoozedAt: now, - snoozedUntil: transaction.snoozedUntil, - }, - ].toSorted( - (left, right) => - firstValidTimestampMs(left.snoozedUntil ?? null) - - firstValidTimestampMs(right.snoozedUntil ?? null), - ); - break; - case "settled": - settled = sortSettledThreadsForSidebar([ - ...settled, - { - ...transaction.sourceThread, - pinnedAt: null, - pinOrderKey: null, - settledOverride: "settled" as const, - settledAt: now, - snoozedAt: null, - snoozedUntil: null, - }, - ]); - break; + for (const section of SIDEBAR_DND_SECTIONS) { + sections[section] = sections[section].filter( + (thread) => sidebarThreadKey(thread) !== transaction.sourceThreadKey, + ); } - return { pinned, regular, snoozed, settled }; + const source = sections[transaction.sourceSection]; + source.splice(Math.min(transaction.sourceIndex, source.length), 0, transaction.sourceThread); + return sections; } diff --git a/apps/web/src/components/Sidebar.dnd.logic.ts b/apps/web/src/components/Sidebar.dnd.logic.ts index 18ddc2057b85..9891414bc9f2 100644 --- a/apps/web/src/components/Sidebar.dnd.logic.ts +++ b/apps/web/src/components/Sidebar.dnd.logic.ts @@ -22,18 +22,6 @@ export type SidebarDndAction = export type SidebarDndPreviewVariant = "card" | "slim"; -export interface SidebarDndPoint { - readonly x: number; - readonly y: number; -} - -export interface SidebarDndRect { - readonly left: number; - readonly top: number; - readonly width: number; - readonly height: number; -} - export interface SidebarDndPointerAnchor { readonly x: number; readonly y: number; @@ -45,20 +33,21 @@ export type SidebarThreadDragPhase = | "committing" | "reconciling"; +export interface SidebarThreadDropTarget { + readonly section: SidebarDndSection; + readonly threadKey: string | null; + readonly edge: "before" | "after" | null; +} + export interface SidebarThreadDragTransaction { readonly phase: SidebarThreadDragPhase; readonly sourceThread: EnvironmentThreadShell; readonly sourceThreadKey: string; readonly sourceSection: SidebarDndSection; readonly sourceIndex: number; - readonly sourceRect: SidebarDndRect; + readonly sourceRect: { readonly width: number; readonly height: number }; readonly pointerAnchor: SidebarDndPointerAnchor; - readonly targetSection: SidebarDndSection | null; - readonly targetThreadKey: string | null; - readonly targetEdge: "before" | "after" | null; - readonly destinationSection: SidebarDndSection | null; - readonly pinnedOrder: readonly string[] | null; - readonly snoozedUntil: string | null; + readonly target: SidebarThreadDropTarget | null; readonly receiptSequencesByEnvironment: ReadonlyMap< EnvironmentThreadShell["environmentId"], number @@ -66,26 +55,7 @@ export interface SidebarThreadDragTransaction { readonly viewportRailTopBySection: ReadonlyMap | null; } -export interface SidebarDndDraggableId { - readonly kind: "draggable"; - readonly section: SidebarDndSection; - readonly threadKey: string; -} - -export interface SidebarDndRowId { - readonly kind: "row"; - readonly section: SidebarDndSection; - readonly threadKey: string; -} - -export interface SidebarDndSectionId { - readonly kind: "section"; - readonly section: SidebarDndSection; -} - -export type SidebarDndId = SidebarDndDraggableId | SidebarDndRowId | SidebarDndSectionId; - -const DND_ID_PREFIX = "sidebar-thread-dnd"; +const DND_SECTION_ID_PREFIX = "sidebar-thread-section:"; export function sidebarThreadKey( thread: Pick, @@ -93,68 +63,19 @@ export function sidebarThreadKey( return scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); } -export function createSidebarDndDraggableId(input: { - section: SidebarDndSection; - threadKey: string; -}): string { - return `${DND_ID_PREFIX}:draggable:${input.section}:${encodeURIComponent(input.threadKey)}`; -} - -export function createSidebarDndRowId(input: { - section: SidebarDndSection; - threadKey: string; -}): string { - return `${DND_ID_PREFIX}:row:${input.section}:${encodeURIComponent(input.threadKey)}`; -} - export function createSidebarDndSectionId(input: { section: SidebarDndSection }): string { - return `${DND_ID_PREFIX}:section:${input.section}`; + return `${DND_SECTION_ID_PREFIX}${input.section}`; } -function parseSection(value: string): SidebarDndSection | null { - switch (value) { +export function parseSidebarDndSectionId(value: unknown): SidebarDndSection | null { + if (typeof value !== "string" || !value.startsWith(DND_SECTION_ID_PREFIX)) return null; + const section = value.slice(DND_SECTION_ID_PREFIX.length); + switch (section) { case "pinned": case "regular": case "snoozed": case "settled": - return value; - default: - return null; - } -} - -function parseThreadKey(value: string): string | null { - if (value.length === 0) return null; - try { - const threadKey = decodeURIComponent(value); - return threadKey.length === 0 || encodeURIComponent(threadKey) !== value ? null : threadKey; - } catch { - return null; - } -} - -/** Safely parses only IDs produced by the sidebar DnD helpers. */ -export function parseSidebarDndId(value: unknown): SidebarDndId | null { - if (typeof value !== "string") return null; - const parts = value.split(":"); - if (parts[0] !== DND_ID_PREFIX) return null; - - switch (parts[1]) { - case "draggable": - case "row": { - if (parts.length !== 4) return null; - const section = parseSection(parts[2] ?? ""); - const threadKey = parseThreadKey(parts[3] ?? ""); - if (section === null || threadKey === null) return null; - return parts[1] === "draggable" - ? { kind: "draggable", section, threadKey } - : { kind: "row", section, threadKey }; - } - case "section": { - if (parts.length !== 3) return null; - const section = parseSection(parts[2] ?? ""); - return section === null ? null : { kind: "section", section }; - } + return section; default: return null; } @@ -167,18 +88,7 @@ export function resolveSidebarDndAction(input: { }): SidebarDndAction { const { destination, source } = input; if (source === destination) { - switch (source) { - case "pinned": - return "reorder-pinned"; - case "snoozed": - case "regular": - case "settled": - return "noop"; - default: { - const _exhaustive: never = source; - return _exhaustive; - } - } + return source === "pinned" ? "reorder-pinned" : "noop"; } switch (destination) { @@ -189,20 +99,7 @@ export function resolveSidebarDndAction(input: { case "settled": return "settle"; case "regular": - switch (source) { - case "pinned": - return "unpin"; - case "snoozed": - return "unsnooze"; - case "settled": - return "unsettle"; - case "regular": - return "noop"; - default: { - const _exhaustive: never = source; - return _exhaustive; - } - } + return source === "pinned" ? "unpin" : source === "snoozed" ? "unsnooze" : "unsettle"; default: { const _exhaustive: never = destination; return _exhaustive; @@ -216,42 +113,5 @@ export function resolveSidebarDndPreviewVariant(input: { destination: SidebarDndSection | null; }): SidebarDndPreviewVariant { const section = input.destination ?? input.source; - switch (section) { - case "pinned": - case "regular": - return "card"; - case "snoozed": - case "settled": - return "slim"; - default: { - const _exhaustive: never = section; - return _exhaustive; - } - } -} - -/** - * The cursor's normalized position in the source row. A zero-sized source - * falls back to its center, so a stale measurement cannot jump the overlay. - */ -export function captureSidebarDndPointerAnchor(input: { - pointer: SidebarDndPoint; - sourceRect: SidebarDndRect; -}): SidebarDndPointerAnchor { - return { - x: normalizePointerAxis(input.pointer.x, input.sourceRect.left, input.sourceRect.width), - y: normalizePointerAxis(input.pointer.y, input.sourceRect.top, input.sourceRect.height), - }; -} - -function normalizePointerAxis(pointer: number, start: number, length: number): number { - if ( - !Number.isFinite(pointer) || - !Number.isFinite(start) || - !Number.isFinite(length) || - length <= 0 - ) { - return 0.5; - } - return Math.min(1, Math.max(0, (pointer - start) / length)); + return section === "snoozed" || section === "settled" ? "slim" : "card"; } diff --git a/apps/web/src/components/sidebar/SidebarThreadBoard.tsx b/apps/web/src/components/sidebar/SidebarThreadBoard.tsx index e0f20ad5fdaa..82e896650ee8 100644 --- a/apps/web/src/components/sidebar/SidebarThreadBoard.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadBoard.tsx @@ -10,31 +10,21 @@ import { cn } from "~/lib/utils"; import type { SidebarDndLayout } from "../../hooks/useSidebarDndLayout"; import type { SidebarThreadBoardSections } from "../Sidebar.dnd.board"; import { - createSidebarDndDraggableId, sidebarThreadKey, - SIDEBAR_DND_SECTIONS, type SidebarDndPreviewVariant, type SidebarDndSection, type SidebarThreadDragTransaction, } from "../Sidebar.dnd.logic"; import { - DraggableSidebarThreadRow, + SidebarThreadDndRow, SidebarThreadDragOverlayContent, SidebarThreadSectionDropZone, - SidebarThreadViewportDropRail, - SortableSidebarThreadRow, type SidebarThreadDndRowBag, } from "./SidebarThreadDnd"; type SidebarThreadDndContextProps = Pick< DndContextProps, - | "sensors" - | "collisionDetection" - | "onDragStart" - | "onDragMove" - | "onDragOver" - | "onDragCancel" - | "onDragEnd" + "sensors" | "collisionDetection" | "onDragStart" | "onDragMove" | "onDragCancel" | "onDragEnd" >; export interface SidebarThreadRenderState { @@ -52,7 +42,10 @@ export interface SidebarThreadBoardDnd { readonly reorderablePinnedKeys: ReadonlySet; readonly pinnedSortingStrategy: SortingStrategy; readonly optimisticPinnedOrderActive: boolean; - readonly dropIndicatorByThreadKey: ReadonlyMap; + readonly dropIndicator: { + readonly threadKey: string; + readonly edge: "before" | "after"; + } | null; readonly dragPreviewVariant: SidebarDndPreviewVariant | null; readonly canDragThread: (thread: EnvironmentThreadShell, source: SidebarDndSection) => boolean; readonly canDropThreadInSection: ( @@ -63,6 +56,53 @@ export interface SidebarThreadBoardDnd { readonly isTemporarySectionRailVisible: (section: SidebarDndSection) => boolean; } +function SidebarThreadShelfHeader(props: { + section: "snoozed" | "settled"; + count: number; + expanded: boolean; + isDropOver: boolean; + onToggle: () => void; +}) { + if (props.count === 0) return null; + const snoozed = props.section === "snoozed"; + const label = snoozed ? "Snoozed" : "Settled"; + const color = snoozed ? "text-blue-600 dark:text-blue-400" : "text-muted-foreground/50"; + const divider = snoozed ? "bg-blue-500/20 dark:bg-blue-400/15" : "bg-sidebar-border/60"; + return ( +
    + +
    + ); +} + export function SidebarThreadBoard(props: { dnd: SidebarThreadBoardDnd; drafts: ReactNode; @@ -109,34 +149,23 @@ export function SidebarThreadBoard(props: { const renderVisualRow = (rowDnd: SidebarThreadDndRowBag) => props.renderThread(thread, section, { dnd: rowDnd, - dimmed: - dnd.transaction?.sourceThreadKey === threadKey && dnd.transaction.phase !== "reconciling", + dimmed: dnd.transaction?.sourceThreadKey === threadKey, inert: dnd.transaction?.sourceThreadKey === threadKey && dnd.transaction.phase !== "dragging", - dropIndicator: dnd.dropIndicatorByThreadKey.get(threadKey) ?? null, + dropIndicator: dnd.dropIndicator?.threadKey === threadKey ? dnd.dropIndicator.edge : null, }); const rowKey = `${threadKey}:${rowVariant}`; - return section === "pinned" && dnd.reorderablePinnedKeys.has(threadKey) ? ( - - {renderVisualRow} - - ) : ( - {renderVisualRow} - + ); }; const rail = (section: SidebarDndSection, label: string, isOver: boolean) => ( @@ -151,19 +180,12 @@ export function SidebarThreadBoard(props: {
    ); - const visibleRailBySection = new Map( - SIDEBAR_DND_SECTIONS.map((section) => [section, dnd.isTemporarySectionRailVisible(section)]), - ); const viewportRailTopBySection = dnd.transaction?.viewportRailTopBySection; const viewportOverlayHost = dnd.layout.viewportOverlayRef.current; - const viewportRailSections = new Set(); - if (viewportRailTopBySection !== null && viewportRailTopBySection !== undefined) { - for (const section of viewportRailTopBySection.keys()) { - if (visibleRailBySection.get(section) === true && viewportOverlayHost !== null) { - viewportRailSections.add(section); - } - } - } + const isViewportRail = (section: SidebarDndSection) => + viewportOverlayHost !== null && + dnd.isTemporarySectionRailVisible(section) && + viewportRailTopBySection?.has(section) === true; const renderViewportRail = ( section: SidebarDndSection, label: string, @@ -171,22 +193,38 @@ export function SidebarThreadBoard(props: { setNodeRef: (node: HTMLElement | null) => void, ) => { const top = viewportRailTopBySection?.get(section); - if (top === undefined || viewportOverlayHost === null || !viewportRailSections.has(section)) { + if (top === undefined || viewportOverlayHost === null || !isViewportRail(section)) { return null; } return createPortal( - +
    {rail(section, label, isOver)} - , +
    , viewportOverlayHost, `sidebar-${section}-viewport-drop-rail`, ); }; + const renderSection = ( + section: SidebarDndSection, + label: string, + content: (isOver: boolean) => ReactNode, + ) => ( + + {({ setNodeRef, isOver }) => { + const showRail = dnd.isTemporarySectionRailVisible(section); + const viewportRail = showRail + ? renderViewportRail(section, label, isOver, setNodeRef) + : null; + if (viewportRail !== null) return viewportRail; + return ( +
  • + {content(isOver)} + {showRail ? rail(section, label, isOver) : null} +
  • + ); + }} + + ); return (
      {props.drafts} - - {({ setNodeRef, isOver }) => { - const showRail = visibleRailBySection.get("pinned") === true; - const viewportRail = showRail - ? renderViewportRail("pinned", "Pinned", isOver, setNodeRef) - : null; - if (viewportRail !== null) return viewportRail; - return ( -
    • - dnd.reorderablePinnedKeys.has(threadKey)) - .map((threadKey) => - createSidebarDndDraggableId({ section: "pinned", threadKey }), - )} - strategy={dnd.pinnedSortingStrategy} - > -
        - {dnd.sections.pinned.map((thread) => renderThread(thread, "pinned"))} -
      -
      - {showRail ? rail("pinned", "Pinned", isOver) : null} -
    • - ); - }} -
      - {(dnd.sections.pinned.length > 0 || visibleRailBySection.get("pinned") === true) && - !viewportRailSections.has("pinned") ? ( + {renderSection("pinned", "Pinned", () => ( + dnd.reorderablePinnedKeys.has(threadKey))} + strategy={dnd.pinnedSortingStrategy} + > +
        + {dnd.sections.pinned.map((thread) => renderThread(thread, "pinned"))} +
      +
      + ))} + {(dnd.sections.pinned.length > 0 || dnd.isTemporarySectionRailVisible("pinned")) && + !isViewportRail("pinned") ? (
    • ) : null} - - {({ setNodeRef, isOver }) => { - const showRail = visibleRailBySection.get("regular") === true; - const viewportRail = showRail - ? renderViewportRail("regular", "Regular", isOver, setNodeRef) - : null; - if (viewportRail !== null) return viewportRail; - return ( -
    • -
        - {dnd.sections.regular.map((thread) => renderThread(thread, "regular"))} -
      - {showRail ? rail("regular", "Regular", isOver) : null} -
    • - ); - }} - - - {({ setNodeRef, isOver }) => { - const collapsedHeaderDropOver = isOver && !props.snoozedShelf.expanded; - const showRail = visibleRailBySection.get("snoozed") === true; - const viewportRail = showRail - ? renderViewportRail("snoozed", "Snooze", isOver, setNodeRef) - : null; - if (viewportRail !== null) return viewportRail; - return ( -
    • - {props.snoozedShelf.threadCount > 0 ? ( -
      - -
      - ) : null} -
        - {dnd.sections.snoozed.map((thread) => renderThread(thread, "snoozed"))} -
      - {showRail ? rail("snoozed", "Snooze", isOver) : null} -
    • - ); - }} -
      - - {({ setNodeRef, isOver }) => { - const collapsedHeaderDropOver = isOver && !props.settledShelf.expanded; - const showRail = visibleRailBySection.get("settled") === true; - const viewportRail = showRail - ? renderViewportRail("settled", "Settled", isOver, setNodeRef) - : null; - if (viewportRail !== null) return viewportRail; - return ( -
    • - {props.settledShelf.threadCount > 0 ? ( -
      - -
      - ) : null} -
        - {dnd.sections.settled.map((thread) => renderThread(thread, "settled"))} -
      - {showRail ? rail("settled", "Settled", isOver) : null} - {props.settledShelf.expanded && props.settledShelf.hiddenCount > 0 ? ( - - ) : null} -
    • - ); - }} -
      + {renderSection("regular", "Regular", () => ( +
        + {dnd.sections.regular.map((thread) => renderThread(thread, "regular"))} +
      + ))} + {renderSection("snoozed", "Snooze", (isOver) => ( + <> + +
        + {dnd.sections.snoozed.map((thread) => renderThread(thread, "snoozed"))} +
      + + ))} + {renderSection("settled", "Settled", (isOver) => ( + <> + +
        + {dnd.sections.settled.map((thread) => renderThread(thread, "settled"))} +
      + {props.settledShelf.expanded && props.settledShelf.hiddenCount > 0 ? ( + + ) : null} + + ))}
    {dnd.transaction?.phase === "dragging" && diff --git a/apps/web/src/components/sidebar/SidebarThreadDnd.tsx b/apps/web/src/components/sidebar/SidebarThreadDnd.tsx index 95bb876ddcf4..da39f48b99e7 100644 --- a/apps/web/src/components/sidebar/SidebarThreadDnd.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadDnd.tsx @@ -1,12 +1,10 @@ -import { useDraggable, useDroppable } from "@dnd-kit/core"; +import { useDroppable } from "@dnd-kit/core"; import { useSortable } from "@dnd-kit/sortable"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; import { useCallback, useEffect, useLayoutEffect, useRef, type ReactNode } from "react"; import { cn } from "~/lib/utils"; import { - createSidebarDndDraggableId, - createSidebarDndRowId, createSidebarDndSectionId, type SidebarDndPreviewVariant, type SidebarDndSection, @@ -15,27 +13,26 @@ import { animatePinnedLayoutChanges } from "../Sidebar.logic"; import { SidebarThreadDragPreview } from "./SidebarThreadDragPreview"; export type SidebarThreadDndRowBag = { - readonly listeners: ReturnType["listeners"]; + readonly listeners: ReturnType["listeners"]; readonly setNodeRef: (node: HTMLElement | null) => void; - readonly transform: ReturnType["transform"]; + readonly transform: ReturnType["transform"]; readonly transition: string | undefined; readonly isDragging: boolean; readonly isSortable: boolean; }; -export function SortableSidebarThreadRow(props: { +export function SidebarThreadDndRow(props: { threadKey: string; - section: SidebarDndSection; - disabled: boolean; + dragDisabled: boolean; + dropDisabled: boolean; + sortable: boolean; onNodeChange: (threadKey: string, node: HTMLElement | null) => void; children: (bag: SidebarThreadDndRowBag) => ReactNode; }) { - const id = createSidebarDndDraggableId({ section: props.section, threadKey: props.threadKey }); const sortable = useSortable({ - id, - disabled: props.disabled, - animateLayoutChanges: animatePinnedLayoutChanges, - data: { section: props.section, threadKey: props.threadKey }, + id: props.threadKey, + disabled: { draggable: props.dragDisabled, droppable: props.dropDisabled }, + ...(props.sortable ? { animateLayoutChanges: animatePinnedLayoutChanges } : {}), }); const setNodeRef = useCallback( (node: HTMLElement | null) => { @@ -53,56 +50,10 @@ export function SortableSidebarThreadRow(props: { return props.children({ listeners: sortable.listeners, setNodeRef, - transform: sortable.transform, - transition: sortable.transition, + transform: props.sortable ? sortable.transform : null, + transition: props.sortable ? sortable.transition : undefined, isDragging: sortable.isDragging, - isSortable: true, - }); -} - -export function DraggableSidebarThreadRow(props: { - threadKey: string; - section: SidebarDndSection; - dragDisabled: boolean; - dropDisabled: boolean; - onNodeChange: (threadKey: string, node: HTMLElement | null) => void; - children: (bag: SidebarThreadDndRowBag) => ReactNode; -}) { - const draggable = useDraggable({ - id: createSidebarDndDraggableId({ - section: props.section, - threadKey: props.threadKey, - }), - disabled: props.dragDisabled, - data: { section: props.section, threadKey: props.threadKey }, - }); - const droppable = useDroppable({ - id: createSidebarDndRowId({ section: props.section, threadKey: props.threadKey }), - disabled: props.dropDisabled, - data: { section: props.section, threadKey: props.threadKey }, - }); - const setNodeRef = useCallback( - (node: HTMLElement | null) => { - draggable.setNodeRef(node); - droppable.setNodeRef(node); - props.onNodeChange(props.threadKey, node); - }, - [draggable.setNodeRef, droppable.setNodeRef, props.onNodeChange, props.threadKey], - ); - useEffect( - () => () => { - props.onNodeChange(props.threadKey, null); - }, - [props.onNodeChange, props.threadKey], - ); - return props.children({ - listeners: draggable.listeners, - setNodeRef, - // Sorted lists never apply the draggable transform to their source row. - transform: null, - transition: undefined, - isDragging: draggable.isDragging, - isSortable: false, + isSortable: props.sortable, }); } @@ -122,32 +73,6 @@ export function SidebarThreadSectionDropZone(props: { return props.children({ setNodeRef: droppable.setNodeRef, isOver: droppable.isOver }); } -export function SidebarThreadViewportDropRail(props: { - section: SidebarDndSection; - top: number; - setDropNodeRef: (node: HTMLElement | null) => void; - onNodeChange: (section: SidebarDndSection, node: HTMLElement | null) => void; - children: ReactNode; -}) { - const setNodeRef = useCallback( - (node: HTMLDivElement | null) => { - props.setDropNodeRef(node); - props.onNodeChange(props.section, node); - }, - [props.onNodeChange, props.section, props.setDropNodeRef], - ); - - return ( -
    - {props.children} -
    - ); -} - export function SidebarThreadDropIndicator(props: { edge: "before" | "after" }) { return ( ; readonly viewportOverlayRef: RefObject; - readonly viewportRailSectionsRef: RefObject>; readonly attachListRef: (node: HTMLUListElement | null) => void; - readonly handleViewportRailNodeChange: ( - section: SidebarDndSection, - node: HTMLElement | null, - ) => void; readonly handleThreadRowNodeChange: (threadKey: string, node: HTMLElement | null) => void; readonly getThreadRowNode: (threadKey: string) => HTMLElement | null; readonly pauseLayoutMotion: () => void; - readonly holdScrollRange: () => void; readonly retainLayoutAnchor: ( preferred?: HTMLElement | null, excludedThreadKey?: string | null, @@ -57,7 +32,6 @@ export interface SidebarDndLayout { export function useSidebarDndLayout(input: { transaction: SidebarThreadDragTransaction | null; - transactionRef: RefObject; setTransaction: SidebarThreadDragStateSetter; pinnedReorderInFlightRef: RefObject; sectionThreadCounts: Readonly>; @@ -73,62 +47,31 @@ export function useSidebarDndLayout(input: { }); const viewportRef = useRef(null); const viewportOverlayRef = useRef(null); - const viewportRailSectionsRef = useRef(new Set()); - const threadListNodeRef = useRef(null); - const scrollRangeHoldRef = useRef(null); const threadRowNodesRef = useRef(new Map()); - const autoAnimatePausedRef = useRef(false); - const viewportOverflowAnchorRef = useRef(""); - const correctedScrollTopRef = useRef(null); - const retainedLayoutAnchorRef = useRef<{ - element: HTMLElement; - top: number; - } | null>(null); + const layoutAnchorRef = useRef<{ element: HTMLElement; top: number } | null>(null); + const motionPausedRef = useRef(false); - const handleViewportRailNodeChange = useCallback( - (section: SidebarDndSection, node: HTMLElement | null) => { - if (node === null) { - viewportRailSectionsRef.current.delete(section); - return; - } - viewportRailSectionsRef.current.add(section); - }, - [], - ); const handleThreadRowNodeChange = useCallback((threadKey: string, node: HTMLElement | null) => { - if (node === null) { - threadRowNodesRef.current.delete(threadKey); - return; - } - threadRowNodesRef.current.set(threadKey, node); + if (node === null) threadRowNodesRef.current.delete(threadKey); + else threadRowNodesRef.current.set(threadKey, node); }, []); const getThreadRowNode = useCallback( (threadKey: string) => threadRowNodesRef.current.get(threadKey) ?? null, [], ); - const pauseLayoutMotion = useCallback(() => { - if (autoAnimatePausedRef.current) return; - autoAnimatePausedRef.current = true; - setAutoAnimateEnabled(false); - const viewport = viewportRef.current; - if (viewport === null) return; - viewportOverflowAnchorRef.current = viewport.style.overflowAnchor; - viewport.style.overflowAnchor = "none"; - }, [setAutoAnimateEnabled]); - const chooseLayoutAnchor = useCallback( - (preferred: HTMLElement | null, excludedThreadKey: string | null = null) => { + const visibleAnchor = useCallback( + (preferred: HTMLElement | null, excludedThreadKey: string | null) => { const viewport = viewportRef.current; if (viewport === null) return null; - const canAnchor = (element: HTMLElement) => { - if (!element.isConnected || element.dataset.dndTransformed === "true") return false; - const rect = element.getBoundingClientRect(); - const viewportRect = viewport.getBoundingClientRect(); + const viewportRect = viewport.getBoundingClientRect(); + const isVisible = (node: HTMLElement) => { + if (!node.isConnected || node.dataset.dndTransformed === "true") return false; + const rect = node.getBoundingClientRect(); return rect.bottom > viewportRect.top && rect.top < viewportRect.bottom; }; - if (preferred !== null && canAnchor(preferred)) return preferred; - for (const [threadKey, element] of threadRowNodesRef.current) { - if (threadKey === excludedThreadKey) continue; - if (canAnchor(element)) return element; + if (preferred !== null && isVisible(preferred)) return preferred; + for (const [threadKey, node] of threadRowNodesRef.current) { + if (threadKey !== excludedThreadKey && isVisible(node)) return node; } return null; }, @@ -136,171 +79,25 @@ export function useSidebarDndLayout(input: { ); const retainLayoutAnchor = useCallback( (preferred: HTMLElement | null = null, excludedThreadKey: string | null = null) => { - const anchor = chooseLayoutAnchor(preferred, excludedThreadKey); - retainedLayoutAnchorRef.current = - anchor === null ? null : { element: anchor, top: anchor.getBoundingClientRect().top }; + const element = visibleAnchor(preferred, excludedThreadKey); + layoutAnchorRef.current = + element === null ? null : { element, top: element.getBoundingClientRect().top }; }, - [chooseLayoutAnchor], + [visibleAnchor], ); - const correctLayoutAnchor = useCallback((): SidebarLayoutCorrection => { - const viewport = viewportRef.current; - const retained = retainedLayoutAnchorRef.current; - if ( - viewport === null || - retained === null || - !retained.element.isConnected || - retained.element.dataset.dndTransformed === "true" - ) { - retainLayoutAnchor(); - return { kind: "stable" }; - } - const nextTop = retained.element.getBoundingClientRect().top; - const delta = nextTop - retained.top; - if (Math.abs(delta) > 0.5) { - const maxScrollTop = Math.max(0, viewport.scrollHeight - viewport.clientHeight); - const previousScrollTop = viewport.scrollTop; - const requestedScrollTop = previousScrollTop + delta; - const nextScrollTop = Math.min(maxScrollTop, Math.max(0, requestedScrollTop)); - viewport.scrollTop = nextScrollTop; - const appliedScrollTop = viewport.scrollTop; - if (Math.abs(appliedScrollTop - previousScrollTop) > 0.5) { - correctedScrollTopRef.current = appliedScrollTop; - } - if (Math.abs(appliedScrollTop - requestedScrollTop) > 0.5) { - return { - kind: "clamped", - edge: requestedScrollTop < 0 ? "start" : "end", - missingScrollRange: Math.abs(appliedScrollTop - requestedScrollTop), - }; - } - } - retainedLayoutAnchorRef.current = { - element: retained.element, - top: retained.element.getBoundingClientRect().top, - }; - return { kind: Math.abs(delta) > 0.5 ? "corrected" : "stable" }; - }, [retainLayoutAnchor]); - const clearScrollRangeHold = useCallback(() => { - const hold = scrollRangeHoldRef.current; - if (hold === null) return; - hold.node.style.minHeight = hold.originalMinHeight; - hold.node.style.paddingTop = hold.originalPaddingTop; - hold.node.style.paddingBottom = hold.originalPaddingBottom; - scrollRangeHoldRef.current = null; - }, []); - const holdScrollRange = useCallback(() => { - const node = threadListNodeRef.current; - if (node === null) return; - const current = scrollRangeHoldRef.current; - if (current !== null && current.node !== node) { - current.node.style.minHeight = current.originalMinHeight; - current.node.style.paddingTop = current.originalPaddingTop; - current.node.style.paddingBottom = current.originalPaddingBottom; - scrollRangeHoldRef.current = null; - } - const activeHold = scrollRangeHoldRef.current; - const height = Math.max(activeHold?.height ?? 0, node.getBoundingClientRect().height); - const next = { - node, - originalMinHeight: activeHold?.originalMinHeight ?? node.style.minHeight, - originalPaddingTop: activeHold?.originalPaddingTop ?? node.style.paddingTop, - originalPaddingBottom: activeHold?.originalPaddingBottom ?? node.style.paddingBottom, - height, - topInset: activeHold?.topInset ?? 0, - bottomInset: activeHold?.bottomInset ?? 0, - } satisfies SidebarScrollRangeHold; - scrollRangeHoldRef.current = next; - node.style.minHeight = `${height}px`; - node.style.paddingTop = - next.topInset === 0 - ? next.originalPaddingTop - : `calc(${next.originalPaddingTop || "0px"} + ${next.topInset}px)`; - node.style.paddingBottom = - next.bottomInset === 0 - ? next.originalPaddingBottom - : `calc(${next.originalPaddingBottom || "0px"} + ${next.bottomInset}px)`; - }, []); - const extendScrollRange = useCallback((edge: "start" | "end", missingScrollRange: number) => { - const hold = scrollRangeHoldRef.current; - if (hold === null || missingScrollRange <= 0.5) return false; - const next = { - ...hold, - height: hold.height + missingScrollRange, - topInset: hold.topInset + (edge === "start" ? missingScrollRange : 0), - bottomInset: hold.bottomInset + (edge === "end" ? missingScrollRange : 0), - } satisfies SidebarScrollRangeHold; - scrollRangeHoldRef.current = next; - next.node.style.minHeight = `${next.height}px`; - next.node.style.paddingTop = - next.topInset === 0 - ? next.originalPaddingTop - : `calc(${next.originalPaddingTop || "0px"} + ${next.topInset}px)`; - next.node.style.paddingBottom = - next.bottomInset === 0 - ? next.originalPaddingBottom - : `calc(${next.originalPaddingBottom || "0px"} + ${next.bottomInset}px)`; - return true; - }, []); - const releaseScrollRangeIfSafe = useCallback(() => { - const hold = scrollRangeHoldRef.current; - if (hold === null) return true; - const viewport = viewportRef.current; - if (viewport === null || !hold.node.isConnected) { - clearScrollRangeHold(); - return true; - } + const pauseLayoutMotion = useCallback(() => { + if (motionPausedRef.current) return; + motionPausedRef.current = true; + setAutoAnimateEnabled(false); + }, [setAutoAnimateEnabled]); - const anchor = chooseLayoutAnchor(null); - const previousAnchorTop = anchor?.getBoundingClientRect().top ?? null; - const previousScrollTop = viewport.scrollTop; - const previousOverflowAnchor = viewport.style.overflowAnchor; - viewport.style.overflowAnchor = "none"; - try { - if (hold.topInset > 0.5) { - viewport.scrollTop = Math.max(0, previousScrollTop - hold.topInset); - } - hold.node.style.minHeight = hold.originalMinHeight; - hold.node.style.paddingTop = hold.originalPaddingTop; - hold.node.style.paddingBottom = hold.originalPaddingBottom; - const naturalMaxScrollTop = Math.max(0, viewport.scrollHeight - viewport.clientHeight); - const anchorDelta = - anchor === null || previousAnchorTop === null - ? 0 - : anchor.getBoundingClientRect().top - previousAnchorTop; - const requestedScrollTop = viewport.scrollTop + anchorDelta; - const outsideNaturalRange = - requestedScrollTop < -0.5 || requestedScrollTop > naturalMaxScrollTop + 0.5; - const temporaryInsetReachedNaturalBoundary = - (requestedScrollTop < -0.5 && hold.topInset > 0.5) || - (requestedScrollTop > naturalMaxScrollTop + 0.5 && hold.bottomInset > 0.5); - if (outsideNaturalRange && !temporaryInsetReachedNaturalBoundary) { - hold.node.style.minHeight = `${hold.height}px`; - hold.node.style.paddingTop = - hold.topInset === 0 - ? hold.originalPaddingTop - : `calc(${hold.originalPaddingTop || "0px"} + ${hold.topInset}px)`; - hold.node.style.paddingBottom = - hold.bottomInset === 0 - ? hold.originalPaddingBottom - : `calc(${hold.originalPaddingBottom || "0px"} + ${hold.bottomInset}px)`; - viewport.scrollTop = previousScrollTop; - return false; - } - viewport.scrollTop = Math.min(naturalMaxScrollTop, Math.max(0, requestedScrollTop)); - correctedScrollTopRef.current = viewport.scrollTop; - scrollRangeHoldRef.current = null; - return true; - } finally { - viewport.style.overflowAnchor = previousOverflowAnchor; - } - }, [chooseLayoutAnchor, clearScrollRangeHold]); - const moveClampedEmptyRailsToViewport = useCallback( + const moveEmptySectionsIntoViewport = useCallback( (transaction: SidebarThreadDragTransaction) => { if (transaction.phase !== "dragging" || transaction.viewportRailTopBySection !== null) { - return false; + return; } - const sourceOrderIndex = SIDEBAR_DND_SECTIONS.indexOf(transaction.sourceSection); - const overlaySections = SIDEBAR_DND_SECTIONS.slice(0, sourceOrderIndex).filter( + const sourceIndex = SIDEBAR_DND_SECTIONS.indexOf(transaction.sourceSection); + const sections = SIDEBAR_DND_SECTIONS.slice(0, sourceIndex).filter( (section) => input.sectionThreadCounts[section] === 0 && input.canDropThreadInSection( @@ -309,135 +106,75 @@ export function useSidebarDndLayout(input: { section, ), ); - if (overlaySections.length === 0) return false; - input.setTransaction((current) => { - if ( - current === null || - current.sourceThreadKey !== transaction.sourceThreadKey || - current.viewportRailTopBySection !== null - ) { - return current; - } - return { - ...current, - viewportRailTopBySection: new Map( - overlaySections.map((section, index) => [ - section, - index * SIDEBAR_DND_EMPTY_RAIL_HEIGHT, - ]), - ), - }; - }); - return true; + if (sections.length === 0) return; + input.setTransaction((current) => + current === null || current.sourceThreadKey !== transaction.sourceThreadKey + ? current + : { + ...current, + viewportRailTopBySection: new Map( + sections.map((section, index) => [section, index * EMPTY_SECTION_HEIGHT]), + ), + }, + ); }, [input.canDropThreadInSection, input.sectionThreadCounts, input.setTransaction], ); - const correctDragLayout = useCallback( - (transaction: SidebarThreadDragTransaction) => { - const correction = correctLayoutAnchor(); - if (correction.kind !== "clamped") return; - if (correction.edge === "end" && moveClampedEmptyRailsToViewport(transaction)) return; - if (!extendScrollRange(correction.edge, correction.missingScrollRange)) { - retainLayoutAnchor(); - return; - } - if (correctLayoutAnchor().kind === "clamped") { - retainLayoutAnchor(); - } - }, - [correctLayoutAnchor, extendScrollRange, moveClampedEmptyRailsToViewport, retainLayoutAnchor], - ); - const attachListRef = useCallback( - (node: HTMLUListElement | null) => { - if (threadListNodeRef.current === node) return; - clearScrollRangeHold(); - threadListNodeRef.current = node; - autoAnimateRef(node); - }, - [autoAnimateRef, clearScrollRangeHold], - ); useLayoutEffect(() => { - if (input.transaction !== null) { - holdScrollRange(); - correctDragLayout(input.transaction); - return; - } - if (input.pinnedReorderInFlightRef.current) return; - if (!autoAnimatePausedRef.current) { - releaseScrollRangeIfSafe(); - return; - } - correctLayoutAnchor(); - autoAnimatePausedRef.current = false; const viewport = viewportRef.current; - if (viewport !== null) { - viewport.style.overflowAnchor = viewportOverflowAnchorRef.current; + const anchor = layoutAnchorRef.current; + if (viewport !== null && anchor !== null && anchor.element.isConnected) { + const delta = anchor.element.getBoundingClientRect().top - anchor.top; + if (Math.abs(delta) > 0.5) { + const requestedScrollTop = viewport.scrollTop + delta; + viewport.scrollTop = requestedScrollTop; + if (Math.abs(viewport.scrollTop - requestedScrollTop) > 0.5 && input.transaction !== null) { + moveEmptySectionsIntoViewport(input.transaction); + } + } + layoutAnchorRef.current = { + element: anchor.element, + top: anchor.element.getBoundingClientRect().top, + }; + } else if (input.transaction !== null) { + retainLayoutAnchor(); } - setAutoAnimateEnabled(true); - retainedLayoutAnchorRef.current = null; - releaseScrollRangeIfSafe(); + + if (input.transaction !== null || input.pinnedReorderInFlightRef.current) return; + if (motionPausedRef.current) { + motionPausedRef.current = false; + setAutoAnimateEnabled(true); + } + layoutAnchorRef.current = null; }); + useEffect(() => { if (input.transaction === null) return; const viewport = viewportRef.current; if (viewport === null) return; const handleScroll = () => { - const correctedScrollTop = correctedScrollTopRef.current; - if (correctedScrollTop !== null && Math.abs(viewport.scrollTop - correctedScrollTop) <= 0.5) { - return; - } - correctedScrollTopRef.current = null; - const retained = retainedLayoutAnchorRef.current; - if (retained === null || !retained.element.isConnected) { + const anchor = layoutAnchorRef.current; + if (anchor === null || !anchor.element.isConnected) { retainLayoutAnchor(); - return; + } else { + layoutAnchorRef.current = { + element: anchor.element, + top: anchor.element.getBoundingClientRect().top, + }; } - retainedLayoutAnchorRef.current = { - element: retained.element, - top: retained.element.getBoundingClientRect().top, - }; }; viewport.addEventListener("scroll", handleScroll, { passive: true }); return () => viewport.removeEventListener("scroll", handleScroll); }, [input.transaction, retainLayoutAnchor]); - useEffect(() => { - if (input.transaction !== null || scrollRangeHoldRef.current === null) return; - const viewport = viewportRef.current; - if (viewport === null) return; - const handleScroll = () => { - if (releaseScrollRangeIfSafe()) { - viewport.removeEventListener("scroll", handleScroll); - } - }; - viewport.addEventListener("scroll", handleScroll, { passive: true }); - return () => viewport.removeEventListener("scroll", handleScroll); - }, [input.transaction, releaseScrollRangeIfSafe]); - useEffect(() => { - if (input.transaction === null || typeof ResizeObserver === "undefined") return; - const observer = new ResizeObserver(() => { - const transaction = input.transactionRef.current; - if (transaction !== null) { - holdScrollRange(); - correctDragLayout(transaction); - } - }); - if (viewportRef.current !== null) observer.observe(viewportRef.current); - if (threadListNodeRef.current !== null) observer.observe(threadListNodeRef.current); - return () => observer.disconnect(); - }, [correctDragLayout, holdScrollRange, input.transaction, input.transactionRef]); - useEffect(() => () => clearScrollRangeHold(), [clearScrollRangeHold]); return { viewportRef, viewportOverlayRef, - viewportRailSectionsRef, - attachListRef, - handleViewportRailNodeChange, + attachListRef: autoAnimateRef, handleThreadRowNodeChange, getThreadRowNode, pauseLayoutMotion, - holdScrollRange, retainLayoutAnchor, }; } diff --git a/apps/web/src/hooks/useSidebarPinnedDnd.ts b/apps/web/src/hooks/useSidebarPinnedDnd.ts index b2e0a85005c2..3b5400d59688 100644 --- a/apps/web/src/hooks/useSidebarPinnedDnd.ts +++ b/apps/web/src/hooks/useSidebarPinnedDnd.ts @@ -16,10 +16,14 @@ import { orderItemsByPreferredIds, planPinnedReorder } from "../components/Sideb import { stackedThreadToast, toastManager } from "../components/ui/toast"; import type { useThreadActions } from "./useThreadActions"; +interface SidebarPinnedAssignment { + readonly thread: EnvironmentThreadShell; + readonly threadKey: string; + readonly orderKey: string; +} + export interface SidebarPinnedInsertionPlan { - readonly order: readonly string[]; - readonly assignments: ReadonlyArray<{ readonly id: string; readonly orderKey: string }>; - readonly threadByKey: ReadonlyMap; + readonly assignments: readonly SidebarPinnedAssignment[]; } interface OptimisticPinnedOrder { @@ -74,9 +78,9 @@ export function useSidebarPinnedDnd(input: { transaction === null || transaction.phase !== "dragging" || transaction.sourceSection !== "pinned" || - transaction.targetSection !== "pinned" || - transaction.targetThreadKey === null || - transaction.targetEdge === null + transaction.target?.section !== "pinned" || + transaction.target.threadKey === null || + transaction.target.edge === null ) { return null; } @@ -86,8 +90,8 @@ export function useSidebarPinnedDnd(input: { const previewOrder = movePinnedThreadAtEdge({ keys, activeKey: transaction.sourceThreadKey, - overKey: transaction.targetThreadKey, - edge: transaction.targetEdge, + overKey: transaction.target.threadKey, + edge: transaction.target.edge, }); return previewOrder?.indexOf(transaction.sourceThreadKey) ?? null; }, [input.reorderablePinnedKeys, input.transaction, orderedPinnedThreads]); @@ -204,15 +208,15 @@ export function useSidebarPinnedDnd(input: { ); const planPinnedInsertion = useCallback( (transaction: SidebarThreadDragTransaction): SidebarPinnedInsertionPlan | null => { - if (transaction.sourceSection === "pinned" || transaction.targetSection !== "pinned") { + if (transaction.sourceSection === "pinned" || transaction.target?.section !== "pinned") { return null; } const existingKeys = input.allPinnedThreads.map(sidebarThreadKey); let insertionIndex = existingKeys.length; - if (transaction.targetThreadKey !== null) { - const targetIndex = existingKeys.indexOf(transaction.targetThreadKey); + if (transaction.target.threadKey !== null) { + const targetIndex = existingKeys.indexOf(transaction.target.threadKey); if (targetIndex !== -1) { - insertionIndex = targetIndex + (transaction.targetEdge === "after" ? 1 : 0); + insertionIndex = targetIndex + (transaction.target.edge === "after" ? 1 : 0); } } else if (existingKeys.length === 0) { insertionIndex = 0; @@ -236,6 +240,7 @@ export function useSidebarPinnedDnd(input: { movedId: transaction.sourceThreadKey, }); if (assignments.length === 0) return null; + const resolvedAssignments: SidebarPinnedAssignment[] = []; for (const assignment of assignments) { const thread = threadByKey.get(assignment.id); if (thread === undefined) return null; @@ -244,8 +249,13 @@ export function useSidebarPinnedDnd(input: { } else if (!input.canReorder(thread)) { return null; } + resolvedAssignments.push({ + thread, + threadKey: assignment.id, + orderKey: assignment.orderKey, + }); } - return { order, assignments, threadByKey }; + return { assignments: resolvedAssignments }; }, [input.allPinnedThreads, input.canPinWithOrder, input.canReorder], ); diff --git a/apps/web/src/hooks/useSidebarThreadDnd.ts b/apps/web/src/hooks/useSidebarThreadDnd.ts index 6b3c66eb1da7..fe4d9039fb38 100644 --- a/apps/web/src/hooks/useSidebarThreadDnd.ts +++ b/apps/web/src/hooks/useSidebarThreadDnd.ts @@ -5,10 +5,8 @@ import { useSensor, useSensors, type CollisionDetection, - type DragCancelEvent, type DragEndEvent, type DragMoveEvent, - type DragOverEvent, type DragStartEvent, } from "@dnd-kit/core"; import { getEventCoordinates } from "@dnd-kit/utilities"; @@ -29,14 +27,14 @@ import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentSnapshotAtom } from "../state/shell"; import { buildSidebarDndBoardSections } from "../components/Sidebar.dnd.board"; import { - captureSidebarDndPointerAnchor, - parseSidebarDndId, + parseSidebarDndSectionId, resolveSidebarDndAction, resolveSidebarDndPreviewVariant, sidebarThreadKey, SIDEBAR_DND_SECTIONS, type SidebarDndAction, type SidebarDndSection, + type SidebarThreadDropTarget, type SidebarThreadDragTransaction, } from "../components/Sidebar.dnd.logic"; import { @@ -49,12 +47,6 @@ import { useSidebarDndLayout } from "./useSidebarDndLayout"; import { useSidebarPinnedDnd, type SidebarPinnedInsertionPlan } from "./useSidebarPinnedDnd"; import type { useThreadActions } from "./useThreadActions"; -interface SidebarThreadDropTarget { - readonly targetSection: SidebarDndSection; - readonly targetThreadKey: string | null; - readonly targetEdge: "before" | "after" | null; -} - interface SidebarThreadDndCapabilities { readonly threadPinning?: boolean; readonly threadPinReorder?: boolean; @@ -77,15 +69,6 @@ type SidebarSnoozeOutcome = | { readonly status: "failure"; readonly error: unknown } | { readonly status: "success"; readonly sequence: number }; -function sectionIndex( - section: SidebarDndSection, - threadKey: string, - sections: Readonly>, -): number { - const index = sections[section].findIndex((thread) => sidebarThreadKey(thread) === threadKey); - return Math.max(0, index); -} - export function useSidebarThreadDnd(input: { threads: readonly EnvironmentThreadShell[]; pinnedThreads: readonly EnvironmentThreadShell[]; @@ -201,23 +184,14 @@ export function useSidebarThreadDnd(input: { handlePinnedReorder, planPinnedInsertion, } = pinnedDnd; - const sectionThreadCounts = useMemo( - () => ({ - pinned: input.pinnedThreads.length, - regular: input.activeThreads.length, - snoozed: input.snoozedThreads.length, - settled: input.settledThreads.length, - }), - [ - input.activeThreads.length, - input.pinnedThreads.length, - input.settledThreads.length, - input.snoozedThreads.length, - ], - ); + const sectionThreadCounts = { + pinned: input.pinnedThreads.length, + regular: input.activeThreads.length, + snoozed: input.snoozedThreads.length, + settled: input.settledThreads.length, + }; const layout = useSidebarDndLayout({ transaction, - transactionRef, setTransaction, pinnedReorderInFlightRef, sectionThreadCounts, @@ -226,10 +200,8 @@ export function useSidebarThreadDnd(input: { const { viewportRef, viewportOverlayRef, - viewportRailSectionsRef, getThreadRowNode, pauseLayoutMotion, - holdScrollRange, retainLayoutAnchor, } = layout; @@ -249,10 +221,14 @@ export function useSidebarThreadDnd(input: { void readLocalApi()?.contextMenu.close(); } pointerCoordinatesRef.current = null; - retainLayoutAnchor( - options.excludeSource || current === null + const preferredThreadKey = + current === null ? null - : getThreadRowNode(current.sourceThreadKey), + : options.excludeSource + ? (current.target?.threadKey ?? null) + : current.sourceThreadKey; + retainLayoutAnchor( + preferredThreadKey === null ? null : getThreadRowNode(preferredThreadKey), options.excludeSource && current !== null ? current.sourceThreadKey : null, ); setTransaction(null); @@ -262,19 +238,12 @@ export function useSidebarThreadDnd(input: { const beginReconciliation = useCallback( (reconciliation: { transaction: SidebarThreadDragTransaction; - destinationSection: SidebarDndSection; receiptSequencesByEnvironment: ReadonlyMap; - pinnedOrder?: readonly string[] | null; - snoozedUntil?: string | null; }) => { retainLayoutAnchor(null, reconciliation.transaction.sourceThreadKey); setTransaction({ ...reconciliation.transaction, phase: "reconciling", - targetSection: reconciliation.destinationSection, - destinationSection: reconciliation.destinationSection, - pinnedOrder: reconciliation.pinnedOrder ?? null, - snoozedUntil: reconciliation.snoozedUntil ?? null, receiptSequencesByEnvironment: reconciliation.receiptSequencesByEnvironment, }); }, @@ -300,7 +269,6 @@ export function useSidebarThreadDnd(input: { const commitLifecycleDrop = useCallback( ( current: SidebarThreadDragTransaction, - destinationSection: SidebarDndSection, action: Exclude, pinnedPlan: SidebarPinnedInsertionPlan | null, ) => { @@ -312,10 +280,6 @@ export function useSidebarThreadDnd(input: { setTransaction({ ...current, phase: "committing", - targetSection: destinationSection, - destinationSection, - pinnedOrder: pinnedPlan?.order ?? null, - snoozedUntil: null, receiptSequencesByEnvironment: null, }); const threadRef = scopeThreadRef( @@ -323,29 +287,15 @@ export function useSidebarThreadDnd(input: { current.sourceThread.id, ); const receiptSequences = new Map(); - const recordReceipt = ( - environmentId: EnvironmentThreadShell["environmentId"], - sequence: number, - ) => { - receiptSequences.set( - environmentId, - Math.max(receiptSequences.get(environmentId) ?? 0, sequence), - ); - }; if (action === "pin") { if (pinnedPlan === null) { finishTransaction(); return; } for (const assignment of pinnedPlan.assignments) { - if (assignment.id === current.sourceThreadKey) continue; - const thread = pinnedPlan.threadByKey.get(assignment.id); - if (thread === undefined) { - finishTransaction(); - return; - } + if (assignment.threadKey === current.sourceThreadKey) continue; const result = await input.actions.reorderPinnedThread( - scopeThreadRef(thread.environmentId, thread.id), + scopeThreadRef(assignment.thread.environmentId, assignment.thread.id), assignment.orderKey, ); if (result._tag === "Failure") { @@ -353,10 +303,10 @@ export function useSidebarThreadDnd(input: { reportDropFailure("Failed to prepare pinned order", result); return; } - recordReceipt(thread.environmentId, result.value.sequence); + receiptSequences.set(assignment.thread.environmentId, result.value.sequence); } const sourceAssignment = pinnedPlan.assignments.find( - (assignment) => assignment.id === current.sourceThreadKey, + (assignment) => assignment.threadKey === current.sourceThreadKey, ); if (sourceAssignment === undefined) { finishTransaction(); @@ -370,12 +320,10 @@ export function useSidebarThreadDnd(input: { reportDropFailure("Failed to pin thread", result); return; } - recordReceipt(current.sourceThread.environmentId, result.value.sequence); + receiptSequences.set(current.sourceThread.environmentId, result.value.sequence); beginReconciliation({ transaction: current, - destinationSection, receiptSequencesByEnvironment: receiptSequences, - pinnedOrder: pinnedPlan.order, }); return; } @@ -407,10 +355,9 @@ export function useSidebarThreadDnd(input: { if (action === "settle" && input.isRouteThread(current.sourceThreadKey)) { navigateAfterSettle?.(); } - recordReceipt(current.sourceThread.environmentId, result.value.sequence); + receiptSequences.set(current.sourceThread.environmentId, result.value.sequence); beginReconciliation({ transaction: current, - destinationSection, receiptSequencesByEnvironment: receiptSequences, }); })(); @@ -433,10 +380,7 @@ export function useSidebarThreadDnd(input: { setTransaction({ ...current, phase: "awaiting-snooze-choice", - targetSection: "snoozed", - destinationSection: "snoozed", - pinnedOrder: null, - snoozedUntil: null, + target: { section: "snoozed", threadKey: null, edge: null }, receiptSequencesByEnvironment: null, }); void (async () => { @@ -460,12 +404,7 @@ export function useSidebarThreadDnd(input: { finishTransaction(); return; } - const selectedId = selected.value.startsWith("snooze:") - ? selected.value.slice("snooze:".length) - : null; - const preset = resolveSnoozePresets(new Date(), input.timestampFormat).find( - (candidate) => candidate.id === selectedId, - ); + const preset = menuPresets.find((candidate) => `snooze:${candidate.id}` === selected.value); if (preset === undefined || !sourceStillMatchesDragStart(current)) { finishTransaction(); return; @@ -473,10 +412,6 @@ export function useSidebarThreadDnd(input: { setTransaction({ ...current, phase: "committing", - targetSection: "snoozed", - destinationSection: "snoozed", - pinnedOrder: null, - snoozedUntil: preset.snoozedUntil, receiptSequencesByEnvironment: null, }); const threadRef = scopeThreadRef( @@ -513,11 +448,9 @@ export function useSidebarThreadDnd(input: { ); beginReconciliation({ transaction: current, - destinationSection: "snoozed", receiptSequencesByEnvironment: new Map([ [current.sourceThread.environmentId, outcome.sequence], ]), - snoozedUntil: preset.snoozedUntil, }); })(); }, @@ -532,33 +465,31 @@ export function useSidebarThreadDnd(input: { ], ); - const collisionDetection = useCallback( - (args) => { - if (args.pointerCoordinates !== null) pointerCoordinatesRef.current = args.pointerCoordinates; - const pointerCollisions = pointerWithin(args); - if (pointerCollisions.length > 0) { - const viewportRailSections = viewportRailSectionsRef.current; - return pointerCollisions.toSorted((left, right) => { - const priority = (id: ReturnType) => { - if (id?.kind === "section" && viewportRailSections.has(id.section)) return 0; - return id?.kind === "section" ? 2 : 1; - }; - return priority(parseSidebarDndId(left.id)) - priority(parseSidebarDndId(right.id)); - }); - } - return closestCenter(args); - }, - [viewportRailSectionsRef], - ); + const collisionDetection = useCallback((args) => { + if (args.pointerCoordinates !== null) pointerCoordinatesRef.current = args.pointerCoordinates; + const pointerCollisions = pointerWithin(args); + if (pointerCollisions.length > 0) { + const viewportRailSections = transactionRef.current?.viewportRailTopBySection; + return pointerCollisions.toSorted((left, right) => { + const priority = (id: unknown) => { + const section = parseSidebarDndSectionId(id); + if (section !== null && viewportRailSections?.has(section) === true) return 0; + return section === null ? 1 : 2; + }; + return priority(left.id) - priority(right.id); + }); + } + return closestCenter(args); + }, []); const handleDragStart = useCallback( (event: DragStartEvent) => { if (pinnedReorderInFlightRef.current) return; - const id = parseSidebarDndId(event.active.id); - if (id === null || id.kind !== "draggable") return; - const sourceThread = allThreadByKeyRef.current.get(id.threadKey); - const sourceNode = getThreadRowNode(id.threadKey); + if (typeof event.active.id !== "string") return; + const threadKey = event.active.id; + const sourceThread = allThreadByKeyRef.current.get(threadKey); + const sourceNode = getThreadRowNode(threadKey); if (sourceThread === undefined || sourceNode === null) return; - const sourceSection = canonicalSectionByThreadKeyRef.current.get(id.threadKey); + const sourceSection = canonicalSectionByThreadKeyRef.current.get(threadKey); if (sourceSection === undefined || !canDragThread(sourceThread, sourceSection)) return; const sourceRect = sourceNode.getBoundingClientRect(); const pointer = getEventCoordinates(event.activatorEvent) ?? { @@ -573,27 +504,31 @@ export function useSidebarThreadDnd(input: { settled: input.renderedSettledThreads, } satisfies Readonly>; pauseLayoutMotion(); - holdScrollRange(); retainLayoutAnchor(sourceNode); setTransaction({ phase: "dragging", sourceThread, - sourceThreadKey: id.threadKey, + sourceThreadKey: threadKey, sourceSection, - sourceIndex: sectionIndex(sourceSection, id.threadKey, sections), + sourceIndex: Math.max( + 0, + sections[sourceSection].findIndex((thread) => sidebarThreadKey(thread) === threadKey), + ), sourceRect: { - left: sourceRect.left, - top: sourceRect.top, width: sourceRect.width, height: sourceRect.height, }, - pointerAnchor: captureSidebarDndPointerAnchor({ pointer, sourceRect }), - targetSection: sourceSection, - targetThreadKey: id.threadKey, - targetEdge: null, - destinationSection: null, - pinnedOrder: null, - snoozedUntil: null, + pointerAnchor: { + x: + sourceRect.width === 0 + ? 0.5 + : Math.min(1, Math.max(0, (pointer.x - sourceRect.left) / sourceRect.width)), + y: + sourceRect.height === 0 + ? 0.5 + : Math.min(1, Math.max(0, (pointer.y - sourceRect.top) / sourceRect.height)), + }, + target: { section: sourceSection, threadKey, edge: null }, receiptSequencesByEnvironment: null, viewportRailTopBySection: null, }); @@ -601,7 +536,6 @@ export function useSidebarThreadDnd(input: { [ canDragThread, getThreadRowNode, - holdScrollRange, input.activeThreads, input.renderedSettledThreads, input.visibleSnoozedThreads, @@ -618,17 +552,22 @@ export function useSidebarThreadDnd(input: { over: DragMoveEvent["over"], ): SidebarThreadDropTarget | null => { if (over === null) return null; - const overId = parseSidebarDndId(over.id); - if (overId === null) return null; - const destination = overId.section; + const sectionDrop = parseSidebarDndSectionId(over.id); + const targetThreadKey = sectionDrop === null && typeof over.id === "string" ? over.id : null; + const destination = + sectionDrop ?? + (targetThreadKey === null + ? undefined + : canonicalSectionByThreadKeyRef.current.get(targetThreadKey)); + if (destination === undefined) return null; if (!canDropThreadInSection(current.sourceThread, current.sourceSection, destination)) { return null; } - let targetThreadKey = overId.kind === "section" ? null : overId.threadKey; + let resolvedThreadKey = targetThreadKey; let targetEdge: "before" | "after" | null = null; const pointerY = pointerCoordinatesRef.current?.y ?? over.rect.top + over.rect.height / 2; - if (targetThreadKey !== null) { - if (destination === "pinned" && !input.reorderablePinnedKeys.has(targetThreadKey)) { + if (resolvedThreadKey !== null) { + if (destination === "pinned" && !input.reorderablePinnedKeys.has(resolvedThreadKey)) { return null; } targetEdge = pointerY < over.rect.top + over.rect.height / 2 ? "before" : "after"; @@ -636,72 +575,39 @@ export function useSidebarThreadDnd(input: { const before = pointerY < over.rect.top + over.rect.height / 2; const target = before ? orderedPinnedThreads[0] : orderedPinnedThreads.at(-1); if (target !== undefined) { - targetThreadKey = sidebarThreadKey(target); + resolvedThreadKey = sidebarThreadKey(target); targetEdge = before ? "before" : "after"; } } - return { targetSection: destination, targetThreadKey, targetEdge }; + return { section: destination, threadKey: resolvedThreadKey, edge: targetEdge }; }, [canDropThreadInSection, input.reorderablePinnedKeys, orderedPinnedThreads], ); - const capturePointerFromDragEvent = useCallback((event: DragMoveEvent) => { - const activationCoordinates = getEventCoordinates(event.activatorEvent); - if (activationCoordinates === null) return pointerCoordinatesRef.current; - const pointer = { - x: activationCoordinates.x + event.delta.x, - y: activationCoordinates.y + event.delta.y, - }; - pointerCoordinatesRef.current = pointer; - return pointer; - }, []); const updateDragTarget = useCallback( (over: DragMoveEvent["over"]) => { const current = transactionRef.current; if (current === null || current.phase !== "dragging") return; const target = resolveDropTarget(current, over); if (target === null) { - if (current.targetSection === null) return; - setTransaction({ - ...current, - targetSection: null, - targetThreadKey: null, - targetEdge: null, - }); + if (current.target === null) return; + setTransaction({ ...current, target: null }); return; } if ( - current.targetSection === target.targetSection && - current.targetThreadKey === target.targetThreadKey && - current.targetEdge === target.targetEdge + current.target?.section === target.section && + current.target.threadKey === target.threadKey && + current.target.edge === target.edge ) { return; } - setTransaction({ ...current, ...target }); + setTransaction({ ...current, target }); }, [resolveDropTarget, setTransaction], ); - const handleDragMove = useCallback( - (event: DragMoveEvent) => { - capturePointerFromDragEvent(event); - updateDragTarget(event.over); - }, - [capturePointerFromDragEvent, updateDragTarget], - ); - const handleDragOver = useCallback( - (event: DragOverEvent) => { - capturePointerFromDragEvent(event); - updateDragTarget(event.over); - }, - [capturePointerFromDragEvent, updateDragTarget], - ); - const handleDragCancel = useCallback( - (_event: DragCancelEvent) => finishTransaction(), - [finishTransaction], - ); const handleDragEnd = useCallback( (event: DragEndEvent) => { const current = transactionRef.current; - const releasePoint = capturePointerFromDragEvent(event); + const releasePoint = pointerCoordinatesRef.current; const target = current !== null && current.phase === "dragging" ? resolveDropTarget(current, event.over) @@ -711,10 +617,10 @@ export function useSidebarThreadDnd(input: { finishTransaction(); return; } - const finalized = { ...current, ...target }; + const finalized = { ...current, target }; const action = resolveSidebarDndAction({ source: finalized.sourceSection, - destination: finalized.targetSection, + destination: finalized.target.section, }); if (action === "noop") { finishTransaction(); @@ -723,20 +629,15 @@ export function useSidebarThreadDnd(input: { if (action === "reorder-pinned") { handlePinnedReorder( finalized.sourceThreadKey, - finalized.targetThreadKey, - finalized.targetEdge, + finalized.target.threadKey, + finalized.target.edge, ); finishTransaction(); return; } if (action === "snooze") { - openSnoozeDropMenu( - finalized, - releasePoint ?? { - x: finalized.sourceRect.left + finalized.sourceRect.width / 2, - y: finalized.sourceRect.top + finalized.sourceRect.height / 2, - }, - ); + if (releasePoint === null) finishTransaction(); + else openSnoozeDropMenu(finalized, releasePoint); return; } const pinnedPlan = action === "pin" ? planPinnedInsertion(finalized) : null; @@ -744,10 +645,9 @@ export function useSidebarThreadDnd(input: { finishTransaction(); return; } - commitLifecycleDrop(finalized, finalized.targetSection, action, pinnedPlan); + commitLifecycleDrop(finalized, action, pinnedPlan); }, [ - capturePointerFromDragEvent, commitLifecycleDrop, finishTransaction, handlePinnedReorder, @@ -807,19 +707,14 @@ export function useSidebarThreadDnd(input: { transaction, ], ); - const dropIndicatorByThreadKey = useMemo(() => { - const indicators = new Map(); - if ( - transaction === null || - transaction.phase === "reconciling" || - transaction.targetThreadKey === null || - transaction.targetEdge === null - ) { - return indicators; - } - indicators.set(transaction.targetThreadKey, transaction.targetEdge); - return indicators; - }, [transaction]); + const dropIndicator = + transaction !== null && + transaction.phase !== "reconciling" && + transaction.target?.threadKey !== null && + transaction.target?.threadKey !== undefined && + transaction.target.edge !== null + ? { threadKey: transaction.target.threadKey, edge: transaction.target.edge } + : null; const isTemporarySectionRailVisible = useCallback( (section: SidebarDndSection) => { if (transaction === null || transaction.phase === "reconciling") return false; @@ -844,7 +739,7 @@ export function useSidebarThreadDnd(input: { transaction?.phase === "dragging" ? resolveSidebarDndPreviewVariant({ source: transaction.sourceSection, - destination: transaction.targetSection, + destination: transaction.target?.section ?? null, }) : null; @@ -857,9 +752,8 @@ export function useSidebarThreadDnd(input: { sensors, collisionDetection, onDragStart: handleDragStart, - onDragMove: handleDragMove, - onDragOver: handleDragOver, - onDragCancel: handleDragCancel, + onDragMove: (event: DragMoveEvent) => updateDragTarget(event.over), + onDragCancel: () => finishTransaction(), onDragEnd: handleDragEnd, }, layout, @@ -868,7 +762,7 @@ export function useSidebarThreadDnd(input: { reorderablePinnedKeys: input.reorderablePinnedKeys, pinnedSortingStrategy, optimisticPinnedOrderActive: optimisticPinnedOrder !== null, - dropIndicatorByThreadKey, + dropIndicator, dragPreviewVariant, canDragThread, canDropThreadInSection, From 0a8cf5c3459c01afba5d295dcd3ea8ea06fc607a Mon Sep 17 00:00:00 2001 From: Taras Date: Fri, 21 Aug 2026 21:28:53 +0300 Subject: [PATCH 04/26] fix(web): stabilize sidebar dragging --- .../sidebar/SidebarThreadDragPreview.tsx | 4 ++-- apps/web/src/hooks/useSidebarDndLayout.ts | 18 ++++++++++++++---- apps/web/src/hooks/useSidebarThreadDnd.ts | 1 + 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/sidebar/SidebarThreadDragPreview.tsx b/apps/web/src/components/sidebar/SidebarThreadDragPreview.tsx index d41243281d8b..ab6aece505e7 100644 --- a/apps/web/src/components/sidebar/SidebarThreadDragPreview.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadDragPreview.tsx @@ -29,7 +29,7 @@ export const SidebarThreadDragPreview = memo(function SidebarThreadDragPreview( return (
    {favicon} @@ -41,7 +41,7 @@ export const SidebarThreadDragPreview = memo(function SidebarThreadDragPreview( return (
    -
    +
    {favicon} diff --git a/apps/web/src/hooks/useSidebarDndLayout.ts b/apps/web/src/hooks/useSidebarDndLayout.ts index 49a62ceb57ad..cf5e7b7fbd5b 100644 --- a/apps/web/src/hooks/useSidebarDndLayout.ts +++ b/apps/web/src/hooks/useSidebarDndLayout.ts @@ -124,7 +124,12 @@ export function useSidebarDndLayout(input: { useLayoutEffect(() => { const viewport = viewportRef.current; const anchor = layoutAnchorRef.current; - if (viewport !== null && anchor !== null && anchor.element.isConnected) { + if ( + viewport !== null && + anchor !== null && + anchor.element.isConnected && + anchor.element.dataset.dndTransformed !== "true" + ) { const delta = anchor.element.getBoundingClientRect().top - anchor.top; if (Math.abs(delta) > 0.5) { const requestedScrollTop = viewport.scrollTop + delta; @@ -138,7 +143,7 @@ export function useSidebarDndLayout(input: { top: anchor.element.getBoundingClientRect().top, }; } else if (input.transaction !== null) { - retainLayoutAnchor(); + retainLayoutAnchor(null, input.transaction.sourceThreadKey); } if (input.transaction !== null || input.pinnedReorderInFlightRef.current) return; @@ -151,12 +156,17 @@ export function useSidebarDndLayout(input: { useEffect(() => { if (input.transaction === null) return; + const sourceThreadKey = input.transaction.sourceThreadKey; const viewport = viewportRef.current; if (viewport === null) return; const handleScroll = () => { const anchor = layoutAnchorRef.current; - if (anchor === null || !anchor.element.isConnected) { - retainLayoutAnchor(); + if ( + anchor === null || + !anchor.element.isConnected || + anchor.element.dataset.dndTransformed === "true" + ) { + retainLayoutAnchor(null, sourceThreadKey); } else { layoutAnchorRef.current = { element: anchor.element, diff --git a/apps/web/src/hooks/useSidebarThreadDnd.ts b/apps/web/src/hooks/useSidebarThreadDnd.ts index fe4d9039fb38..5f5a2c363a99 100644 --- a/apps/web/src/hooks/useSidebarThreadDnd.ts +++ b/apps/web/src/hooks/useSidebarThreadDnd.ts @@ -710,6 +710,7 @@ export function useSidebarThreadDnd(input: { const dropIndicator = transaction !== null && transaction.phase !== "reconciling" && + (transaction.sourceSection !== "pinned" || transaction.target?.section !== "pinned") && transaction.target?.threadKey !== null && transaction.target?.threadKey !== undefined && transaction.target.edge !== null From 9ffd1bce8938f2637a7883004b55475a1a3391fa Mon Sep 17 00:00:00 2001 From: Taras Date: Sat, 22 Aug 2026 23:04:22 +0300 Subject: [PATCH 05/26] refactor(web): use one sortable sidebar list --- apps/web/src/components/Sidebar.dnd.board.ts | 98 ++- apps/web/src/components/Sidebar.dnd.logic.ts | 15 +- apps/web/src/components/Sidebar.tsx | 720 +++++++++--------- .../components/sidebar/SidebarThreadBoard.tsx | 447 ++++++----- .../components/sidebar/SidebarThreadDnd.tsx | 185 +++-- .../sidebar/SidebarThreadDragPreview.tsx | 77 -- .../sidebar/SidebarThreadDropOutline.tsx | 125 +++ apps/web/src/components/ui/scroll-area.tsx | 10 - apps/web/src/components/ui/sidebar.tsx | 10 +- apps/web/src/hooks/useSidebarDndLayout.ts | 200 +---- apps/web/src/hooks/useSidebarPinnedDnd.ts | 38 +- apps/web/src/hooks/useSidebarThreadDnd.ts | 362 +++++---- docs/internals/sidebar-thread-dnd.md | 169 +++- 13 files changed, 1336 insertions(+), 1120 deletions(-) delete mode 100644 apps/web/src/components/sidebar/SidebarThreadDragPreview.tsx create mode 100644 apps/web/src/components/sidebar/SidebarThreadDropOutline.tsx diff --git a/apps/web/src/components/Sidebar.dnd.board.ts b/apps/web/src/components/Sidebar.dnd.board.ts index 3748c1b98743..07492879acfd 100644 --- a/apps/web/src/components/Sidebar.dnd.board.ts +++ b/apps/web/src/components/Sidebar.dnd.board.ts @@ -1,38 +1,92 @@ -import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; - import { + createSidebarDndSectionId, sidebarThreadKey, SIDEBAR_DND_SECTIONS, type SidebarDndSection, - type SidebarThreadDragTransaction, + type SidebarThreadDropTarget, } from "./Sidebar.dnd.logic"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; -export type SidebarThreadBoardSections = Readonly< - Record ->; +export type SidebarDndBoardEntry = + | { + readonly kind: "boundary"; + readonly id: string; + readonly section: SidebarDndSection; + } + | { + readonly kind: "thread"; + readonly id: string; + readonly thread: EnvironmentThreadShell; + }; -export function buildSidebarDndBoardSections(input: { +export function buildSidebarDndBoardEntries(input: { pinnedThreads: readonly EnvironmentThreadShell[]; regularThreads: readonly EnvironmentThreadShell[]; snoozedThreads: readonly EnvironmentThreadShell[]; settledThreads: readonly EnvironmentThreadShell[]; - transaction: SidebarThreadDragTransaction | null; -}): SidebarThreadBoardSections { - const sections: Record = { - pinned: [...input.pinnedThreads], - regular: [...input.regularThreads], - snoozed: [...input.snoozedThreads], - settled: [...input.settledThreads], - }; - const { transaction } = input; - if (transaction === null) return sections; +}): readonly SidebarDndBoardEntry[] { + const threadsBySection = { + pinned: input.pinnedThreads, + regular: input.regularThreads, + snoozed: input.snoozedThreads, + settled: input.settledThreads, + } satisfies Readonly>; + const entries: SidebarDndBoardEntry[] = []; for (const section of SIDEBAR_DND_SECTIONS) { - sections[section] = sections[section].filter( - (thread) => sidebarThreadKey(thread) !== transaction.sourceThreadKey, + entries.push({ + kind: "boundary", + id: createSidebarDndSectionId({ section }), + section, + }); + entries.push( + ...threadsBySection[section].map((thread) => ({ + kind: "thread" as const, + id: sidebarThreadKey(thread), + thread, + })), ); } - const source = sections[transaction.sourceSection]; - source.splice(Math.min(transaction.sourceIndex, source.length), 0, transaction.sourceThread); - return sections; + return entries; +} + +export function findSidebarDndBoardThreadSection( + entries: readonly SidebarDndBoardEntry[], + threadKey: string, +): SidebarDndSection | null { + let section: SidebarDndSection | null = null; + for (const entry of entries) { + if (entry.kind === "boundary") { + section = entry.section; + continue; + } + if (entry.id === threadKey) return section; + } + return null; +} + +export function moveSidebarDndBoardThread(input: { + entries: readonly SidebarDndBoardEntry[]; + threadKey: string; + target: SidebarThreadDropTarget; +}): readonly SidebarDndBoardEntry[] { + const activeEntry = input.entries.find( + (entry) => entry.kind === "thread" && entry.id === input.threadKey, + ); + if (activeEntry === undefined) return input.entries; + + const entries = input.entries.filter((entry) => entry.id !== input.threadKey); + const targetId = + input.target.threadKey ?? createSidebarDndSectionId({ section: input.target.section }); + const targetIndex = entries.findIndex((entry) => entry.id === targetId); + if (targetIndex === -1) return input.entries; + + const insertionIndex = + input.target.threadKey === null || input.target.edge === "after" + ? targetIndex + 1 + : targetIndex; + entries.splice(insertionIndex, 0, activeEntry); + return entries.every((entry, index) => entry.id === input.entries[index]?.id) + ? input.entries + : entries; } diff --git a/apps/web/src/components/Sidebar.dnd.logic.ts b/apps/web/src/components/Sidebar.dnd.logic.ts index 9891414bc9f2..ba4102770134 100644 --- a/apps/web/src/components/Sidebar.dnd.logic.ts +++ b/apps/web/src/components/Sidebar.dnd.logic.ts @@ -1,6 +1,8 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment"; +import type { SidebarDndBoardEntry } from "./Sidebar.dnd.board"; + export type SidebarDndSection = "pinned" | "regular" | "snoozed" | "settled"; export const SIDEBAR_DND_SECTIONS = [ @@ -44,15 +46,22 @@ export interface SidebarThreadDragTransaction { readonly sourceThread: EnvironmentThreadShell; readonly sourceThreadKey: string; readonly sourceSection: SidebarDndSection; - readonly sourceIndex: number; - readonly sourceRect: { readonly width: number; readonly height: number }; + readonly sourceRect: { + readonly top: number; + readonly left: number; + readonly width: number; + readonly height: number; + }; + readonly sourceScrollTop: number; readonly pointerAnchor: SidebarDndPointerAnchor; + readonly initialEntries: readonly SidebarDndBoardEntry[]; + readonly entries: readonly SidebarDndBoardEntry[]; + readonly emptySections: ReadonlySet; readonly target: SidebarThreadDropTarget | null; readonly receiptSequencesByEnvironment: ReadonlyMap< EnvironmentThreadShell["environmentId"], number > | null; - readonly viewportRailTopBySection: ReadonlyMap | null; } const DND_SECTION_ID_PREFIX = "sidebar-thread-section:"; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index cc02d5634dda..56a288ccebbe 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -162,8 +162,9 @@ import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./u import { SidebarContent, SidebarGroup, SidebarMenuButton, useSidebar } from "./ui/sidebar"; import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; import { - SidebarThreadDropIndicator, + SidebarThreadDragMorph, type SidebarThreadDndRowBag, + type SidebarThreadDragView, } from "./sidebar/SidebarThreadDnd"; import { SidebarThreadBoard, type SidebarThreadRenderState } from "./sidebar/SidebarThreadBoard"; import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; @@ -677,12 +678,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { // the descriptor is not loaded. Pinning itself lives in the context menu. pinningSupported: boolean; isPinned: boolean; - // Applied to the exact row root measured by DnD Kit. Sorted rows never use - // the draggable transform; Pinned alone receives sortable transforms. + // Applied to the exact row root measured and transformed by dnd-kit. dnd?: SidebarThreadDndRowBag | undefined; - dndDimmed: boolean; + dndDragView: SidebarThreadDragView | null; dndInert: boolean; - dropIndicator: "before" | "after" | null; // Compact wake countdown ("2h") for rows in the snoozed shelf. snoozeWakeLabelText: string | null; // When a snooze ended (timer or early wake); drives the Woke pill until @@ -739,9 +738,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { openPullRequestsInRightPanel, renamingTitle, thread, - variant, variantAction, } = props; + const variant = props.dndDragView?.variant ?? props.variant; const threadRef = useMemo( () => scopeThreadRef(thread.environmentId, thread.id), [thread.environmentId, thread.id], @@ -1103,6 +1102,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { !props.isActive && !isSelected && "opacity-70 transition-opacity hover:opacity-100", + props.dndDragView !== null && + "bg-sidebar/95! text-sidebar-foreground opacity-100! shadow-lg ring-1 ring-sidebar-border/70", ); const title = isRenaming ? ( @@ -1210,401 +1211,442 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ) ) : null; + const dnd = props.dnd; + const dragView = dnd?.isDragging ? props.dndDragView : null; + if (variant === "slim") { - const dnd = props.dnd; return (
  • - {props.dropIndicator ? : null} - - - } - > - {/* Settled history recedes: dimmed favicon at rest, restored on - hover so the tail stays scannable when you're hunting. */} - + + + } > - - - {title} - {pinIndicator} - {terminalStatusIcon} - {isRegeneratingTitle ? ( - - Regenerating title - - ) : null} - {/* The PR badge stays outside the hover-fading slot: it must - remain visible AND clickable while the row is hovered. Only - the time/jump label yields to the settle affordance. */} - {prBadge} - + {/* Settled history recedes: dimmed favicon at rest, restored on + hover so the tail stays scannable when you're hunting. */} - {variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? ( - // Snoozed rows show when they come BACK, not when they were - // last touched — the return ticket is the row's whole story. - - {props.snoozeWakeLabelText} - - ) : isWoke ? ( - - - - Woke - - } - /> - Dismiss Woke notification - - ) : ( - - {variantAction === "unsettle" - ? settledTimeLabel(thread) - : threadTimeLabel(thread)} - - )} + - {variantAction === "unsnooze" ? ( - !props.snoozeSupported ? null : ( + {title} + {terminalStatusIcon} + {isRegeneratingTitle ? ( + + Regenerating title + + ) : null} + {/* The PR badge stays outside the hover-fading slot: it must + remain visible AND clickable while the row is hovered. Only + the time/jump label yields to the settle affordance. */} + {prBadge} + + + {variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? ( + // Snoozed rows show when they come BACK, not when they were + // last touched — the return ticket is the row's whole story. + + {props.snoozeWakeLabelText} + + ) : isWoke ? ( + + + + Woke + + } + /> + Dismiss Woke notification + + ) : ( + + {variantAction === "unsettle" + ? settledTimeLabel(thread) + : threadTimeLabel(thread)} + + )} + + {variantAction === "unsnooze" ? ( + !props.snoozeSupported ? null : ( + + ) + ) : !props.settlementSupported ? null : variantAction === "unsettle" ? ( - ) - ) : !props.settlementSupported ? null : variantAction === "unsettle" ? ( - - - } + ) : ( + - )} - - {props.jumpLabel ? : null} - - {detailsTooltip} - + + + )} + + {props.jumpLabel ? : null} + + {detailsTooltip} + +
  • ); } const diff = latestTurnDiff(thread); - const dnd = props.dnd; return (
  • - {props.dropIndicator ? : null} - - - } - > -
    -
    - + + - {props.projectTitle ? ( - - {props.projectTitle} - - ) : ( - - )} - {pinIndicator} - {/* The visible state owns this slot's width: status at rest, + } + > +
    +
    + + {props.projectTitle ? ( + + {props.projectTitle} + + ) : ( + + )} + {props.isPinned ? ( + props.pinningSupported ? ( + + + } + > + + + Unpin thread + + ) : ( + + ) + ) : null} + {/* The visible state owns this slot's width: status at rest, actions on hover/keyboard focus or while the popover is open. Keeping the hidden state out of flow lets the project label reclaim space without either state overlapping it. */} - - {/* Read-only status labels yield to the hover actions. Woke is + + {/* Read-only status labels yield to the hover actions. Woke is itself an action, so it stays pointer-enabled and visible while the other controls appear beside it. */} - - {topStatus ? ( - isWokeStatus ? ( - - - - {topStatus.label} - - } - /> - Dismiss Woke notification - - ) : ( - - {topStatus.icon === "working" ? ( - - ) : topStatus.icon === "done" ? ( - - ) : null} - {/* The label alone is the live region: a role="status" - wrapper around the ticking duration would make - screen readers announce every second. */} - {topStatus.label} - {status === "working" ? ( - - - - ) : null} - - ) - ) : ( - threadTimeLabel(thread) - )} - - {props.settlementSupported || showSnoozeButton ? ( - {showSnoozeButton ? ( - - ) : null} - {props.settlementSupported ? ( - - - } + {topStatus ? ( + isWokeStatus ? ( + + + + {topStatus.label} + + } + /> + Dismiss Woke notification + + ) : ( + - - Settle - - Settle thread - - ) : null} + {topStatus.icon === "working" ? ( + + ) : topStatus.icon === "done" ? ( + + ) : null} + {/* The label alone is the live region: a role="status" + wrapper around the ticking duration would make + screen readers announce every second. */} + {topStatus.label} + {status === "working" ? ( + + + + ) : null} + + ) + ) : ( + threadTimeLabel(thread) + )} - ) : null} - -
    -
    - {title} - {isRegeneratingTitle ? ( - - Regenerating title - - ) : null} -
    -
    - {/* Always the branch. The plan step used to take this slot while - working, but it truncated to a half-sentence and dropped the - branch, so the row lost its most stable identifier. */} - {thread.branch ? ( - <> - - {thread.branch} - - ) : ( - - )} - {terminalStatusIcon} - {prBadge} - {diff ? ( - - +{diff.insertions}{" "} - −{diff.deletions} + {props.settlementSupported || showSnoozeButton ? ( + + {showSnoozeButton ? ( + + ) : null} + {props.settlementSupported ? ( + + + } + > + + Settle + + Settle thread + + ) : null} + + ) : null} - ) : null} - - {isRemote ? ( - - +
    +
    + {title} + {isRegeneratingTitle ? ( + + Regenerating title ) : null} - {driverKind ? ( - - +
    +
    + {/* Always the branch. The plan step used to take this slot while + working, but it truncated to a half-sentence and dropped the + branch, so the row lost its most stable identifier. */} + {thread.branch ? ( + <> + + + {thread.branch} + + + ) : ( + + )} + {terminalStatusIcon} + {prBadge} + {diff ? ( + + + +{diff.insertions} + {" "} + −{diff.deletions} ) : null} - + + {isRemote ? ( + + + + ) : null} + {driverKind ? ( + + + + ) : null} + +
    -
    - {props.jumpLabel ? : null} - - {detailsTooltip} - + {props.jumpLabel ? : null} + + {detailsTooltip} + +
  • ); }); @@ -2741,9 +2783,7 @@ export default function Sidebar() { planForwardNavigation, isRouteThread, }); - const dragTransaction = threadDnd.transaction; const sidebarViewportRef = threadDnd.viewportRef; - const sidebarViewportOverlayRef = threadDnd.viewportOverlayRef; const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection); const handleMultiSelectContextMenu = useCallback( @@ -3279,23 +3319,6 @@ export default function Sidebar() { setShowJumpHints(shouldShowJumpHintsNow); }, [shouldShowJumpHintsNow]); - const dragPreviewProject = - dragTransaction?.phase === "dragging" - ? { - title: - projectDisplayNameByKey.get( - `${dragTransaction.sourceThread.environmentId}:${dragTransaction.sourceThread.projectId}`, - ) ?? null, - cwd: - projectCwdByKey.get( - `${dragTransaction.sourceThread.environmentId}:${dragTransaction.sourceThread.projectId}`, - ) ?? null, - faviconPath: - projectFaviconPathByKey.get( - `${dragTransaction.sourceThread.environmentId}:${dragTransaction.sourceThread.projectId}`, - ) ?? null, - } - : null; const renderBoardThread = ( thread: EnvironmentThreadShell, section: SidebarDndSection, @@ -3324,9 +3347,8 @@ export default function Sidebar() { } isPinned={section === "pinned"} dnd={state.dnd} - dndDimmed={state.dimmed} + dndDragView={state.dragView} dndInert={state.inert} - dropIndicator={state.dropIndicator} snoozeWakeLabelText={ section === "snoozed" && thread.snoozedUntil != null ? snoozeWakeLabel(thread.snoozedUntil, { now: new Date().toISOString() }) @@ -3413,7 +3435,6 @@ export default function Sidebar() { ) : null} diff --git a/apps/web/src/components/sidebar/SidebarThreadBoard.tsx b/apps/web/src/components/sidebar/SidebarThreadBoard.tsx index 82e896650ee8..db7617493e59 100644 --- a/apps/web/src/components/sidebar/SidebarThreadBoard.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadBoard.tsx @@ -1,14 +1,18 @@ -import { DndContext, DragOverlay, MeasuringStrategy, type DndContextProps } from "@dnd-kit/core"; +import { DndContext, type DndContextProps } from "@dnd-kit/core"; import { restrictToVerticalAxis } from "@dnd-kit/modifiers"; -import { SortableContext, type SortingStrategy } from "@dnd-kit/sortable"; +import { + SortableContext, + verticalListSortingStrategy, + type SortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; import { ChevronDownIcon, PlusIcon } from "lucide-react"; -import { createPortal } from "react-dom"; -import type { ReactNode } from "react"; +import { useMemo, useRef, type CSSProperties, type ReactNode } from "react"; import { cn } from "~/lib/utils"; import type { SidebarDndLayout } from "../../hooks/useSidebarDndLayout"; -import type { SidebarThreadBoardSections } from "../Sidebar.dnd.board"; +import type { SidebarDndBoardEntry } from "../Sidebar.dnd.board"; import { sidebarThreadKey, type SidebarDndPreviewVariant, @@ -16,54 +20,65 @@ import { type SidebarThreadDragTransaction, } from "../Sidebar.dnd.logic"; import { + SidebarThreadDndBoundary, SidebarThreadDndRow, - SidebarThreadDragOverlayContent, - SidebarThreadSectionDropZone, + SIDEBAR_THREAD_DRAG_PRESENTATION_HEIGHT, + type SidebarThreadDndBoundaryBag, type SidebarThreadDndRowBag, + type SidebarThreadDragView, } from "./SidebarThreadDnd"; +import { SidebarThreadDropOutline } from "./SidebarThreadDropOutline"; type SidebarThreadDndContextProps = Pick< DndContextProps, - "sensors" | "collisionDetection" | "onDragStart" | "onDragMove" | "onDragCancel" | "onDragEnd" + | "sensors" + | "collisionDetection" + | "onDragStart" + | "onDragMove" + | "onDragOver" + | "onDragCancel" + | "onDragEnd" >; export interface SidebarThreadRenderState { readonly dnd: SidebarThreadDndRowBag; - readonly dimmed: boolean; + readonly dragView: SidebarThreadDragView | null; readonly inert: boolean; - readonly dropIndicator: "before" | "after" | null; } export interface SidebarThreadBoardDnd { readonly contextProps: SidebarThreadDndContextProps; readonly layout: SidebarDndLayout; readonly transaction: SidebarThreadDragTransaction | null; - readonly sections: SidebarThreadBoardSections; - readonly reorderablePinnedKeys: ReadonlySet; - readonly pinnedSortingStrategy: SortingStrategy; + readonly entries: readonly SidebarDndBoardEntry[]; + readonly threadByKey: ReadonlyMap; readonly optimisticPinnedOrderActive: boolean; - readonly dropIndicator: { - readonly threadKey: string; - readonly edge: "before" | "after"; - } | null; readonly dragPreviewVariant: SidebarDndPreviewVariant | null; + readonly sortingOverIndex: number | null; readonly canDragThread: (thread: EnvironmentThreadShell, source: SidebarDndSection) => boolean; readonly canDropThreadInSection: ( thread: EnvironmentThreadShell, source: SidebarDndSection, destination: SidebarDndSection, ) => boolean; - readonly isTemporarySectionRailVisible: (section: SidebarDndSection) => boolean; +} + +function sortableStyle(bag: { + readonly transform: SidebarThreadDndBoundaryBag["transform"]; + readonly transition: string | undefined; +}): CSSProperties { + return { + transform: CSS.Translate.toString(bag.transform), + transition: bag.transition, + }; } function SidebarThreadShelfHeader(props: { section: "snoozed" | "settled"; count: number; expanded: boolean; - isDropOver: boolean; onToggle: () => void; }) { - if (props.count === 0) return null; const snoozed = props.section === "snoozed"; const label = snoozed ? "Snoozed" : "Settled"; const color = snoozed ? "text-blue-600 dark:text-blue-400" : "text-muted-foreground/50"; @@ -75,34 +90,36 @@ function SidebarThreadShelfHeader(props: { onClick={props.onToggle} aria-expanded={props.expanded} data-testid={`sidebar-${props.section}-shelf-toggle`} - className={cn( - "mb-1 mt-3 flex w-full cursor-pointer items-center gap-2 rounded-md px-2.5 text-left transition-colors", - props.isDropOver && - "bg-sidebar-accent text-sidebar-accent-foreground ring-1 ring-sidebar-ring/50", - )} + className="mb-1 mt-3 flex w-full cursor-pointer items-center gap-2 rounded-md px-2.5 text-left transition-colors" > - + {props.expanded ? label : `${label} (${props.count})`} - +
    ); } +function EmptySectionRail(props: { section: SidebarDndSection; label: string; isOver: boolean }) { + return ( +
    +
    + {props.label} +
    +
    + ); +} + export function SidebarThreadBoard(props: { dnd: SidebarThreadBoardDnd; drafts: ReactNode; @@ -124,197 +141,237 @@ export function SidebarThreadBoard(props: { readonly onToggle: () => void; readonly onShowMore: () => void; }; - dragPreviewProject: { - readonly title: string | null; - readonly cwd: string | null; - readonly faviconPath: string | null; - } | null; }) { const { dnd } = props; - const activeDropTransaction = dnd.transaction?.phase === "dragging" ? dnd.transaction : null; - const sectionDropDisabled = (section: SidebarDndSection) => - activeDropTransaction === null || - !dnd.canDropThreadInSection( - activeDropTransaction.sourceThread, - activeDropTransaction.sourceSection, + const emptyRailVisible = (section: SidebarDndSection) => + dnd.transaction?.emptySections.has(section) === true && + dnd.canDropThreadInSection( + dnd.transaction.sourceThread, + dnd.transaction.sourceSection, section, ); - const renderThread = (thread: EnvironmentThreadShell, section: SidebarDndSection) => { + const pinnedSectionHasThreads = + dnd.transaction === null + ? dnd.entries[1]?.kind === "thread" + : !dnd.transaction.emptySections.has("pinned"); + + const renderBoundary = (entry: Extract) => ( + + {(bag) => { + const railVisible = emptyRailVisible(entry.section); + let content: ReactNode = null; + switch (entry.section) { + case "pinned": + content = railVisible ? ( + + ) : null; + break; + case "regular": + content = railVisible ? ( + + ) : pinnedSectionHasThreads ? ( +
    + ) : null; + break; + case "snoozed": + content = + props.snoozedShelf.threadCount > 0 || railVisible ? ( + + ) : null; + break; + case "settled": + content = + props.settledShelf.threadCount > 0 ? ( + + ) : railVisible ? ( + + ) : null; + break; + default: { + const _exhaustive: never = entry.section; + return _exhaustive; + } + } + return ( +
  • + {content} +
  • + ); + }} + + ); + + const renderThread = ( + entry: Extract, + section: SidebarDndSection, + ) => { + const thread = dnd.threadByKey.get(entry.id) ?? entry.thread; const threadKey = sidebarThreadKey(thread); - const rowVariant = section === "regular" || section === "pinned" ? "card" : "slim"; const dragDisabled = dnd.optimisticPinnedOrderActive || !dnd.canDragThread(thread, section) || (dnd.transaction !== null && dnd.transaction.phase !== "dragging"); - const renderVisualRow = (rowDnd: SidebarThreadDndRowBag) => - props.renderThread(thread, section, { - dnd: rowDnd, - dimmed: dnd.transaction?.sourceThreadKey === threadKey, - inert: - dnd.transaction?.sourceThreadKey === threadKey && dnd.transaction.phase !== "dragging", - dropIndicator: dnd.dropIndicator?.threadKey === threadKey ? dnd.dropIndicator.edge : null, - }); - const rowKey = `${threadKey}:${rowVariant}`; return ( - {renderVisualRow} + {(rowDnd) => + props.renderThread(thread, section, { + dnd: rowDnd, + dragView: + rowDnd.isDragging && + dnd.transaction?.phase === "dragging" && + dnd.dragPreviewVariant !== null + ? { + variant: dnd.dragPreviewVariant, + sourceRect: dnd.transaction.sourceRect, + translation: { + x: rowDnd.transform?.x ?? 0, + y: rowDnd.transform?.y ?? 0, + }, + scrollDeltaY: + (dnd.layout.viewportRef.current?.scrollTop ?? + dnd.transaction.sourceScrollTop) - dnd.transaction.sourceScrollTop, + pointerAnchor: dnd.transaction.pointerAnchor, + } + : null, + inert: + dnd.transaction?.sourceThreadKey === threadKey && + dnd.transaction.phase !== "dragging", + }) + } ); }; - const rail = (section: SidebarDndSection, label: string, isOver: boolean) => ( -
    -
    - {label} -
    -
    - ); - const viewportRailTopBySection = dnd.transaction?.viewportRailTopBySection; - const viewportOverlayHost = dnd.layout.viewportOverlayRef.current; - const isViewportRail = (section: SidebarDndSection) => - viewportOverlayHost !== null && - dnd.isTemporarySectionRailVisible(section) && - viewportRailTopBySection?.has(section) === true; - const renderViewportRail = ( - section: SidebarDndSection, - label: string, - isOver: boolean, - setNodeRef: (node: HTMLElement | null) => void, - ) => { - const top = viewportRailTopBySection?.get(section); - if (top === undefined || viewportOverlayHost === null || !isViewportRail(section)) { - return null; + + let section: SidebarDndSection = "pinned"; + const boardEntries = dnd.entries.map((entry) => { + if (entry.kind === "boundary") { + section = entry.section; + return renderBoundary(entry); } - return createPortal( -
    - {rail(section, label, isOver)} -
    , - viewportOverlayHost, - `sidebar-${section}-viewport-drop-rail`, - ); - }; - const renderSection = ( - section: SidebarDndSection, - label: string, - content: (isOver: boolean) => ReactNode, - ) => ( - - {({ setNodeRef, isOver }) => { - const showRail = dnd.isTemporarySectionRailVisible(section); - const viewportRail = showRail - ? renderViewportRail(section, label, isOver, setNodeRef) - : null; - if (viewportRail !== null) return viewportRail; - return ( -
  • - {content(isOver)} - {showRail ? rail(section, label, isOver) : null} -
  • - ); - }} -
    + return renderThread(entry, section); + }); + const dragSourceHeight = + dnd.transaction?.phase === "dragging" ? dnd.transaction.sourceRect.height : null; + const dragPresentationHeight = + dnd.dragPreviewVariant === null + ? null + : SIDEBAR_THREAD_DRAG_PRESENTATION_HEIGHT[dnd.dragPreviewVariant]; + const sortingStrategy = useMemo( + () => (args) => { + const transform = verticalListSortingStrategy({ + ...args, + overIndex: dnd.sortingOverIndex ?? args.overIndex, + }); + if ( + transform === null || + args.index === args.activeIndex || + dragSourceHeight === null || + dragPresentationHeight === null + ) { + return transform; + } + + const projectedIndex = dnd.sortingOverIndex ?? args.overIndex; + const followsProjectedActive = + projectedIndex < args.activeIndex + ? args.index >= projectedIndex + : args.index > projectedIndex; + if (!followsProjectedActive) return transform; + + const heightDelta = dragPresentationHeight - dragSourceHeight; + return { + ...transform, + y: transform.y + heightDelta, + }; + }, + [dnd.sortingOverIndex, dragPresentationHeight, dragSourceHeight], ); + const listRef = useRef(null); return ( element === dnd.layout.viewportRef.current, }} > -
      +
        {props.drafts} - {renderSection("pinned", "Pinned", () => ( - dnd.reorderablePinnedKeys.has(threadKey))} - strategy={dnd.pinnedSortingStrategy} - > -
          - {dnd.sections.pinned.map((thread) => renderThread(thread, "pinned"))} -
        -
        - ))} - {(dnd.sections.pinned.length > 0 || dnd.isTemporarySectionRailVisible("pinned")) && - !isViewportRail("pinned") ? ( -
      • - ) : null} - {renderSection("regular", "Regular", () => ( -
          - {dnd.sections.regular.map((thread) => renderThread(thread, "regular"))} -
        - ))} - {renderSection("snoozed", "Snooze", (isOver) => ( - <> - -
          - {dnd.sections.snoozed.map((thread) => renderThread(thread, "snoozed"))} -
        - - ))} - {renderSection("settled", "Settled", (isOver) => ( - <> - -
          - {dnd.sections.settled.map((thread) => renderThread(thread, "settled"))} -
        - {props.settledShelf.expanded && props.settledShelf.hiddenCount > 0 ? ( - - ) : null} - - ))} -
      - + entry.id)} strategy={sortingStrategy}> + {boardEntries} + {dnd.transaction?.phase === "dragging" && - dnd.dragPreviewVariant !== null && - props.dragPreviewProject !== null ? ( - ) : null} - + {props.settledShelf.expanded && props.settledShelf.hiddenCount > 0 ? ( +
    • + +
    • + ) : null} +
    ); } diff --git a/apps/web/src/components/sidebar/SidebarThreadDnd.tsx b/apps/web/src/components/sidebar/SidebarThreadDnd.tsx index da39f48b99e7..c2ebc9e4de60 100644 --- a/apps/web/src/components/sidebar/SidebarThreadDnd.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadDnd.tsx @@ -1,18 +1,21 @@ -import { useDroppable } from "@dnd-kit/core"; -import { useSortable } from "@dnd-kit/sortable"; -import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; +import { useSortable, type AnimateLayoutChanges } from "@dnd-kit/sortable"; import { useCallback, useEffect, useLayoutEffect, useRef, type ReactNode } from "react"; -import { cn } from "~/lib/utils"; import { createSidebarDndSectionId, type SidebarDndPreviewVariant, type SidebarDndSection, } from "../Sidebar.dnd.logic"; -import { animatePinnedLayoutChanges } from "../Sidebar.logic"; -import { SidebarThreadDragPreview } from "./SidebarThreadDragPreview"; + +export const SIDEBAR_THREAD_DRAG_PRESENTATION_HEIGHT = { + card: 82, + slim: 36, +} satisfies Readonly>; + +const disableLayoutChanges: AnimateLayoutChanges = () => false; export type SidebarThreadDndRowBag = { + readonly section: SidebarDndSection; readonly listeners: ReturnType["listeners"]; readonly setNodeRef: (node: HTMLElement | null) => void; readonly transform: ReturnType["transform"]; @@ -23,16 +26,17 @@ export type SidebarThreadDndRowBag = { export function SidebarThreadDndRow(props: { threadKey: string; + section: SidebarDndSection; dragDisabled: boolean; - dropDisabled: boolean; - sortable: boolean; - onNodeChange: (threadKey: string, node: HTMLElement | null) => void; + disableLayoutAnimation: boolean; + onNodeChange: (id: string, node: HTMLElement | null) => void; children: (bag: SidebarThreadDndRowBag) => ReactNode; }) { const sortable = useSortable({ id: props.threadKey, - disabled: { draggable: props.dragDisabled, droppable: props.dropDisabled }, - ...(props.sortable ? { animateLayoutChanges: animatePinnedLayoutChanges } : {}), + disabled: { draggable: props.dragDisabled, droppable: false }, + data: { section: props.section }, + ...(props.disableLayoutAnimation ? { animateLayoutChanges: disableLayoutChanges } : {}), }); const setNodeRef = useCallback( (node: HTMLElement | null) => { @@ -48,62 +52,77 @@ export function SidebarThreadDndRow(props: { [props.onNodeChange, props.threadKey], ); return props.children({ + section: props.section, listeners: sortable.listeners, setNodeRef, - transform: props.sortable ? sortable.transform : null, - transition: props.sortable ? sortable.transition : undefined, + transform: sortable.transform, + transition: sortable.transition, isDragging: sortable.isDragging, - isSortable: props.sortable, + isSortable: true, }); } -export function SidebarThreadSectionDropZone(props: { +export interface SidebarThreadDndBoundaryBag { + readonly setNodeRef: (node: HTMLElement | null) => void; + readonly transform: ReturnType["transform"]; + readonly transition: string | undefined; + readonly isOver: boolean; +} + +export function SidebarThreadDndBoundary(props: { section: SidebarDndSection; - disabled: boolean; - children: (bag: { - readonly setNodeRef: (node: HTMLElement | null) => void; - readonly isOver: boolean; - }) => ReactNode; + onNodeChange: (id: string, node: HTMLElement | null) => void; + children: (bag: SidebarThreadDndBoundaryBag) => ReactNode; }) { - const droppable = useDroppable({ - id: createSidebarDndSectionId({ section: props.section }), - disabled: props.disabled, + const id = createSidebarDndSectionId({ section: props.section }); + const sortable = useSortable({ + id, + disabled: { draggable: true, droppable: false }, data: { section: props.section }, }); - return props.children({ setNodeRef: droppable.setNodeRef, isOver: droppable.isOver }); -} - -export function SidebarThreadDropIndicator(props: { edge: "before" | "after" }) { - return ( - + const setNodeRef = useCallback( + (node: HTMLElement | null) => { + sortable.setNodeRef(node); + props.onNodeChange(id, node); + }, + [id, props.onNodeChange, sortable.setNodeRef], ); + useEffect( + () => () => { + props.onNodeChange(id, null); + }, + [id, props.onNodeChange], + ); + return props.children({ + setNodeRef, + transform: sortable.transform, + transition: sortable.transition, + isOver: sortable.isOver, + }); } -export interface SidebarThreadDragOverlayTransaction { - readonly sourceThread: EnvironmentThreadShell; +export interface SidebarThreadDragView { + readonly variant: SidebarDndPreviewVariant; readonly sourceRect: { + readonly top: number; + readonly left: number; readonly width: number; readonly height: number; }; + readonly translation: { + readonly x: number; + readonly y: number; + }; + readonly scrollDeltaY: number; readonly pointerAnchor: { readonly x: number; readonly y: number; }; } -export function SidebarThreadDragOverlayContent(props: { - transaction: SidebarThreadDragOverlayTransaction; - variant: SidebarDndPreviewVariant; - projectTitle: string | null; - projectCwd: string | null; - projectFaviconPath: string | null; +export function SidebarThreadDragMorph(props: { + dragView: SidebarThreadDragView | null; + children: ReactNode; }) { const innerRef = useRef(null); const animationRef = useRef(null); @@ -111,18 +130,28 @@ export function SidebarThreadDragOverlayContent(props: { readonly width: number; readonly height: number; } | null>(null); - const previewHeight = props.variant === "card" ? 82 : 36; - const previewWidth = props.transaction.sourceRect.width; - const left = - props.transaction.pointerAnchor.x * props.transaction.sourceRect.width - - props.transaction.pointerAnchor.x * previewWidth; - const top = - props.transaction.pointerAnchor.y * props.transaction.sourceRect.height - - props.transaction.pointerAnchor.y * previewHeight; + const previewHeight = + props.dragView === null + ? null + : SIDEBAR_THREAD_DRAG_PRESENTATION_HEIGHT[props.dragView.variant]; + const previewWidth = props.dragView?.sourceRect.width ?? null; + const pointerAnchorX = props.dragView?.pointerAnchor.x ?? null; + const pointerAnchorY = props.dragView?.pointerAnchor.y ?? null; useLayoutEffect(() => { const node = innerRef.current; - if (node === null) return; + if ( + node === null || + previewHeight === null || + previewWidth === null || + pointerAnchorX === null || + pointerAnchorY === null + ) { + animationRef.current?.cancel(); + animationRef.current = null; + geometryRef.current = null; + return; + } const nextGeometry = { width: previewWidth, height: previewHeight }; const previousGeometry = geometryRef.current; geometryRef.current = nextGeometry; @@ -138,20 +167,16 @@ export function SidebarThreadDragOverlayContent(props: { const fromHeight = interruptedRect?.height ?? previousGeometry.height; const scaleX = settledRect.width > 0 ? fromWidth / settledRect.width : 1; const scaleY = settledRect.height > 0 ? fromHeight / settledRect.height : 1; - const settledAnchorX = settledRect.left + props.transaction.pointerAnchor.x * settledRect.width; - const settledAnchorY = settledRect.top + props.transaction.pointerAnchor.y * settledRect.height; + const settledAnchorX = settledRect.left + pointerAnchorX * settledRect.width; + const settledAnchorY = settledRect.top + pointerAnchorY * settledRect.height; const translateX = interruptedRect === null ? 0 - : interruptedRect.left + - props.transaction.pointerAnchor.x * interruptedRect.width - - settledAnchorX; + : interruptedRect.left + pointerAnchorX * interruptedRect.width - settledAnchorX; const translateY = interruptedRect === null ? 0 - : interruptedRect.top + - props.transaction.pointerAnchor.y * interruptedRect.height - - settledAnchorY; + : interruptedRect.top + pointerAnchorY * interruptedRect.height - settledAnchorY; animationRef.current = node.animate( [ { @@ -162,37 +187,47 @@ export function SidebarThreadDragOverlayContent(props: { ], { duration: 160, easing: "cubic-bezier(0.2, 0, 0, 1)", fill: "both" }, ); - }, [previewHeight, previewWidth, props.transaction.pointerAnchor]); + }, [pointerAnchorX, pointerAnchorY, previewHeight, previewWidth]); useEffect(() => () => animationRef.current?.cancel(), []); + if ( + props.dragView === null || + previewHeight === null || + previewWidth === null || + pointerAnchorX === null || + pointerAnchorY === null + ) { + return props.children; + } + + const left = pointerAnchorX * props.dragView.sourceRect.width - pointerAnchorX * previewWidth; + const top = pointerAnchorY * props.dragView.sourceRect.height - pointerAnchorY * previewHeight; + return (
    - + {props.children}
    ); diff --git a/apps/web/src/components/sidebar/SidebarThreadDragPreview.tsx b/apps/web/src/components/sidebar/SidebarThreadDragPreview.tsx deleted file mode 100644 index ab6aece505e7..000000000000 --- a/apps/web/src/components/sidebar/SidebarThreadDragPreview.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; -import { GitBranchIcon, MessageSquareIcon } from "lucide-react"; -import { memo } from "react"; - -import { ProjectFavicon } from "../ProjectFavicon"; - -export interface SidebarThreadDragPreviewProps { - readonly thread: EnvironmentThreadShell; - readonly variant: "card" | "slim"; - readonly projectTitle: string | null; - readonly projectCwd: string | null; - readonly projectFaviconPath: string | null; -} - -export const SidebarThreadDragPreview = memo(function SidebarThreadDragPreview( - props: SidebarThreadDragPreviewProps, -) { - const favicon = ( - - ); - - if (props.variant === "slim") { - return ( -
    - {favicon} - - {props.thread.title} - -
    - ); - } - - return ( -
    -
    -
    -
    - {favicon} - {props.projectTitle ? ( - - {props.projectTitle} - - ) : ( - - )} -
    -
    - - {props.thread.title} - -
    -
    - {props.thread.branch ? ( - <> - - - {props.thread.branch} - - - ) : ( - - )} -
    -
    -
    -
    - ); -}); diff --git a/apps/web/src/components/sidebar/SidebarThreadDropOutline.tsx b/apps/web/src/components/sidebar/SidebarThreadDropOutline.tsx new file mode 100644 index 000000000000..b3b0cbd2f673 --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarThreadDropOutline.tsx @@ -0,0 +1,125 @@ +import { getClientRect } from "@dnd-kit/core"; +import { useLayoutEffect, useState, type RefObject } from "react"; + +import { moveSidebarDndBoardThread, type SidebarDndBoardEntry } from "../Sidebar.dnd.board"; +import type { SidebarDndSection, SidebarThreadDropTarget } from "../Sidebar.dnd.logic"; + +interface OutlineGeometry { + readonly top: number; + readonly height: number; +} + +export function SidebarThreadDropOutline(props: { + section: "snoozed" | "settled"; + sourceSection: SidebarDndSection; + sourceThreadKey: string; + entries: readonly SidebarDndBoardEntry[]; + target: SidebarThreadDropTarget; + presentationHeight: number; + listRef: RefObject; + getEntryNode: (id: string) => HTMLElement | null; +}) { + const { + entries, + getEntryNode, + listRef, + presentationHeight, + section, + sourceSection, + sourceThreadKey, + target, + } = props; + const [geometry, setGeometry] = useState(null); + + useLayoutEffect(() => { + const list = listRef.current; + if (list === null) return; + + const sectionEntryIds: string[] = []; + let inSection = false; + for (const entry of entries) { + if (entry.kind === "boundary") { + if (inSection) break; + if (entry.section !== section) continue; + inSection = true; + } + if (inSection) sectionEntryIds.push(entry.id); + } + + const measureEntry = (id: string) => { + const node = getEntryNode(id); + if (node === null) return null; + const rect = getClientRect(node, { ignoreTransform: true }); + const translateY = + sourceSection === section || node.style.transform.length === 0 + ? 0 + : new DOMMatrixReadOnly(node.style.transform).m42; + return { top: rect.top + translateY, bottom: rect.bottom + translateY }; + }; + + const firstRect = measureEntry(sectionEntryIds[0] ?? ""); + if (firstRect === null) { + setGeometry(null); + return; + } + + let bottom = firstRect.bottom; + for (const id of sectionEntryIds.slice(1)) { + const rect = measureEntry(id); + if (rect !== null) bottom = Math.max(bottom, rect.bottom); + } + + if (sourceSection !== section) { + const projectedEntries = moveSidebarDndBoardThread({ + entries, + threadKey: sourceThreadKey, + target, + }); + const activeIndex = projectedEntries.findIndex((entry) => entry.id === sourceThreadKey); + const nextEntry = projectedEntries[activeIndex + 1]; + if (activeIndex >= 1 && (nextEntry === undefined || nextEntry.kind === "boundary")) { + const previousEntry = projectedEntries[activeIndex - 1]; + const previousRect = previousEntry === undefined ? null : measureEntry(previousEntry.id); + if (previousRect !== null) { + bottom = Math.max(bottom, previousRect.bottom + 1 + presentationHeight); + } + } + } + + const listRect = getClientRect(list, { ignoreTransform: true }); + const nextGeometry = { + top: firstRect.top - listRect.top, + height: bottom - firstRect.top, + }; + setGeometry((current) => + current !== null && + Math.abs(current.top - nextGeometry.top) < 0.5 && + Math.abs(current.height - nextGeometry.height) < 0.5 + ? current + : nextGeometry, + ); + }, [ + entries, + getEntryNode, + listRef, + presentationHeight, + section, + sourceSection, + sourceThreadKey, + target, + ]); + + if (geometry === null) return null; + + return ( +
  • + ); +} diff --git a/apps/web/src/components/ui/scroll-area.tsx b/apps/web/src/components/ui/scroll-area.tsx index cc15d91c5ec3..638baf4d6d32 100644 --- a/apps/web/src/components/ui/scroll-area.tsx +++ b/apps/web/src/components/ui/scroll-area.tsx @@ -11,7 +11,6 @@ interface ScrollAreaProps extends ScrollAreaPrimitive.Root.Props { readonly hideScrollbars?: boolean; readonly chainVerticalScroll?: boolean; readonly viewportRef?: Ref | undefined; - readonly viewportOverlayRef?: Ref | undefined; } function getVirtualizedScrollFadeClassName({ top, bottom }: { top: boolean; bottom: boolean }) { @@ -39,7 +38,6 @@ function ScrollArea({ hideScrollbars = false, chainVerticalScroll = false, viewportRef, - viewportOverlayRef, ...props }: ScrollAreaProps) { return ( @@ -62,14 +60,6 @@ function ScrollArea({ > {children} - {viewportOverlayRef !== undefined ? ( -
    - ) : null} {!hideScrollbars && ( <> diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index 75cba8953668..f4f1d0531413 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -689,16 +689,9 @@ function SidebarSeparator({ className, ...props }: React.ComponentProps { readonly fixedHeader?: React.ReactNode; readonly viewportRef?: React.Ref | undefined; - readonly viewportOverlayRef?: React.Ref | undefined; } -function SidebarContent({ - className, - fixedHeader, - viewportRef, - viewportOverlayRef, - ...props -}: SidebarContentProps) { +function SidebarContent({ className, fixedHeader, viewportRef, ...props }: SidebarContentProps) { return ( <> {fixedHeader ?
    {fixedHeader}
    : null} @@ -706,7 +699,6 @@ function SidebarContent({ hideScrollbars scrollFade viewportRef={viewportRef} - viewportOverlayRef={viewportOverlayRef} className="h-auto min-h-0 flex-1" >
    SidebarThreadDragTransaction | null), -) => void; +interface PendingAnchor { + readonly node: HTMLElement; + top: number; +} export interface SidebarDndLayout { readonly viewportRef: RefObject; - readonly viewportOverlayRef: RefObject; - readonly attachListRef: (node: HTMLUListElement | null) => void; - readonly handleThreadRowNodeChange: (threadKey: string, node: HTMLElement | null) => void; - readonly getThreadRowNode: (threadKey: string) => HTMLElement | null; - readonly pauseLayoutMotion: () => void; - readonly retainLayoutAnchor: ( - preferred?: HTMLElement | null, - excludedThreadKey?: string | null, - ) => void; + readonly handleEntryNodeChange: (id: string, node: HTMLElement | null) => void; + readonly getEntryNode: (id: string) => HTMLElement | null; + readonly captureEntryPosition: (id: string | null) => void; +} + +function layoutTop(node: HTMLElement): number { + return getClientRect(node, { ignoreTransform: true }).top; } -export function useSidebarDndLayout(input: { - transaction: SidebarThreadDragTransaction | null; - setTransaction: SidebarThreadDragStateSetter; - pinnedReorderInFlightRef: RefObject; - sectionThreadCounts: Readonly>; - canDropThreadInSection: ( - thread: EnvironmentThreadShell, - source: SidebarDndSection, - destination: SidebarDndSection, - ) => boolean; -}): SidebarDndLayout { - const [autoAnimateRef, setAutoAnimateEnabled] = useAutoAnimate({ - duration: 150, - easing: "ease-out", - }); +export function useSidebarDndLayout(revision: unknown): SidebarDndLayout { const viewportRef = useRef(null); - const viewportOverlayRef = useRef(null); - const threadRowNodesRef = useRef(new Map()); - const layoutAnchorRef = useRef<{ element: HTMLElement; top: number } | null>(null); - const motionPausedRef = useRef(false); + const entryNodesRef = useRef(new Map()); + const pendingAnchorRef = useRef(null); - const handleThreadRowNodeChange = useCallback((threadKey: string, node: HTMLElement | null) => { - if (node === null) threadRowNodesRef.current.delete(threadKey); - else threadRowNodesRef.current.set(threadKey, node); + const handleEntryNodeChange = useCallback((id: string, node: HTMLElement | null) => { + if (node === null) entryNodesRef.current.delete(id); + else entryNodesRef.current.set(id, node); + }, []); + const getEntryNode = useCallback((id: string) => entryNodesRef.current.get(id) ?? null, []); + const captureEntryPosition = useCallback((id: string | null) => { + const node = id === null ? null : (entryNodesRef.current.get(id) ?? null); + pendingAnchorRef.current = + node === null || !node.isConnected ? null : { node, top: layoutTop(node) }; }, []); - const getThreadRowNode = useCallback( - (threadKey: string) => threadRowNodesRef.current.get(threadKey) ?? null, - [], - ); - const visibleAnchor = useCallback( - (preferred: HTMLElement | null, excludedThreadKey: string | null) => { - const viewport = viewportRef.current; - if (viewport === null) return null; - const viewportRect = viewport.getBoundingClientRect(); - const isVisible = (node: HTMLElement) => { - if (!node.isConnected || node.dataset.dndTransformed === "true") return false; - const rect = node.getBoundingClientRect(); - return rect.bottom > viewportRect.top && rect.top < viewportRect.bottom; - }; - if (preferred !== null && isVisible(preferred)) return preferred; - for (const [threadKey, node] of threadRowNodesRef.current) { - if (threadKey !== excludedThreadKey && isVisible(node)) return node; - } - return null; - }, - [], - ); - const retainLayoutAnchor = useCallback( - (preferred: HTMLElement | null = null, excludedThreadKey: string | null = null) => { - const element = visibleAnchor(preferred, excludedThreadKey); - layoutAnchorRef.current = - element === null ? null : { element, top: element.getBoundingClientRect().top }; - }, - [visibleAnchor], - ); - const pauseLayoutMotion = useCallback(() => { - if (motionPausedRef.current) return; - motionPausedRef.current = true; - setAutoAnimateEnabled(false); - }, [setAutoAnimateEnabled]); - - const moveEmptySectionsIntoViewport = useCallback( - (transaction: SidebarThreadDragTransaction) => { - if (transaction.phase !== "dragging" || transaction.viewportRailTopBySection !== null) { - return; - } - const sourceIndex = SIDEBAR_DND_SECTIONS.indexOf(transaction.sourceSection); - const sections = SIDEBAR_DND_SECTIONS.slice(0, sourceIndex).filter( - (section) => - input.sectionThreadCounts[section] === 0 && - input.canDropThreadInSection( - transaction.sourceThread, - transaction.sourceSection, - section, - ), - ); - if (sections.length === 0) return; - input.setTransaction((current) => - current === null || current.sourceThreadKey !== transaction.sourceThreadKey - ? current - : { - ...current, - viewportRailTopBySection: new Map( - sections.map((section, index) => [section, index * EMPTY_SECTION_HEIGHT]), - ), - }, - ); - }, - [input.canDropThreadInSection, input.sectionThreadCounts, input.setTransaction], - ); useLayoutEffect(() => { const viewport = viewportRef.current; - const anchor = layoutAnchorRef.current; - if ( - viewport !== null && - anchor !== null && - anchor.element.isConnected && - anchor.element.dataset.dndTransformed !== "true" - ) { - const delta = anchor.element.getBoundingClientRect().top - anchor.top; - if (Math.abs(delta) > 0.5) { - const requestedScrollTop = viewport.scrollTop + delta; - viewport.scrollTop = requestedScrollTop; - if (Math.abs(viewport.scrollTop - requestedScrollTop) > 0.5 && input.transaction !== null) { - moveEmptySectionsIntoViewport(input.transaction); - } - } - layoutAnchorRef.current = { - element: anchor.element, - top: anchor.element.getBoundingClientRect().top, - }; - } else if (input.transaction !== null) { - retainLayoutAnchor(null, input.transaction.sourceThreadKey); - } + const anchor = pendingAnchorRef.current; + pendingAnchorRef.current = null; + if (viewport === null || anchor === null || !anchor.node.isConnected) return; - if (input.transaction !== null || input.pinnedReorderInFlightRef.current) return; - if (motionPausedRef.current) { - motionPausedRef.current = false; - setAutoAnimateEnabled(true); - } - layoutAnchorRef.current = null; - }); + const delta = layoutTop(anchor.node) - anchor.top; + if (Math.abs(delta) > 0.5) viewport.scrollTop += delta; + }, [revision]); useEffect(() => { - if (input.transaction === null) return; - const sourceThreadKey = input.transaction.sourceThreadKey; const viewport = viewportRef.current; if (viewport === null) return; const handleScroll = () => { - const anchor = layoutAnchorRef.current; - if ( - anchor === null || - !anchor.element.isConnected || - anchor.element.dataset.dndTransformed === "true" - ) { - retainLayoutAnchor(null, sourceThreadKey); - } else { - layoutAnchorRef.current = { - element: anchor.element, - top: anchor.element.getBoundingClientRect().top, - }; - } + const anchor = pendingAnchorRef.current; + if (anchor === null || !anchor.node.isConnected) return; + anchor.top = layoutTop(anchor.node); }; viewport.addEventListener("scroll", handleScroll, { passive: true }); return () => viewport.removeEventListener("scroll", handleScroll); - }, [input.transaction, retainLayoutAnchor]); + }, []); return { viewportRef, - viewportOverlayRef, - attachListRef: autoAnimateRef, - handleThreadRowNodeChange, - getThreadRowNode, - pauseLayoutMotion, - retainLayoutAnchor, + handleEntryNodeChange, + getEntryNode, + captureEntryPosition, }; } diff --git a/apps/web/src/hooks/useSidebarPinnedDnd.ts b/apps/web/src/hooks/useSidebarPinnedDnd.ts index 3b5400d59688..993cbf8b1462 100644 --- a/apps/web/src/hooks/useSidebarPinnedDnd.ts +++ b/apps/web/src/hooks/useSidebarPinnedDnd.ts @@ -1,5 +1,3 @@ -import type { SortingStrategy } from "@dnd-kit/sortable"; -import { verticalListSortingStrategy } from "@dnd-kit/sortable"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; import { @@ -55,7 +53,6 @@ export function useSidebarPinnedDnd(input: { pinnedThreads: readonly EnvironmentThreadShell[]; allPinnedThreads: readonly EnvironmentThreadShell[]; reorderablePinnedKeys: ReadonlySet; - transaction: SidebarThreadDragTransaction | null; reorderPinnedThread: ReturnType["reorderPinnedThread"]; canPinWithOrder: (thread: EnvironmentThreadShell) => boolean; canReorder: (thread: EnvironmentThreadShell) => boolean; @@ -72,38 +69,6 @@ export function useSidebarPinnedDnd(input: { getId: sidebarThreadKey, }); }, [input.pinnedThreads, optimisticPinnedOrder]); - const pinnedSortingOverIndex = useMemo(() => { - const transaction = input.transaction; - if ( - transaction === null || - transaction.phase !== "dragging" || - transaction.sourceSection !== "pinned" || - transaction.target?.section !== "pinned" || - transaction.target.threadKey === null || - transaction.target.edge === null - ) { - return null; - } - const keys = orderedPinnedThreads - .map(sidebarThreadKey) - .filter((threadKey) => input.reorderablePinnedKeys.has(threadKey)); - const previewOrder = movePinnedThreadAtEdge({ - keys, - activeKey: transaction.sourceThreadKey, - overKey: transaction.target.threadKey, - edge: transaction.target.edge, - }); - return previewOrder?.indexOf(transaction.sourceThreadKey) ?? null; - }, [input.reorderablePinnedKeys, input.transaction, orderedPinnedThreads]); - const pinnedSortingStrategy = useCallback( - (args) => - verticalListSortingStrategy({ - ...args, - overIndex: pinnedSortingOverIndex ?? args.overIndex, - }), - [pinnedSortingOverIndex], - ); - useEffect(() => { if (optimisticPinnedOrder === null) return; const canonical = input.pinnedThreads.filter((thread) => @@ -218,7 +183,7 @@ export function useSidebarPinnedDnd(input: { if (targetIndex !== -1) { insertionIndex = targetIndex + (transaction.target.edge === "after" ? 1 : 0); } - } else if (existingKeys.length === 0) { + } else { insertionIndex = 0; } const order = [...existingKeys]; @@ -263,7 +228,6 @@ export function useSidebarPinnedDnd(input: { return { optimisticPinnedOrder, orderedPinnedThreads, - pinnedSortingStrategy, pinnedReorderInFlightRef, handlePinnedReorder, planPinnedInsertion, diff --git a/apps/web/src/hooks/useSidebarThreadDnd.ts b/apps/web/src/hooks/useSidebarThreadDnd.ts index 5f5a2c363a99..c03a07fe0ba7 100644 --- a/apps/web/src/hooks/useSidebarThreadDnd.ts +++ b/apps/web/src/hooks/useSidebarThreadDnd.ts @@ -1,12 +1,15 @@ import { - PointerSensor, closestCenter, + getClientRect, + PointerSensor, pointerWithin, + rectIntersection, useSensor, useSensors, type CollisionDetection, type DragEndEvent, type DragMoveEvent, + type DragOverEvent, type DragStartEvent, } from "@dnd-kit/core"; import { getEventCoordinates } from "@dnd-kit/utilities"; @@ -25,12 +28,16 @@ import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; import { readLocalApi } from "../localApi"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentSnapshotAtom } from "../state/shell"; -import { buildSidebarDndBoardSections } from "../components/Sidebar.dnd.board"; +import { + buildSidebarDndBoardEntries, + findSidebarDndBoardThreadSection, + moveSidebarDndBoardThread, + type SidebarDndBoardEntry, +} from "../components/Sidebar.dnd.board"; import { parseSidebarDndSectionId, resolveSidebarDndAction, resolveSidebarDndPreviewVariant, - sidebarThreadKey, SIDEBAR_DND_SECTIONS, type SidebarDndAction, type SidebarDndSection, @@ -171,7 +178,6 @@ export function useSidebarThreadDnd(input: { pinnedThreads: input.pinnedThreads, allPinnedThreads: input.allPinnedThreads, reorderablePinnedKeys: input.reorderablePinnedKeys, - transaction, reorderPinnedThread: input.actions.reorderPinnedThread, canPinWithOrder, canReorder: canReorderPinnedThread, @@ -179,31 +185,42 @@ export function useSidebarThreadDnd(input: { const { optimisticPinnedOrder, orderedPinnedThreads, - pinnedSortingStrategy, pinnedReorderInFlightRef, handlePinnedReorder, planPinnedInsertion, } = pinnedDnd; - const sectionThreadCounts = { - pinned: input.pinnedThreads.length, - regular: input.activeThreads.length, - snoozed: input.snoozedThreads.length, - settled: input.settledThreads.length, - }; - const layout = useSidebarDndLayout({ - transaction, - setTransaction, - pinnedReorderInFlightRef, - sectionThreadCounts, - canDropThreadInSection, - }); - const { - viewportRef, - viewportOverlayRef, - getThreadRowNode, - pauseLayoutMotion, - retainLayoutAnchor, - } = layout; + const canonicalEntries = useMemo( + () => + buildSidebarDndBoardEntries({ + pinnedThreads: orderedPinnedThreads, + regularThreads: input.activeThreads, + snoozedThreads: input.visibleSnoozedThreads, + settledThreads: input.renderedSettledThreads, + }), + [ + input.activeThreads, + input.renderedSettledThreads, + input.visibleSnoozedThreads, + orderedPinnedThreads, + ], + ); + const canonicalEntriesRef = useRef(canonicalEntries); + canonicalEntriesRef.current = canonicalEntries; + const displayedEntries = transaction?.entries ?? canonicalEntries; + const temporaryRailsVisible = transaction !== null; + const layoutRevision = useMemo( + () => ({ entries: displayedEntries, temporaryRailsVisible }), + [displayedEntries, temporaryRailsVisible], + ); + const layout = useSidebarDndLayout(layoutRevision); + const captureInsertionPosition = useCallback( + (entries: readonly SidebarDndBoardEntry[], threadKey: string) => { + const activeIndex = entries.findIndex((entry) => entry.id === threadKey); + const anchor = entries[activeIndex + 1] ?? entries[activeIndex - 1]; + layout.captureEntryPosition(anchor?.id ?? threadKey); + }, + [layout], + ); const sourceStillMatchesDragStart = useCallback((current: SidebarThreadDragTransaction) => { const source = allThreadByKeyRef.current.get(current.sourceThreadKey); @@ -213,41 +230,28 @@ export function useSidebarThreadDnd(input: { canonicalSectionByThreadKeyRef.current.get(current.sourceThreadKey) === current.sourceSection ); }, []); - const finishTransaction = useCallback( - (options: { excludeSource?: boolean } = {}) => { - const current = transactionRef.current; - snoozeDropEpochRef.current += 1; - if (current?.phase === "awaiting-snooze-choice") { - void readLocalApi()?.contextMenu.close(); - } - pointerCoordinatesRef.current = null; - const preferredThreadKey = - current === null - ? null - : options.excludeSource - ? (current.target?.threadKey ?? null) - : current.sourceThreadKey; - retainLayoutAnchor( - preferredThreadKey === null ? null : getThreadRowNode(preferredThreadKey), - options.excludeSource && current !== null ? current.sourceThreadKey : null, - ); - setTransaction(null); - }, - [getThreadRowNode, retainLayoutAnchor, setTransaction], - ); + const finishTransaction = useCallback(() => { + const current = transactionRef.current; + snoozeDropEpochRef.current += 1; + if (current?.phase === "awaiting-snooze-choice") { + void readLocalApi()?.contextMenu.close(); + } + pointerCoordinatesRef.current = null; + if (current !== null) captureInsertionPosition(current.entries, current.sourceThreadKey); + setTransaction(null); + }, [captureInsertionPosition, setTransaction]); const beginReconciliation = useCallback( (reconciliation: { transaction: SidebarThreadDragTransaction; receiptSequencesByEnvironment: ReadonlyMap; }) => { - retainLayoutAnchor(null, reconciliation.transaction.sourceThreadKey); setTransaction({ ...reconciliation.transaction, phase: "reconciling", receiptSequencesByEnvironment: reconciliation.receiptSequencesByEnvironment, }); }, - [retainLayoutAnchor, setTransaction], + [setTransaction], ); const reportDropFailure = useCallback( ( @@ -465,29 +469,96 @@ export function useSidebarThreadDnd(input: { ], ); - const collisionDetection = useCallback((args) => { - if (args.pointerCoordinates !== null) pointerCoordinatesRef.current = args.pointerCoordinates; - const pointerCollisions = pointerWithin(args); - if (pointerCollisions.length > 0) { - const viewportRailSections = transactionRef.current?.viewportRailTopBySection; - return pointerCollisions.toSorted((left, right) => { - const priority = (id: unknown) => { - const section = parseSidebarDndSectionId(id); - if (section !== null && viewportRailSections?.has(section) === true) return 0; - return section === null ? 1 : 2; - }; - return priority(left.id) - priority(right.id); + const collisionDetection = useCallback( + (args) => { + if (args.pointerCoordinates !== null) pointerCoordinatesRef.current = args.pointerCoordinates; + const current = transactionRef.current; + if (current === null || current.phase !== "dragging") return []; + + const validCandidates = args.droppableContainers.filter((container) => { + if (container.id === args.active.id) return false; + const boundarySection = parseSidebarDndSectionId(container.id); + const targetThreadKey = + boundarySection === null && typeof container.id === "string" ? container.id : null; + const section = + boundarySection ?? + (targetThreadKey === null + ? null + : findSidebarDndBoardThreadSection(current.initialEntries, targetThreadKey)); + if ( + section === null || + !canDropThreadInSection(current.sourceThread, current.sourceSection, section) + ) { + return false; + } + return ( + section !== "pinned" || + targetThreadKey === null || + input.reorderablePinnedKeys.has(targetThreadKey) + ); }); - } - return closestCenter(args); - }, []); + const visualDroppableRects = new Map(args.droppableRects); + let visualTop = Number.POSITIVE_INFINITY; + let visualBottom = Number.NEGATIVE_INFINITY; + let pointerInsideBoardWidth = false; + for (const container of validCandidates) { + if (typeof container.id !== "string") continue; + const node = layout.getEntryNode(container.id); + const rect = node === null ? visualDroppableRects.get(container.id) : getClientRect(node); + if (rect === undefined) continue; + visualDroppableRects.set(container.id, rect); + visualTop = Math.min(visualTop, rect.top); + visualBottom = Math.max(visualBottom, rect.bottom); + if ( + args.pointerCoordinates !== null && + rect.left <= args.pointerCoordinates.x && + args.pointerCoordinates.x <= rect.right + ) { + pointerInsideBoardWidth = true; + } + } + const pointerCollisions = pointerWithin({ + ...args, + droppableContainers: validCandidates, + droppableRects: visualDroppableRects, + }); + if (pointerCollisions.length > 0) { + return pointerCollisions; + } + if (args.pointerCoordinates !== null) { + const { x, y } = args.pointerCoordinates; + const viewport = layout.viewportRef.current; + const hitAreaBottom = viewport === null ? visualBottom : getClientRect(viewport).bottom; + if (!pointerInsideBoardWidth || y < visualTop || y > hitAreaBottom) return []; + return closestCenter({ + ...args, + collisionRect: { + width: 0, + height: 0, + top: y, + bottom: y, + left: x, + right: x, + }, + droppableContainers: validCandidates, + droppableRects: visualDroppableRects, + }); + } + return rectIntersection({ + ...args, + droppableContainers: validCandidates, + droppableRects: visualDroppableRects, + }); + }, + [canDropThreadInSection, input.reorderablePinnedKeys, layout], + ); const handleDragStart = useCallback( (event: DragStartEvent) => { if (pinnedReorderInFlightRef.current) return; if (typeof event.active.id !== "string") return; const threadKey = event.active.id; const sourceThread = allThreadByKeyRef.current.get(threadKey); - const sourceNode = getThreadRowNode(threadKey); + const sourceNode = layout.getEntryNode(threadKey); if (sourceThread === undefined || sourceNode === null) return; const sourceSection = canonicalSectionByThreadKeyRef.current.get(threadKey); if (sourceSection === undefined || !canDragThread(sourceThread, sourceSection)) return; @@ -497,27 +568,26 @@ export function useSidebarThreadDnd(input: { y: sourceRect.top + sourceRect.height / 2, }; pointerCoordinatesRef.current = pointer; - const sections = { - pinned: orderedPinnedThreads, - regular: input.activeThreads, - snoozed: input.visibleSnoozedThreads, - settled: input.renderedSettledThreads, - } satisfies Readonly>; - pauseLayoutMotion(); - retainLayoutAnchor(sourceNode); + const sectionCounts = { + pinned: orderedPinnedThreads.length, + regular: input.activeThreads.length, + snoozed: input.snoozedThreads.length, + settled: input.settledThreads.length, + } satisfies Readonly>; + const initialEntries = canonicalEntriesRef.current; + layout.captureEntryPosition(threadKey); setTransaction({ phase: "dragging", sourceThread, sourceThreadKey: threadKey, sourceSection, - sourceIndex: Math.max( - 0, - sections[sourceSection].findIndex((thread) => sidebarThreadKey(thread) === threadKey), - ), sourceRect: { + top: sourceRect.top, + left: sourceRect.left, width: sourceRect.width, height: sourceRect.height, }, + sourceScrollTop: layout.viewportRef.current?.scrollTop ?? 0, pointerAnchor: { x: sourceRect.width === 0 @@ -528,21 +598,23 @@ export function useSidebarThreadDnd(input: { ? 0.5 : Math.min(1, Math.max(0, (pointer.y - sourceRect.top) / sourceRect.height)), }, + initialEntries, + entries: initialEntries, + emptySections: new Set( + SIDEBAR_DND_SECTIONS.filter((section) => sectionCounts[section] === 0), + ), target: { section: sourceSection, threadKey, edge: null }, receiptSequencesByEnvironment: null, - viewportRailTopBySection: null, }); }, [ canDragThread, - getThreadRowNode, + layout, input.activeThreads, - input.renderedSettledThreads, - input.visibleSnoozedThreads, + input.settledThreads.length, + input.snoozedThreads.length, orderedPinnedThreads, - pauseLayoutMotion, pinnedReorderInFlightRef, - retainLayoutAnchor, setTransaction, ], ); @@ -557,31 +629,26 @@ export function useSidebarThreadDnd(input: { const destination = sectionDrop ?? (targetThreadKey === null - ? undefined - : canonicalSectionByThreadKeyRef.current.get(targetThreadKey)); - if (destination === undefined) return null; + ? null + : findSidebarDndBoardThreadSection(current.initialEntries, targetThreadKey)); + if (destination === null) return null; if (!canDropThreadInSection(current.sourceThread, current.sourceSection, destination)) { return null; } let resolvedThreadKey = targetThreadKey; let targetEdge: "before" | "after" | null = null; - const pointerY = pointerCoordinatesRef.current?.y ?? over.rect.top + over.rect.height / 2; if (resolvedThreadKey !== null) { if (destination === "pinned" && !input.reorderablePinnedKeys.has(resolvedThreadKey)) { return null; } - targetEdge = pointerY < over.rect.top + over.rect.height / 2 ? "before" : "after"; - } else if (destination === "pinned" && orderedPinnedThreads.length > 0) { - const before = pointerY < over.rect.top + over.rect.height / 2; - const target = before ? orderedPinnedThreads[0] : orderedPinnedThreads.at(-1); - if (target !== undefined) { - resolvedThreadKey = sidebarThreadKey(target); - targetEdge = before ? "before" : "after"; - } + const targetNode = layout.getEntryNode(resolvedThreadKey); + const targetRect = targetNode === null ? over.rect : getClientRect(targetNode); + const pointerY = pointerCoordinatesRef.current?.y ?? targetRect.top + targetRect.height / 2; + targetEdge = pointerY < targetRect.top + targetRect.height / 2 ? "before" : "after"; } return { section: destination, threadKey: resolvedThreadKey, edge: targetEdge }; }, - [canDropThreadInSection, input.reorderablePinnedKeys, orderedPinnedThreads], + [canDropThreadInSection, input.reorderablePinnedKeys, layout], ); const updateDragTarget = useCallback( (over: DragMoveEvent["over"]) => { @@ -605,19 +672,21 @@ export function useSidebarThreadDnd(input: { [resolveDropTarget, setTransaction], ); const handleDragEnd = useCallback( - (event: DragEndEvent) => { + (_event: DragEndEvent) => { const current = transactionRef.current; const releasePoint = pointerCoordinatesRef.current; - const target = - current !== null && current.phase === "dragging" - ? resolveDropTarget(current, event.over) - : null; pointerCoordinatesRef.current = null; + const target = current?.target ?? null; if (current === null || current.phase !== "dragging" || target === null) { finishTransaction(); return; } - const finalized = { ...current, target }; + const projectedEntries = moveSidebarDndBoardThread({ + entries: current.initialEntries, + threadKey: current.sourceThreadKey, + target, + }); + const finalized = { ...current, entries: projectedEntries, target }; const action = resolveSidebarDndAction({ source: finalized.sourceSection, destination: finalized.target.section, @@ -626,11 +695,19 @@ export function useSidebarThreadDnd(input: { finishTransaction(); return; } + captureInsertionPosition(projectedEntries, current.sourceThreadKey); + setTransaction(finalized); if (action === "reorder-pinned") { + const firstPinnedThread = finalized.entries.find( + (entry) => + entry.kind === "thread" && + entry.id !== finalized.sourceThreadKey && + findSidebarDndBoardThreadSection(finalized.entries, entry.id) === "pinned", + ); handlePinnedReorder( finalized.sourceThreadKey, - finalized.target.threadKey, - finalized.target.edge, + finalized.target.threadKey ?? firstPinnedThread?.id ?? null, + finalized.target.threadKey === null ? "before" : finalized.target.edge, ); finishTransaction(); return; @@ -648,12 +725,13 @@ export function useSidebarThreadDnd(input: { commitLifecycleDrop(finalized, action, pinnedPlan); }, [ + captureInsertionPosition, commitLifecycleDrop, finishTransaction, handlePinnedReorder, openSnoozeDropMenu, planPinnedInsertion, - resolveDropTarget, + setTransaction, ], ); @@ -669,7 +747,7 @@ export function useSidebarThreadDnd(input: { const snapshot = appAtomRegistry.get(environmentSnapshotAtom(environmentId)); if (snapshot === null || snapshot.snapshotSequence < receiptSequence) return; } - finishTransaction({ excludeSource: true }); + finishTransaction(); }, [finishTransaction, input.threads, transaction]); useLayoutEffect(() => { if ( @@ -690,84 +768,48 @@ export function useSidebarThreadDnd(input: { transaction, ]); - const sections = useMemo( - () => - buildSidebarDndBoardSections({ - pinnedThreads: orderedPinnedThreads, - regularThreads: input.activeThreads, - snoozedThreads: input.visibleSnoozedThreads, - settledThreads: input.renderedSettledThreads, - transaction, - }), - [ - input.activeThreads, - input.renderedSettledThreads, - input.visibleSnoozedThreads, - orderedPinnedThreads, - transaction, - ], - ); - const dropIndicator = - transaction !== null && - transaction.phase !== "reconciling" && - (transaction.sourceSection !== "pinned" || transaction.target?.section !== "pinned") && - transaction.target?.threadKey !== null && - transaction.target?.threadKey !== undefined && - transaction.target.edge !== null - ? { threadKey: transaction.target.threadKey, edge: transaction.target.edge } - : null; - const isTemporarySectionRailVisible = useCallback( - (section: SidebarDndSection) => { - if (transaction === null || transaction.phase === "reconciling") return false; - const sectionIsEmpty = - sections[section].length === 0 && - (section !== "snoozed" || input.snoozedThreads.length === 0) && - (section !== "settled" || input.settledThreads.length === 0); - return ( - sectionIsEmpty && - canDropThreadInSection(transaction.sourceThread, transaction.sourceSection, section) - ); - }, - [ - canDropThreadInSection, - input.settledThreads.length, - input.snoozedThreads.length, - sections, - transaction, - ], - ); const dragPreviewVariant = - transaction?.phase === "dragging" + transaction !== null && transaction.phase === "dragging" ? resolveSidebarDndPreviewVariant({ source: transaction.sourceSection, destination: transaction.target?.section ?? null, }) : null; + const sortingOverIndex = useMemo(() => { + if (transaction === null || transaction.phase !== "dragging" || transaction.target === null) { + return null; + } + const projectedEntries = moveSidebarDndBoardThread({ + entries: transaction.initialEntries, + threadKey: transaction.sourceThreadKey, + target: transaction.target, + }); + const index = projectedEntries.findIndex((entry) => entry.id === transaction.sourceThreadKey); + return index === -1 ? null : index; + }, [transaction]); return { transaction, - viewportRef, - viewportOverlayRef, + viewportRef: layout.viewportRef, boardDnd: { contextProps: { sensors, collisionDetection, onDragStart: handleDragStart, onDragMove: (event: DragMoveEvent) => updateDragTarget(event.over), + onDragOver: (event: DragOverEvent) => updateDragTarget(event.over), onDragCancel: () => finishTransaction(), onDragEnd: handleDragEnd, }, layout, transaction, - sections, - reorderablePinnedKeys: input.reorderablePinnedKeys, - pinnedSortingStrategy, + entries: displayedEntries, + threadByKey: input.allThreadByKey, optimisticPinnedOrderActive: optimisticPinnedOrder !== null, - dropIndicator, dragPreviewVariant, + sortingOverIndex, canDragThread, canDropThreadInSection, - isTemporarySectionRailVisible, }, }; } diff --git a/docs/internals/sidebar-thread-dnd.md b/docs/internals/sidebar-thread-dnd.md index c284a6c0ad31..b5b1a73c9fde 100644 --- a/docs/internals/sidebar-thread-dnd.md +++ b/docs/internals/sidebar-thread-dnd.md @@ -4,29 +4,160 @@ Status: accepted +## Context + +The sidebar renders Pinned, Regular, Snoozed, and Settled as categories, but the user drags through +them as one vertical list. A thread may move between any supported categories. Pinned has manual +ordering. The other categories keep their existing natural order. Pinned and Regular use cards; +Snoozed and Settled use compact rows and may be collapsed or empty. + +The previous implementation represented those categories as separate drag containers. Moving a row +changed one container's height before the next collision pass. Everything below it moved, so the row +under the pointer stopped being the target. Portal rails, target locks, extra indicators, and +category-specific scroll corrections hid individual cases but left several systems changing the same +geometry. + +Later flat-list versions removed the container boundary but still changed the DOM order during every +hover. The active row then changed the collision rectangles used to choose its next position. This +produced oscillation under a stationary pointer and, with continuous measurement, a React update +loop. Flattening the DOM was necessary but not sufficient. Drag-time layout also has to stay stable. + ## Decision -Web and desktop use one sidebar drag-and-drop board for Pinned, Regular, Snooze, and Settled. -Mobile keeps its existing menu actions; it does not add native drag and drop. +Web and desktop use one flat sidebar drag board. Mobile keeps its existing menu actions. + +### One rendered list + +The board has one `DndContext`, one `SortableContext`, and one direct `
      `. The sortable entries are +stable category boundaries followed by their visible thread rows: + +```text +Pinned boundary +Pinned threads +Regular boundary +Regular threads +Snoozed boundary +Snoozed threads +Settled boundary +Settled threads +``` + +Each boundary and thread is a keyed sibling. Boundaries use `useSortable` with dragging disabled and +dropping enabled. The nearest preceding boundary determines a thread's category. + +Drag start snapshots this array. React keeps the snapshot in the same DOM order while the pointer is +down. The current collision produces a semantic target consisting of a category and either a row +edge or category boundary. A small sorting-strategy adapter converts that target to a prospective +index and delegates to dnd-kit's `verticalListSortingStrategy`. When card and compact heights differ, +the adapter applies that fixed height difference to every sibling whose projected position follows +the active row. This sizes the target gap and moves the remaining tail as one projected list. The +active row and affected siblings move with dnd-kit's sortable FLIP behavior without changing +collision rectangles. + +On release, React applies the projected array once. The same projected array remains rendered while +the command is pending. React replaces it with canonical order after reconciliation. + +Collapsed Snoozed and Settled sections omit their canonical rows, but their boundaries remain mounted +and droppable. Empty destinations expose a fixed-height boundary target for the whole transaction. +Drag-over never mounts a new rail, expands a category, or changes pagination. + +The sortable gap is the insertion feedback. There is no separate drop indicator. + +### Geometry ownership + +dnd-kit owns pointer sensing, droppable measurement, collision rectangles, sortable transforms, +auto-scroll, and drop animation. Collision filters out the active row and domain-invalid destinations, +then uses `pointerWithin` followed by `rectIntersection`. The client clears the target when neither +strategy reports a valid collision. + +The strategy adapter contains no DOM measurements, animation state, direction lock, or +category-specific correction. It computes the index that the pure flat-array move would produce, +passes that index to `verticalListSortingStrategy`, then adjusts rows after the projected insertion by +the difference between the measured source height and the card or compact presentation height. The +index adapter is necessary because the domain target distinguishes before, after, and the first slot +after a category boundary, while dnd-kit's strategy receives only an `overIndex`. + +Pointer movement updates only the semantic target and dragged-row variant. It does not change DOM order, +row height, scroll range, or measured rectangles. A layout revision therefore cannot create another +collision under the same sensor event. + +The active sortable row is the pointer visual. Its outer element keeps the dimensions measured at +activation, while the normal `SidebarThreadRow` renderer changes between card and compact layouts +around the captured pointer offset. Dragging does not use a second, simplified copy of the row. +dnd-kit controls the row transform, including scroll adjustment. While dragging, the board ignores +pointer hit-testing so rows under the active row do not show hover actions or tooltips. The active +sensor keeps tracking pointer movement at the document level. + +The client owns one small piece of geometry that dnd-kit does not: viewport anchoring during a real +React layout change. Before such a change, it records one stable entry's untransformed position. +After React commits, it adjusts only the sidebar viewport's `scrollTop` by that entry's layout delta. +The pointer and dragged row do not move. The list moves around them. + +Hover updates need no correction because sortable transforms do not affect layout. Activation anchors +the source while empty rails mount. Drop anchors the first stable entry after the resulting insertion +slot. Transaction teardown uses the same rule when the projected array returns to canonical order. +User scroll and dnd-kit auto-scroll establish a new anchor baseline. The board does not add synthetic +scroll headroom because auto-scroll would expose it as blank space at the viewport edges. + +### Persistence + +Drag start snapshots the rendered flat order. Canonical thread content may update during the drag, +but canonical membership does not rewrite the snapshot. If the source disappears, becomes +archived, loses the needed capability, or leaves the current sidebar scope, the client cancels the +transaction. + +The projected row stays at the dropped position while the existing lifecycle command and shell +projection complete. Reconciliation replaces the projected array with canonical order in one +sortable layout animation. + +Cross-category drops use the existing lifecycle commands. Their deciders atomically clear conflicting +state while pinning, unpinning, settling, un-settling, snoozing, or waking the thread. Drag and drop +adds no command, event, capability, or protocol compatibility path. + +`thread.pin.reorder` remains key-only. Moving into Pinned computes the order keys for the visible +position, then pins the source. Pinned threads preserve that manual order. Regular, Snoozed, and +Settled keep their existing sort rules and ignore the transient insertion index after persistence. + +The client keeps the projected row until each affected environment's shell snapshot reaches its +receipt sequence. Concurrent canonical state wins at reconciliation. + +## Consequences + +There is one immutable drag snapshot, one semantic target, and one projected order. Category rendering +cannot move a collision target while the pointer is down. dnd-kit performs all hover and drop motion. +The sidebar code maps domain rules to an index and preserves the viewport around the few real layout +changes. + +The board must keep boundaries mounted and their dimensions stable for the full transaction. New +sidebar categories must join the same flat order instead of adding another nested sortable context. + +## Rejected alternatives + +### Multiple sortable containers + +Separate containers match the server projection but not the interaction. Moving a row changes later +containers' positions between collision passes and makes the target escape the pointer. + +### Physically reorder on hover + +Changing the flat DOM array on every collision also changes the next collision's rectangles. The +active row can cover the pointer while being excluded from collision, or move a category boundary +away from the pointer. Updating from `onDragMove` instead of `onDragOver` avoids a React feedback loop +but does not remove this geometric feedback. Sortable transforms provide the same visible movement +without changing layout. + +### A separate drag overlay -The category part of a cross-section drop dispatches an existing lifecycle command. The decider makes -the category change atomic by emitting the required cleanup events with it. The categories are -exclusive. Pinning, settling, waking, un-settling, and snoozing clear conflicting state in the same -decision. The implementation adds no new command, event, capability, or protocol compatibility path -for drag and drop. +An overlay duplicates the active row and requires hiding the original copy. Keeping the sortable row +as the pointer visual gives dnd-kit one transform to own. A source-sized outer row isolates the +card-to-compact morph from list geometry. -`thread.pin.reorder` remains separate and key-only. Pinned insertion computes the order keys needed -for the dropped position, then pins the source. Pinned threads use those keys for manual order. -Regular, Snooze, and Settled use their existing sort rules. A cross-section drop into one of those -sections changes state but does not write an arbitrary list index. +### A separate drop indicator -The client holds the source row and layout anchor until each affected environment's shell snapshot -reaches its receipt sequence. This keeps sorted lists from jumping during a drop and lets concurrent -canonical state win once the transaction completes. +The sortable gap already shows the resulting index. Another indicator duplicates the same state. -## Rationale +### Target locks and category-specific corrections -Lifecycle commands already express the state transitions. Making their decisions atomic keeps a -cross-section move understandable to the server and avoids a temporary state where a thread appears -in two sections. Keeping pinned reorder key-only preserves its existing ordering model while the -other sections remain naturally sorted. +Locks, hysteresis, portal rails, and per-category scroll rules preserve stale geometry. They make one +case look stable by changing collision or layout elsewhere. The flat list and one anchor rule remove +the underlying container shift. From 558b7a142d2bbb727c7747a0ea456dccd1d0b78e Mon Sep 17 00:00:00 2001 From: Taras Date: Sun, 23 Aug 2026 02:16:20 +0300 Subject: [PATCH 06/26] fix(web): stabilize sidebar drag transitions --- apps/web/src/components/Sidebar.dnd.logic.ts | 16 + apps/web/src/components/Sidebar.logic.ts | 9 + apps/web/src/components/Sidebar.tsx | 18 +- .../components/sidebar/SidebarThreadBoard.tsx | 162 +++++-- .../components/sidebar/SidebarThreadDnd.tsx | 94 +++- apps/web/src/hooks/useSidebarThreadDnd.ts | 457 ++++++++++++++---- docs/internals/sidebar-thread-dnd.md | 96 ++-- 7 files changed, 654 insertions(+), 198 deletions(-) diff --git a/apps/web/src/components/Sidebar.dnd.logic.ts b/apps/web/src/components/Sidebar.dnd.logic.ts index ba4102770134..d24eecf99662 100644 --- a/apps/web/src/components/Sidebar.dnd.logic.ts +++ b/apps/web/src/components/Sidebar.dnd.logic.ts @@ -31,10 +31,20 @@ export interface SidebarDndPointerAnchor { export type SidebarThreadDragPhase = | "dragging" + | "dropping" | "awaiting-snooze-choice" | "committing" | "reconciling"; +export interface SidebarThreadDropAnimation { + readonly variant: SidebarDndPreviewVariant; + readonly translation: { + readonly x: number; + readonly y: number; + }; + readonly scrollDeltaY: number; +} + export interface SidebarThreadDropTarget { readonly section: SidebarDndSection; readonly threadKey: string | null; @@ -46,6 +56,10 @@ export interface SidebarThreadDragTransaction { readonly sourceThread: EnvironmentThreadShell; readonly sourceThreadKey: string; readonly sourceSection: SidebarDndSection; + readonly dragTranslation: { + readonly x: number; + readonly y: number; + }; readonly sourceRect: { readonly top: number; readonly left: number; @@ -56,8 +70,10 @@ export interface SidebarThreadDragTransaction { readonly pointerAnchor: SidebarDndPointerAnchor; readonly initialEntries: readonly SidebarDndBoardEntry[]; readonly entries: readonly SidebarDndBoardEntry[]; + readonly sectionCounts: Readonly>; readonly emptySections: ReadonlySet; readonly target: SidebarThreadDropTarget | null; + readonly dropAnimation: SidebarThreadDropAnimation | null; readonly receiptSequencesByEnvironment: ReadonlyMap< EnvironmentThreadShell["environmentId"], number diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 747fc07d3daf..f1acad08971a 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -552,6 +552,15 @@ export function sortThreadsForSidebar< ); } +export function sortSnoozedThreadsForSidebar< + T extends { readonly snoozedUntil?: string | null | undefined }, +>(threads: readonly T[]): T[] { + return [...threads].toSorted( + (left, right) => + firstValidTimestampMs(left.snoozedUntil) - firstValidTimestampMs(right.snoozedUntil), + ); +} + // Pinned-reorder key math and the keyed sort live in client-runtime // (state/thread-sort) so web and mobile compute identical pinned orders. export { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 56a288ccebbe..b431c63052cd 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -109,7 +109,6 @@ import { buildThreadActionMenuItems } from "./threadActionMenu.logic"; import { buildBulkTitleRegenerationContextMenuItem, formatWorkingDurationLabel, - firstValidTimestampMs, hasUnseenCompletion, isSidebarNestedLinkClick, isTrailingDoubleClick, @@ -122,6 +121,7 @@ import { resolveWorkingStartedAt, sortLogicalProjectsForSidebar, sortPinnedThreadsForSidebar, + sortSnoozedThreadsForSidebar, sortSettledThreadsForSidebar, sortThreadsForSidebar, } from "./Sidebar.logic"; @@ -1212,7 +1212,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ) : null; const dnd = props.dnd; - const dragView = dnd?.isDragging ? props.dndDragView : null; + const dragView = props.dndDragView; if (variant === "slim") { return ( @@ -1229,7 +1229,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ? { transform: dragView === null ? CSS.Translate.toString(dnd.transform) : undefined, transition: dragView === null ? dnd.transition : undefined, - height: dragView?.sourceRect.height, + height: dragView?.flowPlaceholderHeight, } : undefined } @@ -1237,7 +1237,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { onPointerDownCapture={dnd ? handleDndPointerDownCapture : undefined} className={cn( "relative list-none", - dragView === null && "[content-visibility:auto] [contain-intrinsic-size:auto_34px]", + dragView === null && "[content-visibility:auto] [contain-intrinsic-size:auto_36px]", dnd && "touch-pan-y cursor-grab active:cursor-grabbing", dragView !== null && "z-20 cursor-grabbing", props.dndInert && "pointer-events-none", @@ -1394,7 +1394,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ? { transform: dragView === null ? CSS.Translate.toString(dnd.transform) : undefined, transition: dragView === null ? dnd.transition : undefined, - height: dragView?.sourceRect.height, + height: dragView?.flowPlaceholderHeight, } : undefined } @@ -1403,7 +1403,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { className={cn( "relative list-none", dragView === null - ? "py-0.5 [content-visibility:auto] [contain-intrinsic-size:auto_96px]" + ? "py-0.5 [content-visibility:auto] [contain-intrinsic-size:auto_78px]" : "z-20 cursor-grabbing", dnd && "touch-pan-y cursor-grab active:cursor-grabbing", props.dndInert && "pointer-events-none", @@ -2143,11 +2143,7 @@ export default function Sidebar() { ), activeThreads: sortThreadsForSidebar(active), // Soonest wake first: "what comes back next" is the shelf's question. - snoozedThreads: snoozed.toSorted( - (left, right) => - firstValidTimestampMs(left.snoozedUntil ?? null) - - firstValidTimestampMs(right.snoozedUntil ?? null), - ), + snoozedThreads: sortSnoozedThreadsForSidebar(snoozed), settledThreads: sortSettledThreadsForSidebar(settled), snoozeNow: preciseNow, }; diff --git a/apps/web/src/components/sidebar/SidebarThreadBoard.tsx b/apps/web/src/components/sidebar/SidebarThreadBoard.tsx index db7617493e59..229ca000f263 100644 --- a/apps/web/src/components/sidebar/SidebarThreadBoard.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadBoard.tsx @@ -1,5 +1,5 @@ import { DndContext, type DndContextProps } from "@dnd-kit/core"; -import { restrictToVerticalAxis } from "@dnd-kit/modifiers"; +import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers"; import { SortableContext, verticalListSortingStrategy, @@ -55,6 +55,7 @@ export interface SidebarThreadBoardDnd { readonly optimisticPinnedOrderActive: boolean; readonly dragPreviewVariant: SidebarDndPreviewVariant | null; readonly sortingOverIndex: number | null; + readonly completeDropAnimation: () => void; readonly canDragThread: (thread: EnvironmentThreadShell, source: SidebarDndSection) => boolean; readonly canDropThreadInSection: ( thread: EnvironmentThreadShell, @@ -77,6 +78,7 @@ function SidebarThreadShelfHeader(props: { section: "snoozed" | "settled"; count: number; expanded: boolean; + setDroppableNodeRef: (node: HTMLElement | null) => void; onToggle: () => void; }) { const snoozed = props.section === "snoozed"; @@ -86,6 +88,7 @@ function SidebarThreadShelfHeader(props: { return (
      + } + /> + Dismiss Woke notification + + ) : ( + + {variantAction === "unsettle" + ? settledTimeLabel(thread) + : threadTimeLabel(thread)} + + )} - {title} - {pinIndicator} - {terminalStatusIcon} - {isRegeneratingTitle ? ( - - Regenerating title - - ) : null} - {/* The PR badge stays outside the hover-fading slot: it must - remain visible AND clickable while the row is hovered. Only - the time/jump label yields to the settle affordance. */} - {prBadge} - - - {variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? ( - // Snoozed rows show when they come BACK, not when they were - // last touched — the return ticket is the row's whole story. - - {props.snoozeWakeLabelText} - - ) : isWoke ? ( - - - - Woke - - } - /> - Dismiss Woke notification - - ) : ( - - {variantAction === "unsettle" - ? settledTimeLabel(thread) - : threadTimeLabel(thread)} - - )} - - {variantAction === "unsnooze" ? ( - !props.snoozeSupported ? null : ( - - ) - ) : !props.settlementSupported ? null : variantAction === "unsettle" ? ( + {variantAction === "unsnooze" ? ( + !props.snoozeSupported ? null : ( - ) : ( - - )} - - {props.jumpLabel ? : null} - - {detailsTooltip} - - - + ) + ) : !props.settlementSupported ? null : variantAction === "unsettle" ? ( + + ) : ( + + )} + + {props.jumpLabel ? : null} + + {detailsTooltip} + + ); } const diff = latestTurnDiff(thread); return ( - +
      + {props.jumpLabel ? : null} + + {detailsTooltip} + + ); }); diff --git a/apps/web/src/components/sidebar/SidebarThreadBoard.tsx b/apps/web/src/components/sidebar/SidebarThreadBoard.tsx index ac79ae7f472c..ff5c21c666b4 100644 --- a/apps/web/src/components/sidebar/SidebarThreadBoard.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadBoard.tsx @@ -29,7 +29,6 @@ import { SidebarThreadDndBoundary, SidebarThreadDndRow, SIDEBAR_THREAD_DRAG_PRESENTATION_HEIGHT, - type SidebarThreadDndBoundaryBag, type SidebarThreadDndRowBag, type SidebarThreadDragView, } from "./SidebarThreadDnd"; @@ -54,7 +53,7 @@ export interface SidebarThreadRenderState { readonly inert: boolean; } -export interface SidebarThreadBoardDnd { +interface SidebarThreadBoardDnd { readonly contextProps: SidebarThreadDndContextProps; readonly layout: SidebarDndLayout; readonly transaction: SidebarThreadDragTransaction | null; @@ -72,16 +71,6 @@ export interface SidebarThreadBoardDnd { ) => boolean; } -function sortableStyle(bag: { - readonly transform: SidebarThreadDndBoundaryBag["transform"]; - readonly transition: string | undefined; -}): CSSProperties { - return { - transform: CSS.Translate.toString(bag.transform), - transition: bag.transition, - }; -} - function SidebarThreadShelfHeader(props: { section: "snoozed" | "settled"; count: number; @@ -268,7 +257,10 @@ export function SidebarThreadBoard(props: { ref={bag.setNodeRef} data-sidebar-thread-section-boundary={entry.section} className={cn("relative list-none", content === null && "h-0")} - style={sortableStyle(bag)} + style={{ + transform: CSS.Translate.toString(bag.transform), + transition: bag.transition, + }} > {content} diff --git a/apps/web/src/components/sidebar/SidebarThreadDnd.tsx b/apps/web/src/components/sidebar/SidebarThreadDnd.tsx index c8a8cc56b10b..64a75f7b7d0a 100644 --- a/apps/web/src/components/sidebar/SidebarThreadDnd.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadDnd.tsx @@ -1,5 +1,15 @@ import { useSortable, type AnimateLayoutChanges } from "@dnd-kit/sortable"; -import { useCallback, useEffect, useLayoutEffect, useRef, type ReactNode } from "react"; +import { CSS } from "@dnd-kit/utilities"; +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + type PointerEvent as ReactPointerEvent, + type ReactNode, +} from "react"; + +import { cn } from "~/lib/utils"; import { createSidebarDndSectionId, @@ -62,7 +72,7 @@ export function SidebarThreadDndRow(props: { }); } -export interface SidebarThreadDndBoundaryBag { +interface SidebarThreadDndBoundaryBag { readonly setNodeRef: (node: HTMLElement | null) => void; readonly setDroppableNodeRef: (node: HTMLElement | null) => void; readonly transform: ReturnType["transform"]; @@ -108,7 +118,54 @@ export interface SidebarThreadDragView { readonly pointerAnchor: { readonly x: number; readonly y: number }; } -export function SidebarThreadDragMorph(props: { +export function SidebarThreadDndShell(props: { + threadKey: string; + variant: SidebarDndPreviewVariant; + dnd: SidebarThreadDndRowBag | undefined; + dragView: SidebarThreadDragView | null; + hidden: boolean; + inert: boolean; + onPointerDownCapture: (event: ReactPointerEvent) => void; + children: ReactNode; +}) { + const { dnd, dragView } = props; + return ( +
    • + {props.children} +
    • + ); +} + +function SidebarThreadDragMorph(props: { dragView: SidebarThreadDragView | null; children: ReactNode; }) { diff --git a/apps/web/src/hooks/useSidebarDndLayout.ts b/apps/web/src/hooks/useSidebarDndLayout.ts index 87cb60042fd1..67035146517f 100644 --- a/apps/web/src/hooks/useSidebarDndLayout.ts +++ b/apps/web/src/hooks/useSidebarDndLayout.ts @@ -12,10 +12,6 @@ export interface SidebarDndLayout { readonly captureEntryPosition: (id: string | null) => void; } -function visualTop(node: HTMLElement): number { - return node.getBoundingClientRect().top; -} - export function useSidebarDndLayout(revision: unknown): SidebarDndLayout { const viewportRef = useRef(null); const entryNodesRef = useRef(new Map()); @@ -29,7 +25,7 @@ export function useSidebarDndLayout(revision: unknown): SidebarDndLayout { const captureEntryPosition = useCallback((id: string | null) => { const node = id === null ? null : (entryNodesRef.current.get(id) ?? null); pendingAnchorRef.current = - node === null || !node.isConnected ? null : { node, top: visualTop(node) }; + node === null || !node.isConnected ? null : { node, top: node.getBoundingClientRect().top }; }, []); useLayoutEffect(() => { @@ -38,7 +34,7 @@ export function useSidebarDndLayout(revision: unknown): SidebarDndLayout { pendingAnchorRef.current = null; if (viewport === null || anchor === null || !anchor.node.isConnected) return; - const delta = visualTop(anchor.node) - anchor.top; + const delta = anchor.node.getBoundingClientRect().top - anchor.top; if (Math.abs(delta) > 0.5) viewport.scrollTop += delta; }, [revision]); @@ -48,7 +44,7 @@ export function useSidebarDndLayout(revision: unknown): SidebarDndLayout { const handleScroll = () => { const anchor = pendingAnchorRef.current; if (anchor === null || !anchor.node.isConnected) return; - anchor.top = visualTop(anchor.node); + anchor.top = anchor.node.getBoundingClientRect().top; }; viewport.addEventListener("scroll", handleScroll, { passive: true }); return () => viewport.removeEventListener("scroll", handleScroll); diff --git a/docs/internals/sidebar-thread-dnd.md b/docs/internals/sidebar-thread-dnd.md index d32712c74ecc..5eb54af8582d 100644 --- a/docs/internals/sidebar-thread-dnd.md +++ b/docs/internals/sidebar-thread-dnd.md @@ -6,30 +6,30 @@ Status: accepted ## Context -The sidebar renders Pinned, Regular, Snoozed, and Settled as categories, but the user drags through -them as one vertical list. A thread may move between any supported categories. Pinned has manual -ordering. The other categories keep their existing natural order. Pinned and Regular use cards; -Snoozed and Settled use compact rows and may be collapsed or empty. - -The previous implementation represented those categories as separate drag containers. Moving a row -changed one container's height before the next collision pass. Everything below it moved, so the row -under the pointer stopped being the target. Portal rails, target locks, extra indicators, and -category-specific scroll corrections hid individual cases but left several systems changing the same -geometry. - -Later flat-list versions removed the container boundary but still changed the DOM order during every -hover. The active row then changed the collision rectangles used to choose its next position. This -produced oscillation under a stationary pointer and, with continuous measurement, a React update -loop. Flattening the DOM was necessary but not sufficient. Drag-time layout also has to stay stable. +The sidebar shows Pinned, Regular, Snoozed, and Settled as categories, but the user drags through +them as one vertical list. Pinned has manual ordering. The other categories keep their existing +sort order. Pinned and Regular use cards; Snoozed and Settled use compact rows and may be collapsed +or empty. + +Earlier implementations used separate drag containers or changed the rendered array on every +hover. Both approaches changed category geometry before the next collision pass. The target moved +away from a stationary pointer, which caused oscillation, cursor drift, scroll jumps, and, with +continuous measurement, a React update loop. + +The interaction needs two stable points: + +- The point grabbed inside the dragged row stays under the pointer while the row changes shape. +- The category or row under the pointer stays in the same viewport position when real layout + changes occur. ## Decision -Web and desktop use one flat sidebar drag board. Mobile keeps its existing menu actions. +Web and desktop use one flat drag board. Mobile keeps its menu actions. -### One rendered list +### One sortable board -The board has one `DndContext`, one `SortableContext`, and one direct `
        `. The sortable entries are -stable category boundaries followed by their visible thread rows: +The board has one `DndContext`, one `SortableContext`, and one direct `
          `. Its sortable entries +are stable category boundaries followed by visible thread rows: ```text Pinned boundary @@ -42,170 +42,147 @@ Settled boundary Settled threads ``` -Each boundary and thread is a keyed sibling. Boundaries use `useSortable` with dragging disabled and -dropping enabled. The nearest preceding boundary determines a thread's category. - -Drag start snapshots this array. React keeps the snapshot in the same DOM order while the pointer is -down. Pinned reorder uses dnd-kit's row collision and `overIndex` directly. Cross-category collision -produces a semantic target consisting of a category and either a row edge or category boundary. A -small sorting-strategy adapter converts that target to a prospective index and delegates to dnd-kit's -`verticalListSortingStrategy`. When card and compact heights differ, the adapter applies that fixed -height difference to every sibling whose projected position follows the active row. This sizes the -target gap and moves the remaining tail as one projected list. The active row and affected siblings -move with dnd-kit's sortable FLIP behavior without changing collision rectangles. - -On release, Pinned keeps the hovered insertion target because its order is manual. Regular and -Settled replace the hover target with the result of their normal sidebar sorter. Snoozed waits for the -duration choice, then sorts using the selected wake time. React applies that projected natural order -once. The active row keeps its fixed pointer presentation while its new in-flow position acts as an -invisible target slot. The client measures both viewport rectangles and animates the active row into -that slot. dnd-kit FLIP-animates every other row affected by the sort. The same projected array remains -rendered while the command is pending. React replaces it with canonical order after reconciliation. - -Collapsed Snoozed and Settled sections omit their canonical rows, but their boundaries remain mounted -and droppable. Empty destinations expose a fixed-height boundary target while the pointer is down. -On release, the projected row and normal category header replace that temporary target before the -client measures the drop slot. Drag-over never changes pagination. - -The sortable gap is the insertion feedback. There is no separate drop indicator. - -### Geometry ownership - -dnd-kit owns pointer sensing, droppable measurement, collision rectangles, sortable transforms, and -auto-scroll. Within Pinned, same-category reorder keeps the active row in the collision set and uses -`closestCenter`, matching dnd-kit's normal sortable-list behavior. The dragged card changes slots when -its center becomes closer to another row instead of when the pointer crosses the target row's -midpoint. Other paths filter out the active row and domain-invalid destinations, then use -`pointerWithin` followed by `rectIntersection`. The client clears the target when neither strategy -reports a valid collision. -Each category boundary uses its wrapper for sortable movement and registers its visible header, -divider, or empty rail as the droppable node. Gaps between visible droppables resolve to the closest -target so the sortable projection cannot alternate between a row and no target. Snoozed and Settled -own collisions only after the center of dnd-kit's constrained active rectangle crosses their visible -header. A card can overlap a shelf while the last slot in the preceding category remains reachable. -After a shelf owns the collision, normal pointer and closest-center selection chooses its row. When -the pointer leaves the viewport vertically but remains within the board width, `closestCenter` uses -the same constrained active rectangle. The top and bottom insertion slots therefore remain sortable. - -The strategy adapter contains no DOM measurements, animation state, direction lock, or -category-specific correction. For cross-category moves it computes the index that the pure flat-array -move would produce and passes that index to `verticalListSortingStrategy`. It adjusts only the rows -between the source and projected index so they move by the card or compact presentation height rather -than dnd-kit's measured active height. When a compact source grows into a card above its source slot, -the adapter also moves the rows after that slot by the height difference. This prevents overlap -without changing the source placeholder or any collision rectangle. Same-category Pinned reorder -bypasses the projected index and uses dnd-kit's `overIndex`. The adapter is necessary elsewhere -because the domain target distinguishes before, after, and the first slot after a category boundary, -while dnd-kit's strategy receives only an `overIndex`. - -Pointer movement updates only the semantic target, dragged-row variant, and dragged-row translation. -It does not change DOM order, row height, scroll range, or measured rectangles. The -`SortableContext.items` array keeps the same identity while the snapshot is unchanged; otherwise -dnd-kit disables transitions for one frame on every pointer update. A layout revision therefore -cannot create another collision under the same sensor event. - -The active sortable row is the pointer visual. Its outer element keeps the dimensions measured at -activation. The normal `SidebarThreadRow` renderer changes between card and compact layouts around the -captured pointer offset. Dragging does not use a second, simplified copy of the row. -The fixed child derives its translation from dnd-kit's pointer coordinates and the activation pointer -captured inside the source rectangle. List layout and scroll offsets never enter that translation. -The source-sized outer rectangle clamps the visual only at the sidebar viewport edges. The fixed card -cannot increase the list's scroll range. The board ignores pointer hit-testing so rows under the -active row do not show hover actions or tooltips. The active sensor keeps tracking pointer movement at -the document level, while dnd-kit still owns collision, auto-scroll, and surrounding sortable -transforms. - -dnd-kit's derived layout transform is disabled only for an active row that crosses categories. Its -source placeholder is not the card's visible release position, so that transform would replay a move -from the source category. A short client-side FLIP instead measures the fixed card and its projected -target slot, then animates between those exact viewport rectangles. Surrounding rows continue to use -dnd-kit's sortable FLIP. The lifecycle command starts after this visual handoff, and reduced-motion -clients complete it immediately. - -The client owns one small piece of geometry that dnd-kit does not: viewport anchoring during a real -React layout change. Before such a change, it records one stable entry's visible viewport position, -including its current sortable transform. After React commits, it adjusts only the sidebar -viewport's `scrollTop` by that entry's visual delta. The thread content disables native browser scroll -anchoring so the browser and the client cannot both correct the same change. The pointer and dragged -row do not move. The list moves around them. - -Hover updates need no correction because sortable transforms do not affect layout. Activation anchors -the source while empty rails mount. Drop anchors the first stable entry after the resulting insertion -slot. Transaction teardown uses the same rule when the projected array returns to canonical order. -User scroll and dnd-kit auto-scroll establish a new anchor baseline. The board does not add synthetic -scroll headroom because auto-scroll would expose it as blank space at the viewport edges. - -Pinned reorder uses the same release-to-slot handoff as a cross-category drop, then commits its -optimistic order without viewport correction. It keeps the same rows, category structure, and total -height, so surrounding rows can FLIP without changing `scrollTop`. +Every boundary and row is a keyed sibling. Boundaries use `useSortable` with dragging disabled. +Their visible divider, header, or empty rail is the droppable node. The nearest preceding boundary +defines a row's category. -### Persistence +Drag start snapshots the rendered entries. React keeps that order while the pointer is down. Hover +updates only the semantic target and the dragged presentation. dnd-kit moves surrounding rows with +sortable transforms, so collision rectangles and scroll height stay stable. + +The transaction is a discriminated union. `dragging` may have no target. Later phases require a +target. `dropping` stores the resolved action, `committing` stores only a command action, and only +`reconciling` stores receipt sequences. The transaction also owns the starting sidebar scope and a +selected snooze preset, so those values cannot drift in parallel refs. + +### Pointer visual and morph + +dnd-kit's `DragOverlay` is the pointer visual. It renders the same `SidebarThreadRow` component as +the list, with interaction disabled. The source row stays in the list at zero opacity, preserving +its measured slot without showing a duplicate. + +Drag start records the pointer's normalized position inside the source row. The overlay keeps that +point fixed while its inner content morphs between the card and compact presentations. The morph is +a short FLIP animation around the captured pointer origin. It never changes list geometry. + +The overlay uses dnd-kit's transform, vertical-axis restriction, and first-scrollable-ancestor +restriction. Rows below it ignore pointer events, so their hover controls and tooltips do not open +during a drag. + +### Collision ownership + +dnd-kit owns sensing, droppable measurement, sortable transforms, auto-scroll, and collision +ranking. The client adds only the category rules that dnd-kit cannot infer. + +The overlay may change height, but category ownership always uses a collision rectangle rebuilt +from the original source size and captured pointer position. Morphing therefore cannot select the +category that controls its own size. -Drag start snapshots the rendered flat order. Canonical thread content may update during the drag, -but canonical membership does not rewrite the snapshot. If the source disappears, becomes -archived, loses the needed capability, or leaves the current sidebar scope, the client cancels the -transaction. +The visible top of each category defines its boundary. The cursor crossing the thin Regular divider +switches between Pinned and Regular. This makes pinning and unpinning symmetric at the divider. +Snoozed and Settled take ownership only after the source row center crosses their visible header, +so touching a shelf does not steal the last Regular slot. -The projected row stays at the dropped position while the existing lifecycle command and shell -projection complete. Reconciliation replaces the projected array with canonical order in one -sortable layout animation. The drop handoff is client state and adds no server round trip. +After one category owns the drag, invalid destinations are removed. `pointerWithin` chooses an exact +row or boundary when possible; `closestCenter` fills the gaps between visible droppables. The active +row remains a candidate, which preserves dnd-kit's normal sortable behavior inside Pinned. Leaving +the board width clears the target. -A Snooze drop keeps the hovered compact slot in flow while the standard duration menu is open. The -fixed card remains at its release position above that slot. Choosing a duration moves the projected -row to its naturally sorted slot before the drop handoff; cancelling restores the source order. The -viewport anchor applies to both changes, so opening or closing the menu does not collapse the space -under the card. +### Sortable projection and FLIP -Cross-category drops use the existing lifecycle commands. Their deciders atomically clear conflicting -state while pinning, unpinning, settling, un-settling, snoozing, or waking the thread. Drag and drop -adds no command, event, capability, or protocol compatibility path. +Pinned keeps the hovered before or after edge because its order is manual. Regular and Settled +resolve the source through their normal sidebar sorters on release. Snoozed resolves its natural +position after the user chooses a wake time. + +While dragging, a small sorting-strategy adapter turns the semantic target into a prospective flat +index and delegates to `verticalListSortingStrategy`. It adjusts dnd-kit's displacement only when +the card and compact heights differ. This opens a correctly sized target slot while the source row +continues to occupy its original slot. Same-category Pinned reorder uses dnd-kit's `overIndex` +without that projected-index override. + +On release, React renders the projected target order. The destination copy remains invisible and +acts as the overlay's drop target. dnd-kit's `DragOverlay` drop animation moves the visible row from +its release rectangle to that target. Other rows use dnd-kit's sortable FLIP. The destination copy +disables its own cross-category layout transition so it does not replay a second source-to-target +move. The lifecycle command starts after the overlay animation completes. Reduced-motion clients +complete the handoff immediately. + +The sortable gap is the insertion feedback. There is no separate line indicator. Snoozed and +Settled use an absolutely positioned category outline during hover; it takes no list space. + +### Viewport stability + +dnd-kit auto-scroll is limited to the sidebar viewport and uses its vertical layout-shift +compensation. The board does not add synthetic scroll headroom, which would expose blank space at +the viewport edges. + +Some React changes still affect real layout: empty rails appear at activation, projected entries +mount on release, and canonical entries replace the projection after reconciliation. Before those +changes, the client records one stable entry's viewport position. After React commits, it changes +only the sidebar `scrollTop` by that entry's visual delta. The content disables native scroll +anchoring so the browser and the client do not both compensate. User scroll and dnd-kit auto-scroll +update the anchor baseline. + +Pinned reorder does not need manual viewport correction after release. Its membership, category +structure, and total height stay unchanged, so dnd-kit's layout animation is sufficient. + +### Persistence + +Cross-category drops use the existing lifecycle commands. Their deciders emit the primary event and +any events needed to clear conflicting pinned, settled, or snoozed state in one decision. Drag and +drop adds no command, event, or compatibility path. `thread.pin.reorder` remains key-only. Moving into Pinned computes the order keys for the visible -position, then pins the source. Pinned threads preserve that manual order. Regular, Snoozed, and -Settled use the same sort functions for drop projection and canonical rendering, so reconciliation -does not introduce a second unsignalled move. +position, prepares any neighbor keys, then pins the source with its key. Pinned preserves that +manual order. The other categories use the same sort functions for drop projection and canonical +rendering. + +A Snoozed drop holds the projected compact slot while the standard duration menu is open. Choosing +a duration sorts the row by wake time before the drop animation. Cancelling restores the source +order. -The client keeps the projected row until each affected environment's shell snapshot reaches its -receipt sequence. Concurrent canonical state wins at reconciliation. +The projected row remains rendered while the command is pending. Reconciliation ends only after +each affected environment's shell snapshot reaches its receipt sequence. Canonical state then wins. +If the source disappears, becomes archived, loses the needed capability, or leaves the starting +sidebar scope during an interactive phase, the client cancels the transaction. ## Consequences -There is one immutable drag snapshot, one semantic target, and one projected order. Category rendering -cannot move a collision target while the pointer is down. dnd-kit performs hover motion and surrounding -layout FLIP; the client performs only the active card's final rectangle-to-rectangle handoff. The -sidebar code maps domain rules to an index and preserves the viewport around the few real layout -changes. +There is one drag snapshot, one semantic target, and one projected order. dnd-kit owns pointer +movement, hover sorting, auto-scroll, surrounding-row FLIP, and the final overlay drop animation. +The client owns category semantics, the card-to-compact morph, and one viewport-anchor correction +for real layout changes. -The board must keep boundaries mounted and their dimensions stable for the full transaction. New -sidebar categories must join the same flat order instead of adding another nested sortable context. +New sidebar categories must join the same flat order. They must define a visible ownership boundary +and use the same transaction and viewport rules. ## Rejected alternatives ### Multiple sortable containers -Separate containers match the server projection but not the interaction. Moving a row changes later +Separate containers match the projected data but not the interaction. Moving a row changes later containers' positions between collision passes and makes the target escape the pointer. -### Physically reorder on hover +### Physically reorder on every hover -Changing the flat DOM array on every collision also changes the next collision's rectangles. The -active row can cover the pointer while being excluded from collision, or move a category boundary -away from the pointer. Updating from `onDragMove` instead of `onDragOver` avoids a React feedback loop -but does not remove this geometric feedback. Sortable transforms provide the same visible movement -without changing layout. +Changing the DOM array on every collision changes the rectangles used by the next collision. A +stationary pointer can alternate between targets. Sortable transforms provide the same visual +movement without changing layout. -### A separate drag overlay +### Move the real row under the pointer -An overlay duplicates the active row and requires hiding the original copy. Keeping the sortable row -as the pointer visual gives dnd-kit one transform to own. A source-sized outer row isolates the -card-to-compact morph from list geometry. +The real row must keep its source slot measured while dnd-kit sorts siblings. Making that node fixed +or moving it between categories couples its card-to-compact height change to list geometry and hit +testing. A `DragOverlay` separates the pointer visual from the invisible source slot and supplies a +tested drop animation. ### A separate drop indicator -The sortable gap already shows the resulting index. Another indicator duplicates the same state. +The sortable gap already shows the insertion index. Another line duplicates the same state. Category +outlines communicate shelf ownership without consuming list space. -### Target locks and category-specific corrections +### Target locks and category-specific scroll corrections -Locks, hysteresis, portal rails, and per-category scroll rules preserve stale geometry. They make one -case look stable by changing collision or layout elsewhere. The flat list and one anchor rule remove -the underlying container shift. +Locks, hysteresis, portal rails, and per-category scroll rules preserve stale geometry. The flat +board, source-sized collision rectangle, dnd-kit layout-shift compensation, and one anchor rule cover +the underlying layout changes directly. diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 3c520704a896..c2e5f8bcc642 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -1,9 +1,9 @@ # Organizing threads -The sidebar groups threads into Pinned, Regular, Snooze, and Settled. On web and desktop, drag +The sidebar groups threads into Pinned, Regular, Snoozed, and Settled. On web and desktop, drag any thread between these sections. The sidebar keeps the dragged thread under the pointer and holds your scroll position while a section appears or changes. -Empty Pinned, Regular, Snooze, and Settled sections appear as drop targets while you drag. +Empty Pinned, Regular, Snoozed, and Settled sections appear as drop targets while you drag. The destination chooses the thread's state: @@ -12,7 +12,7 @@ The destination chooses the thread's state: - Drop in **Regular** to unpin, wake, or un-settle it as needed. Regular keeps its normal sort order. - Drop in **Settled** to settle it. Settled threads keep their normal history order. -- Drop in **Snooze** to open the usual snooze menu. Choose when the thread should wake. Snoozing +- Drop in **Snoozed** to open the usual snooze menu. Choose when the thread should wake. Snoozing removes it from the other sections, and the row or confirmation toast can wake it again. Pinning, settling, waking, and un-settling happen automatically when you drop. Snooze and Settled From 399a71d142810a2526149b692e38bc9d6cc31495 Mon Sep 17 00:00:00 2001 From: Taras Date: Sun, 23 Aug 2026 17:03:56 +0300 Subject: [PATCH 17/26] fix(web): stabilize pinned thread reordering --- apps/web/src/components/Sidebar.dnd.collision.ts | 9 +++++++++ docs/internals/sidebar-thread-dnd.md | 5 +++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/Sidebar.dnd.collision.ts b/apps/web/src/components/Sidebar.dnd.collision.ts index 7a8b310d2d4d..e8c4f18bf573 100644 --- a/apps/web/src/components/Sidebar.dnd.collision.ts +++ b/apps/web/src/components/Sidebar.dnd.collision.ts @@ -132,6 +132,15 @@ export function detectSidebarThreadCollision(input: SidebarThreadCollisionInput) return section === ownedSection; }); + if (transaction.sourceSection === "pinned" && ownedSection === "pinned") { + if (args.pointerCoordinates !== null && !pointerInsideBoardWidth) return []; + return closestCenter({ + ...args, + collisionRect: sourceCollisionRect, + droppableContainers: collisionCandidates, + }); + } + const pointerCollisions = pointerWithin({ ...args, droppableContainers: collisionCandidates, diff --git a/docs/internals/sidebar-thread-dnd.md b/docs/internals/sidebar-thread-dnd.md index 5eb54af8582d..8a4bca809de9 100644 --- a/docs/internals/sidebar-thread-dnd.md +++ b/docs/internals/sidebar-thread-dnd.md @@ -85,8 +85,9 @@ so touching a shelf does not steal the last Regular slot. After one category owns the drag, invalid destinations are removed. `pointerWithin` chooses an exact row or boundary when possible; `closestCenter` fills the gaps between visible droppables. The active -row remains a candidate, which preserves dnd-kit's normal sortable behavior inside Pinned. Leaving -the board width clears the target. +row remains a candidate. Same-category Pinned reorder bypasses `pointerWithin` and uses dnd-kit's +measured rectangles with `closestCenter`, matching a normal sortable list without feeding transformed +row positions back into collision detection. Leaving the board width clears the target. ### Sortable projection and FLIP From 9d856a3b65a1b772473d6ed15680f1d54d246e5b Mon Sep 17 00:00:00 2001 From: Taras Date: Sun, 23 Aug 2026 17:37:48 +0300 Subject: [PATCH 18/26] fix(web): fix sidebar drag edge cases --- .../components/sidebar/SidebarThreadBoard.tsx | 18 +++++++-------- .../components/sidebar/SidebarThreadDnd.tsx | 3 ++- apps/web/src/hooks/useSidebarPinnedDnd.ts | 4 +++- apps/web/src/hooks/useSidebarThreadDnd.ts | 23 ++++++++++--------- docs/internals/sidebar-thread-dnd.md | 6 +++-- 5 files changed, 29 insertions(+), 25 deletions(-) diff --git a/apps/web/src/components/sidebar/SidebarThreadBoard.tsx b/apps/web/src/components/sidebar/SidebarThreadBoard.tsx index ff5c21c666b4..d5b87a0b8a23 100644 --- a/apps/web/src/components/sidebar/SidebarThreadBoard.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadBoard.tsx @@ -75,6 +75,7 @@ function SidebarThreadShelfHeader(props: { section: "snoozed" | "settled"; count: number; expanded: boolean; + dropActive: boolean; setDroppableNodeRef: (node: HTMLElement | null) => void; onToggle: () => void; }) { @@ -82,6 +83,7 @@ function SidebarThreadShelfHeader(props: { const label = snoozed ? "Snoozed" : "Settled"; const color = snoozed ? "text-blue-600 dark:text-blue-400" : "text-muted-foreground/50"; const divider = snoozed ? "bg-blue-500/20 dark:bg-blue-400/15" : "bg-sidebar-border/60"; + const presentationExpanded = props.expanded || props.dropActive; return (
          @@ -216,11 +218,8 @@ export function SidebarThreadBoard(props: { @@ -232,9 +231,8 @@ export function SidebarThreadBoard(props: { diff --git a/apps/web/src/components/sidebar/SidebarThreadDnd.tsx b/apps/web/src/components/sidebar/SidebarThreadDnd.tsx index 64a75f7b7d0a..4ae9e84eef2f 100644 --- a/apps/web/src/components/sidebar/SidebarThreadDnd.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadDnd.tsx @@ -154,7 +154,8 @@ export function SidebarThreadDndShell(props: { (props.variant === "slim" ? "[content-visibility:auto] [contain-intrinsic-size:auto_36px]" : "py-0.5 [content-visibility:auto] [contain-intrinsic-size:auto_78px]"), - dnd && "touch-pan-y cursor-grab active:cursor-grabbing", + dnd && + "touch-pan-y cursor-grab active:cursor-grabbing [&_[data-thread-row]]:cursor-grab [&_[data-thread-row]]:active:cursor-grabbing", dragView !== null && "z-20 cursor-grabbing", props.hidden && "opacity-0", props.inert && "pointer-events-none", diff --git a/apps/web/src/hooks/useSidebarPinnedDnd.ts b/apps/web/src/hooks/useSidebarPinnedDnd.ts index 6e5fae2997c4..18bd2c0bcb67 100644 --- a/apps/web/src/hooks/useSidebarPinnedDnd.ts +++ b/apps/web/src/hooks/useSidebarPinnedDnd.ts @@ -176,7 +176,9 @@ export function useSidebarPinnedDnd(input: { if (transaction.sourceSection === "pinned" || transaction.target.section !== "pinned") { return null; } - const existingKeys = input.allPinnedThreads.map(sidebarThreadKey); + const existingKeys = input.allPinnedThreads + .map(sidebarThreadKey) + .filter((key) => key !== transaction.sourceThreadKey); let insertionIndex = existingKeys.length; if (transaction.target.threadKey !== null) { const targetIndex = existingKeys.indexOf(transaction.target.threadKey); diff --git a/apps/web/src/hooks/useSidebarThreadDnd.ts b/apps/web/src/hooks/useSidebarThreadDnd.ts index 0d1e6b346905..5cde0d2896d8 100644 --- a/apps/web/src/hooks/useSidebarThreadDnd.ts +++ b/apps/web/src/hooks/useSidebarThreadDnd.ts @@ -304,6 +304,7 @@ export function useSidebarThreadDnd(input: { transaction: SidebarThreadCommittingTransaction; receiptSequencesByEnvironment: ReadonlyMap; }) => { + if (transactionRef.current !== reconciliation.transaction) return; setTransaction({ ...reconciliation.transaction, phase: "reconciling", @@ -847,29 +848,29 @@ export function useSidebarThreadDnd(input: { useLayoutEffect(() => { if (transaction === null || transaction.phase !== "reconciling") return; + if (input.isSearchingThreads || input.scopeKey !== transaction.scopeKey) return; for (const [environmentId, receiptSequence] of transaction.receiptSequencesByEnvironment) { const snapshot = appAtomRegistry.get(environmentSnapshotAtom(environmentId)); if (snapshot === null || snapshot.snapshotSequence < receiptSequence) return; } finishTransaction(); - }, [finishTransaction, input.threads, transaction]); + }, [finishTransaction, input.isSearchingThreads, input.scopeKey, input.threads, transaction]); useLayoutEffect(() => { - if ( - transaction === null || - (transaction.phase !== "dragging" && - transaction.phase !== "dropping" && - transaction.phase !== "awaiting-snooze-choice") - ) { + if (transaction === null) return; + if (input.isSearchingThreads || input.scopeKey !== transaction.scopeKey) { + clearTransaction(); return; } if ( - input.isSearchingThreads || - input.scopeKey !== transaction.scopeKey || - currentSourceThread(transaction) === null + transaction.phase !== "dragging" && + transaction.phase !== "dropping" && + transaction.phase !== "awaiting-snooze-choice" ) { - finishTransaction(); + return; } + if (currentSourceThread(transaction) === null) finishTransaction(); }, [ + clearTransaction, finishTransaction, input.isSearchingThreads, input.scopeKey, diff --git a/docs/internals/sidebar-thread-dnd.md b/docs/internals/sidebar-thread-dnd.md index 8a4bca809de9..4d0f8ef3f777 100644 --- a/docs/internals/sidebar-thread-dnd.md +++ b/docs/internals/sidebar-thread-dnd.md @@ -144,8 +144,10 @@ order. The projected row remains rendered while the command is pending. Reconciliation ends only after each affected environment's shell snapshot reaches its receipt sequence. Canonical state then wins. -If the source disappears, becomes archived, loses the needed capability, or leaves the starting -sidebar scope during an interactive phase, the client cancels the transaction. +Changing sidebar scope or entering search clears the projection in every phase. An already-dispatched +command may still complete, but it cannot restore entries from the previous view. During an +interactive phase, the client also cancels if the source disappears, becomes archived, or loses the +needed capability. ## Consequences From ab352829c2e62b4b3a7df9ca84286ecafa7e18cc Mon Sep 17 00:00:00 2001 From: Taras Date: Sun, 23 Aug 2026 17:53:47 +0300 Subject: [PATCH 19/26] fix(web): stabilize sidebar drag completion --- .../components/sidebar/SidebarThreadBoard.tsx | 91 +++++++++++++------ apps/web/src/hooks/useSidebarPinnedDnd.ts | 5 +- docs/internals/sidebar-thread-dnd.md | 9 +- 3 files changed, 70 insertions(+), 35 deletions(-) diff --git a/apps/web/src/components/sidebar/SidebarThreadBoard.tsx b/apps/web/src/components/sidebar/SidebarThreadBoard.tsx index d5b87a0b8a23..ef26208db733 100644 --- a/apps/web/src/components/sidebar/SidebarThreadBoard.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadBoard.tsx @@ -396,33 +396,70 @@ export function SidebarThreadBoard(props: { }), } satisfies CSSProperties; const dropAnimation = useMemo( - () => ({ - ...defaultDropAnimation, - duration: 160, - easing: "cubic-bezier(0.2, 0, 0, 1)", - keyframes: (args) => { - const keyframes = defaultDropAnimation.keyframes(args); - const first = keyframes[0]; - const last = keyframes[keyframes.length - 1]; - if ( - first === undefined || - last === undefined || - JSON.stringify(first) === JSON.stringify(last) || - window.matchMedia("(prefers-reduced-motion: reduce)").matches - ) { - queueMicrotask(dnd.completeDropAnimation); - return last === undefined ? keyframes : [last, last]; - } - return keyframes; - }, - sideEffects: (args) => { - const cleanup = defaultDropAnimation.sideEffects?.(args); - return () => { - cleanup?.(); - dnd.completeDropAnimation(); - }; - }, - }), + () => async (args) => { + await new Promise((resolve) => queueMicrotask(resolve)); + if (!args.active.node.isConnected || !args.dragOverlay.node.isConnected) { + dnd.completeDropAnimation(); + return; + } + + const activeRect = args.measuringConfiguration.draggable.measure(args.active.node); + const dragOverlayRect = args.dragOverlay.rect; + const delta = { + x: dragOverlayRect.left - activeRect.left, + y: dragOverlayRect.top - activeRect.top, + }; + const measuredArgs = { + ...args, + active: { ...args.active, rect: activeRect }, + dragOverlay: { ...args.dragOverlay, rect: dragOverlayRect }, + }; + const keyframes = defaultDropAnimation.keyframes({ + ...measuredArgs, + transform: { + initial: args.transform, + final: { + x: args.transform.x - delta.x, + y: args.transform.y - delta.y, + scaleX: + args.transform.scaleX === 1 + ? 1 + : (activeRect.width * args.transform.scaleX) / dragOverlayRect.width, + scaleY: + args.transform.scaleY === 1 + ? 1 + : (activeRect.height * args.transform.scaleY) / dragOverlayRect.height, + }, + }, + }); + const first = keyframes[0]; + const last = keyframes[keyframes.length - 1]; + if ( + first === undefined || + last === undefined || + JSON.stringify(first) === JSON.stringify(last) || + window.matchMedia("(prefers-reduced-motion: reduce)").matches + ) { + dnd.completeDropAnimation(); + return; + } + + const cleanup = defaultDropAnimation.sideEffects?.(measuredArgs); + try { + const animation = args.dragOverlay.node.animate(keyframes, { + duration: 160, + easing: "cubic-bezier(0.2, 0, 0, 1)", + fill: "forwards", + }); + await animation.finished.then( + () => undefined, + () => undefined, + ); + } finally { + cleanup?.(); + dnd.completeDropAnimation(); + } + }, [dnd.completeDropAnimation], ); const dragSessionActive = diff --git a/apps/web/src/hooks/useSidebarPinnedDnd.ts b/apps/web/src/hooks/useSidebarPinnedDnd.ts index 18bd2c0bcb67..2d9c5e42931f 100644 --- a/apps/web/src/hooks/useSidebarPinnedDnd.ts +++ b/apps/web/src/hooks/useSidebarPinnedDnd.ts @@ -90,10 +90,7 @@ export function useSidebarPinnedDnd(input: { const allAssignmentsLanded = [...optimisticPinnedOrder.assignedKeys].every( ([threadKey, orderKey]) => currentKeyByThreadKey.get(threadKey) === orderKey, ); - const orderConfirmed = - !membershipChanged && - canonicalKeys.every((key, index) => key === optimisticPinnedOrder.order[index]); - if (membershipChanged || foreignKeyLanded || allAssignmentsLanded || orderConfirmed) { + if (membershipChanged || foreignKeyLanded || allAssignmentsLanded) { pinnedReorderInFlightRef.current = false; setOptimisticPinnedOrder(null); } diff --git a/docs/internals/sidebar-thread-dnd.md b/docs/internals/sidebar-thread-dnd.md index 4d0f8ef3f777..a381c720850b 100644 --- a/docs/internals/sidebar-thread-dnd.md +++ b/docs/internals/sidebar-thread-dnd.md @@ -103,10 +103,11 @@ without that projected-index override. On release, React renders the projected target order. The destination copy remains invisible and acts as the overlay's drop target. dnd-kit's `DragOverlay` drop animation moves the visible row from -its release rectangle to that target. Other rows use dnd-kit's sortable FLIP. The destination copy -disables its own cross-category layout transition so it does not replay a second source-to-target -move. The lifecycle command starts after the overlay animation completes. Reduced-motion clients -complete the handoff immediately. +its release rectangle to that target. Before measuring the target, the drop animation yields through +the current commit so temporary empty rails can disappear and viewport compensation can finish. +Other rows use dnd-kit's sortable FLIP. The destination copy disables its own cross-category layout +transition so it does not replay a second source-to-target move. The lifecycle command starts after +the overlay animation completes. Reduced-motion clients complete the handoff immediately. The sortable gap is the insertion feedback. There is no separate line indicator. Snoozed and Settled use an absolutely positioned category outline during hover; it takes no list space. From 5ff4194154f4aa13d426e48a51ce447ab0ada18a Mon Sep 17 00:00:00 2001 From: Taras Date: Sun, 23 Aug 2026 17:57:04 +0300 Subject: [PATCH 20/26] fix(web): remove empty regular drag rail --- .../components/sidebar/SidebarThreadBoard.tsx | 100 +++++------------- docs/internals/sidebar-thread-dnd.md | 18 ++-- docs/user/thread-sidebar.md | 2 +- 3 files changed, 38 insertions(+), 82 deletions(-) diff --git a/apps/web/src/components/sidebar/SidebarThreadBoard.tsx b/apps/web/src/components/sidebar/SidebarThreadBoard.tsx index ef26208db733..bca1db742a4b 100644 --- a/apps/web/src/components/sidebar/SidebarThreadBoard.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadBoard.tsx @@ -196,14 +196,7 @@ export function SidebarThreadBoard(props: { ) : null; break; case "regular": - content = railVisible ? ( - - ) : pinnedSectionHasThreads ? ( + content = pinnedSectionHasThreads ? (
          ( - () => async (args) => { - await new Promise((resolve) => queueMicrotask(resolve)); - if (!args.active.node.isConnected || !args.dragOverlay.node.isConnected) { - dnd.completeDropAnimation(); - return; - } - - const activeRect = args.measuringConfiguration.draggable.measure(args.active.node); - const dragOverlayRect = args.dragOverlay.rect; - const delta = { - x: dragOverlayRect.left - activeRect.left, - y: dragOverlayRect.top - activeRect.top, - }; - const measuredArgs = { - ...args, - active: { ...args.active, rect: activeRect }, - dragOverlay: { ...args.dragOverlay, rect: dragOverlayRect }, - }; - const keyframes = defaultDropAnimation.keyframes({ - ...measuredArgs, - transform: { - initial: args.transform, - final: { - x: args.transform.x - delta.x, - y: args.transform.y - delta.y, - scaleX: - args.transform.scaleX === 1 - ? 1 - : (activeRect.width * args.transform.scaleX) / dragOverlayRect.width, - scaleY: - args.transform.scaleY === 1 - ? 1 - : (activeRect.height * args.transform.scaleY) / dragOverlayRect.height, - }, - }, - }); - const first = keyframes[0]; - const last = keyframes[keyframes.length - 1]; - if ( - first === undefined || - last === undefined || - JSON.stringify(first) === JSON.stringify(last) || - window.matchMedia("(prefers-reduced-motion: reduce)").matches - ) { - dnd.completeDropAnimation(); - return; - } - - const cleanup = defaultDropAnimation.sideEffects?.(measuredArgs); - try { - const animation = args.dragOverlay.node.animate(keyframes, { - duration: 160, - easing: "cubic-bezier(0.2, 0, 0, 1)", - fill: "forwards", - }); - await animation.finished.then( - () => undefined, - () => undefined, - ); - } finally { - cleanup?.(); - dnd.completeDropAnimation(); - } - }, + () => ({ + ...defaultDropAnimation, + duration: 160, + easing: "cubic-bezier(0.2, 0, 0, 1)", + keyframes: (args) => { + const keyframes = defaultDropAnimation.keyframes(args); + const first = keyframes[0]; + const last = keyframes[keyframes.length - 1]; + if ( + first === undefined || + last === undefined || + JSON.stringify(first) === JSON.stringify(last) || + window.matchMedia("(prefers-reduced-motion: reduce)").matches + ) { + queueMicrotask(dnd.completeDropAnimation); + return last === undefined ? keyframes : [last, last]; + } + return keyframes; + }, + sideEffects: (args) => { + const cleanup = defaultDropAnimation.sideEffects?.(args); + return () => { + cleanup?.(); + dnd.completeDropAnimation(); + }; + }, + }), [dnd.completeDropAnimation], ); const dragSessionActive = diff --git a/docs/internals/sidebar-thread-dnd.md b/docs/internals/sidebar-thread-dnd.md index a381c720850b..3a30d9b6277f 100644 --- a/docs/internals/sidebar-thread-dnd.md +++ b/docs/internals/sidebar-thread-dnd.md @@ -103,11 +103,10 @@ without that projected-index override. On release, React renders the projected target order. The destination copy remains invisible and acts as the overlay's drop target. dnd-kit's `DragOverlay` drop animation moves the visible row from -its release rectangle to that target. Before measuring the target, the drop animation yields through -the current commit so temporary empty rails can disappear and viewport compensation can finish. -Other rows use dnd-kit's sortable FLIP. The destination copy disables its own cross-category layout -transition so it does not replay a second source-to-target move. The lifecycle command starts after -the overlay animation completes. Reduced-motion clients complete the handoff immediately. +its release rectangle to that target. Other rows use dnd-kit's sortable FLIP. The destination copy +disables its own cross-category layout transition so it does not replay a second source-to-target +move. The lifecycle command starts after the overlay animation completes. Reduced-motion clients +complete the handoff immediately. The sortable gap is the insertion feedback. There is no separate line indicator. Snoozed and Settled use an absolutely positioned category outline during hover; it takes no list space. @@ -118,10 +117,11 @@ dnd-kit auto-scroll is limited to the sidebar viewport and uses its vertical lay compensation. The board does not add synthetic scroll headroom, which would expose blank space at the viewport edges. -Some React changes still affect real layout: empty rails appear at activation, projected entries -mount on release, and canonical entries replace the projection after reconciliation. Before those -changes, the client records one stable entry's viewport position. After React commits, it changes -only the sidebar `scrollTop` by that entry's visual delta. The content disables native scroll +Some React changes still affect real layout: empty rails appear at activation for Pinned and +Settled, the empty Snoozed header appears, projected entries mount on release, and canonical entries +replace the projection after reconciliation. Regular has no synthetic empty target. Before the +other changes, the client records one stable entry's viewport position. After React commits, it +changes only the sidebar `scrollTop` by that entry's visual delta. The content disables native scroll anchoring so the browser and the client do not both compensate. User scroll and dnd-kit auto-scroll update the anchor baseline. diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index c2e5f8bcc642..e3fe2a7a4558 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -3,7 +3,7 @@ The sidebar groups threads into Pinned, Regular, Snoozed, and Settled. On web and desktop, drag any thread between these sections. The sidebar keeps the dragged thread under the pointer and holds your scroll position while a section appears or changes. -Empty Pinned, Regular, Snoozed, and Settled sections appear as drop targets while you drag. +Empty Pinned, Snoozed, and Settled sections appear as drop targets while you drag. The destination chooses the thread's state: From 8d0b2e6ef4cba649ef2dfa38a0a9614379ff6bda Mon Sep 17 00:00:00 2001 From: Taras Date: Sun, 23 Aug 2026 18:13:06 +0300 Subject: [PATCH 21/26] fix(web): preserve sidebar drag destinations --- .../src/components/Sidebar.dnd.logic.test.ts | 189 ++++++++++++++++++ apps/web/src/components/Sidebar.logic.test.ts | 13 ++ .../components/sidebar/SidebarThreadBoard.tsx | 8 +- apps/web/src/hooks/useSidebarPinnedDnd.ts | 14 +- docs/internals/sidebar-thread-dnd.md | 13 +- 5 files changed, 228 insertions(+), 9 deletions(-) create mode 100644 apps/web/src/components/Sidebar.dnd.logic.test.ts diff --git a/apps/web/src/components/Sidebar.dnd.logic.test.ts b/apps/web/src/components/Sidebar.dnd.logic.test.ts new file mode 100644 index 000000000000..632c680ddcf7 --- /dev/null +++ b/apps/web/src/components/Sidebar.dnd.logic.test.ts @@ -0,0 +1,189 @@ +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; +import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + buildSidebarDndBoardEntries, + findSidebarDndBoardThreadSection, + findSortedSidebarDndDropTarget, + moveSidebarDndBoardThread, + type SidebarDndBoardEntry, +} from "./Sidebar.dnd.board"; +import { + resolveSidebarDndAction, + resolveSidebarDndPreviewVariant, + sidebarThreadKey, + type SidebarDndAction, + type SidebarDndPreviewVariant, + type SidebarDndSection, +} from "./Sidebar.dnd.logic"; + +describe("resolveSidebarDndAction", () => { + it.each([ + ["pinned", "pinned", "reorder-pinned"], + ["pinned", "regular", "unpin"], + ["pinned", "snoozed", "snooze"], + ["pinned", "settled", "settle"], + ["regular", "pinned", "pin"], + ["regular", "regular", "noop"], + ["regular", "snoozed", "snooze"], + ["regular", "settled", "settle"], + ["snoozed", "pinned", "pin"], + ["snoozed", "regular", "unsnooze"], + ["snoozed", "snoozed", "noop"], + ["snoozed", "settled", "settle"], + ["settled", "pinned", "pin"], + ["settled", "regular", "unsettle"], + ["settled", "snoozed", "snooze"], + ["settled", "settled", "noop"], + ] satisfies ReadonlyArray)( + "%s -> %s resolves to %s", + (source, destination, expected) => { + expect(resolveSidebarDndAction({ source, destination })).toBe(expected); + }, + ); +}); + +describe("resolveSidebarDndPreviewVariant", () => { + it.each([ + ["settled", "pinned", "card"], + ["settled", "regular", "card"], + ["pinned", "snoozed", "slim"], + ["pinned", "settled", "slim"], + ["pinned", null, "card"], + ["regular", null, "card"], + ["snoozed", null, "slim"], + ["settled", null, "slim"], + ] satisfies ReadonlyArray< + readonly [SidebarDndSection, SidebarDndSection | null, SidebarDndPreviewVariant] + >)("%s -> %s uses %s", (source, destination, expected) => { + expect(resolveSidebarDndPreviewVariant({ source, destination })).toBe(expected); + }); +}); + +describe("sidebar DnD board placement", () => { + const pinned = makeThread("pinned"); + const regular = makeThread("regular"); + const snoozed = makeThread("snoozed"); + const settled = makeThread("settled"); + const entries = buildSidebarDndBoardEntries({ + pinnedThreads: [pinned], + regularThreads: [regular], + snoozedThreads: [snoozed], + settledThreads: [settled], + }); + + it("moves a row to the requested thread edge", () => { + const moved = moveSidebarDndBoardThread({ + entries, + threadKey: sidebarThreadKey(regular), + target: { + section: "pinned", + threadKey: sidebarThreadKey(pinned), + edge: "after", + }, + }); + + expect(findSidebarDndBoardThreadSection(moved, sidebarThreadKey(regular))).toBe("pinned"); + expect(threadKeys(moved)).toEqual([ + sidebarThreadKey(pinned), + sidebarThreadKey(regular), + sidebarThreadKey(snoozed), + sidebarThreadKey(settled), + ]); + }); + + it("moves a row immediately after an empty section boundary", () => { + const emptySettledEntries = buildSidebarDndBoardEntries({ + pinnedThreads: [pinned], + regularThreads: [regular], + snoozedThreads: [snoozed], + settledThreads: [], + }); + const moved = moveSidebarDndBoardThread({ + entries: emptySettledEntries, + threadKey: sidebarThreadKey(regular), + target: { section: "settled", threadKey: null, edge: null }, + }); + + expect(findSidebarDndBoardThreadSection(moved, sidebarThreadKey(regular))).toBe("settled"); + expect(threadKeys(moved)).toEqual([ + sidebarThreadKey(pinned), + sidebarThreadKey(snoozed), + sidebarThreadKey(regular), + ]); + }); +}); + +describe("findSortedSidebarDndDropTarget", () => { + const first = makeThread("first"); + const source = makeThread("source"); + const last = makeThread("last"); + + it("targets the next sorted row when one follows the source", () => { + expect( + findSortedSidebarDndDropTarget({ + section: "regular", + sourceThreadKey: sidebarThreadKey(source), + threads: [first, source, last], + }), + ).toEqual({ section: "regular", threadKey: sidebarThreadKey(last), edge: "before" }); + }); + + it("targets after the previous row when the source sorts last", () => { + expect( + findSortedSidebarDndDropTarget({ + section: "snoozed", + sourceThreadKey: sidebarThreadKey(source), + threads: [first, source], + }), + ).toEqual({ section: "snoozed", threadKey: sidebarThreadKey(first), edge: "after" }); + }); + + it("targets the section boundary when the source is alone or missing", () => { + expect( + findSortedSidebarDndDropTarget({ + section: "settled", + sourceThreadKey: sidebarThreadKey(source), + threads: [source], + }), + ).toEqual({ section: "settled", threadKey: null, edge: null }); + expect( + findSortedSidebarDndDropTarget({ + section: "settled", + sourceThreadKey: sidebarThreadKey(source), + threads: [first], + }), + ).toEqual({ section: "settled", threadKey: null, edge: null }); + }); +}); + +function threadKeys(entries: readonly SidebarDndBoardEntry[]): string[] { + return entries.flatMap((entry) => (entry.kind === "thread" ? [entry.id] : [])); +} + +function makeThread(id: string): EnvironmentThreadShell { + const threadId = ThreadId.make(id); + return { + environmentId: EnvironmentId.make("environment-local"), + id: threadId, + projectId: ProjectId.make("project-1"), + title: id, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: "2026-08-23T10:00:00.000Z", + updatedAt: "2026-08-23T10:00:00.000Z", + archivedAt: null, + settledOverride: null, + settledAt: null, + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + }; +} diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index c6e113a44523..9a087f1c89cd 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -27,6 +27,7 @@ import { shouldClearThreadSelectionOnMouseDown, sortLogicalProjectsForSidebar, sortSettledThreadsForSidebar, + sortSnoozedThreadsForSidebar, pinOrderKeyBetween, planPinnedReorder, sortPinnedThreadsForSidebar, @@ -781,6 +782,18 @@ describe("sortThreadsForSidebar", () => { }); }); +describe("sortSnoozedThreadsForSidebar", () => { + it("orders by wake time, soonest first, with stable id ties", () => { + const sorted = sortSnoozedThreadsForSidebar([ + { id: "later", snoozedUntil: "2026-03-09T12:00:00.000Z" }, + { id: "b", snoozedUntil: "2026-03-09T10:00:00.000Z" }, + { id: "a", snoozedUntil: "2026-03-09T10:00:00.000Z" }, + ]); + + expect(sorted.map((thread) => thread.id)).toEqual(["a", "b", "later"]); + }); +}); + describe("pinOrderKeyBetween", () => { it("produces keys that sort between their bounds", () => { const middle = pinOrderKeyBetween(null, null)!; diff --git a/apps/web/src/components/sidebar/SidebarThreadBoard.tsx b/apps/web/src/components/sidebar/SidebarThreadBoard.tsx index bca1db742a4b..1446dd42f18e 100644 --- a/apps/web/src/components/sidebar/SidebarThreadBoard.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadBoard.tsx @@ -203,7 +203,13 @@ export function SidebarThreadBoard(props: { data-testid="sidebar-pinned-divider" className="mx-2.5 my-1.5 h-px bg-sidebar-border/60" /> - ) : null; + ) : ( +
          + ); break; case "snoozed": content = diff --git a/apps/web/src/hooks/useSidebarPinnedDnd.ts b/apps/web/src/hooks/useSidebarPinnedDnd.ts index 2d9c5e42931f..f152d9255301 100644 --- a/apps/web/src/hooks/useSidebarPinnedDnd.ts +++ b/apps/web/src/hooks/useSidebarPinnedDnd.ts @@ -63,11 +63,21 @@ export function useSidebarPinnedDnd(input: { ); const orderedPinnedThreads = useMemo(() => { if (optimisticPinnedOrder === null) return input.pinnedThreads; - return orderItemsByPreferredIds({ - items: input.pinnedThreads, + const optimisticallyOrderedKeys = new Set(optimisticPinnedOrder.order); + const reorderedThreads = orderItemsByPreferredIds({ + items: input.pinnedThreads.filter((thread) => + optimisticallyOrderedKeys.has(sidebarThreadKey(thread)), + ), preferredIds: optimisticPinnedOrder.order, getId: sidebarThreadKey, }); + let reorderedIndex = 0; + return input.pinnedThreads.map((thread) => { + if (!optimisticallyOrderedKeys.has(sidebarThreadKey(thread))) return thread; + const reorderedThread = reorderedThreads[reorderedIndex]; + reorderedIndex += 1; + return reorderedThread ?? thread; + }); }, [input.pinnedThreads, optimisticPinnedOrder]); useEffect(() => { if (optimisticPinnedOrder === null) return; diff --git a/docs/internals/sidebar-thread-dnd.md b/docs/internals/sidebar-thread-dnd.md index 3a30d9b6277f..8378d31c9416 100644 --- a/docs/internals/sidebar-thread-dnd.md +++ b/docs/internals/sidebar-thread-dnd.md @@ -119,11 +119,11 @@ the viewport edges. Some React changes still affect real layout: empty rails appear at activation for Pinned and Settled, the empty Snoozed header appears, projected entries mount on release, and canonical entries -replace the projection after reconciliation. Regular has no synthetic empty target. Before the -other changes, the client records one stable entry's viewport position. After React commits, it -changes only the sidebar `scrollTop` by that entry's visual delta. The content disables native scroll -anchoring so the browser and the client do not both compensate. User scroll and dnd-kit auto-scroll -update the anchor baseline. +replace the projection after reconciliation. Empty Regular keeps an absolutely positioned boundary +droppable, which takes no layout space. Before the other changes, the client records one stable +entry's viewport position. After React commits, it changes only the sidebar `scrollTop` by that +entry's visual delta. The content disables native scroll anchoring so the browser and the client do +not both compensate. User scroll and dnd-kit auto-scroll update the anchor baseline. Pinned reorder does not need manual viewport correction after release. Its membership, category structure, and total height stay unchanged, so dnd-kit's layout animation is sufficient. @@ -136,7 +136,8 @@ drop adds no command, event, or compatibility path. `thread.pin.reorder` remains key-only. Moving into Pinned computes the order keys for the visible position, prepares any neighbor keys, then pins the source with its key. Pinned preserves that -manual order. The other categories use the same sort functions for drop projection and canonical +manual order. During an optimistic reorder, pinned rows without reorder capability keep their +existing slots. The other categories use the same sort functions for drop projection and canonical rendering. A Snoozed drop holds the projected compact slot while the standard duration menu is open. Choosing From d7ea7c2c5374cbd3cd175edd9bbd4922688d10aa Mon Sep 17 00:00:00 2001 From: Taras Date: Sun, 23 Aug 2026 18:20:36 +0300 Subject: [PATCH 22/26] fix(web): morph canceled snooze drags --- apps/web/src/hooks/useSidebarThreadDnd.ts | 59 +++++++++++++++++------ docs/internals/sidebar-thread-dnd.md | 3 +- 2 files changed, 47 insertions(+), 15 deletions(-) diff --git a/apps/web/src/hooks/useSidebarThreadDnd.ts b/apps/web/src/hooks/useSidebarThreadDnd.ts index 5cde0d2896d8..d3750863ede3 100644 --- a/apps/web/src/hooks/useSidebarThreadDnd.ts +++ b/apps/web/src/hooks/useSidebarThreadDnd.ts @@ -21,6 +21,7 @@ import { import type { ScopedThreadRef } from "@t3tools/contracts"; import type { TimestampFormat } from "@t3tools/contracts/settings"; import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { flushSync } from "react-dom"; import { readLocalApi } from "../localApi"; import { appAtomRegistry } from "../rpc/atomRegistry"; @@ -523,27 +524,57 @@ export function useSidebarThreadDnd(input: { target, snoozePreset: null, }); + const restoreCanceledDropPresentation = () => { + const pending = transactionRef.current; + if ( + snoozeDropEpochRef.current !== epoch || + pending === null || + pending.phase !== "awaiting-snooze-choice" || + !dropStillValid(pending, target) + ) { + return; + } + captureInsertionPosition(pending.initialEntries, pending.sourceThreadKey); + // dnd-kit snapshots the overlay as soon as cancelDrop resolves. + flushSync(() => { + setTransaction({ + ...pending, + entries: pending.initialEntries, + target: { + section: pending.sourceSection, + threadKey: pending.sourceThreadKey, + edge: null, + }, + }); + }); + }; const api = readLocalApi(); - if (api === undefined) return false; const menuPresets = resolveSnoozePresets(new Date(), input.timestampFormat); - const selected = await settlePromise(() => - api.contextMenu.show( - menuPresets.map((preset) => ({ - id: `snooze:${preset.id}`, - label: `${preset.label} (${preset.whenLabel})`, - })), - position, - ), - ); + const selected = + api === undefined + ? null + : await settlePromise(() => + api.contextMenu.show( + menuPresets.map((preset) => ({ + id: `snooze:${preset.id}`, + label: `${preset.label} (${preset.whenLabel})`, + })), + position, + ), + ); + const selectedId = selected?._tag === "Success" ? selected.value : null; + const preset = + selectedId === null + ? undefined + : menuPresets.find((candidate) => `snooze:${candidate.id}` === selectedId); if ( snoozeDropEpochRef.current !== epoch || - selected._tag === "Failure" || - selected.value === null + preset === undefined || + !dropStillValid(current, target) ) { + restoreCanceledDropPresentation(); return false; } - const preset = menuPresets.find((candidate) => `snooze:${candidate.id}` === selected.value); - if (preset === undefined || !dropStillValid(current, target)) return false; const projectedTarget = resolveSortedTarget(current, "snoozed", preset.snoozedUntil); const projectedEntries = moveSidebarDndBoardThread({ entries: current.initialEntries, diff --git a/docs/internals/sidebar-thread-dnd.md b/docs/internals/sidebar-thread-dnd.md index 8378d31c9416..86e73c7d40ab 100644 --- a/docs/internals/sidebar-thread-dnd.md +++ b/docs/internals/sidebar-thread-dnd.md @@ -142,7 +142,8 @@ rendering. A Snoozed drop holds the projected compact slot while the standard duration menu is open. Choosing a duration sorts the row by wake time before the drop animation. Cancelling restores the source -order. +order and source preview before dnd-kit starts its return animation, so the card morph and movement +run together. The projected row remains rendered while the command is pending. Reconciliation ends only after each affected environment's shell snapshot reaches its receipt sequence. Canonical state then wins. From ebe62fe279b84792b352939b3edd56d8aee54bf1 Mon Sep 17 00:00:00 2001 From: Taras Date: Sun, 23 Aug 2026 18:23:27 +0300 Subject: [PATCH 23/26] fix(web): hide decorative sidebar boundaries --- apps/web/src/components/sidebar/SidebarThreadBoard.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src/components/sidebar/SidebarThreadBoard.tsx b/apps/web/src/components/sidebar/SidebarThreadBoard.tsx index 1446dd42f18e..db0864e2e1ba 100644 --- a/apps/web/src/components/sidebar/SidebarThreadBoard.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadBoard.tsx @@ -252,6 +252,7 @@ export function SidebarThreadBoard(props: { return (
        • Date: Sun, 23 Aug 2026 18:26:22 +0300 Subject: [PATCH 24/26] fix(web): restore un-settle tooltip --- apps/web/src/components/Sidebar.tsx | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index b4d1dcc536f0..f8df35d8c582 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1321,17 +1321,24 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ) ) : !props.settlementSupported ? null : variantAction === "unsettle" ? ( - + + + } + > + + + Un-settle thread + ) : (