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..d1e03dccbd75 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; } @@ -552,25 +583,33 @@ 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 = yield* planThreadCategoryCleanup({ + thread, + threadId: command.threadId, + commandId: command.commandId, + occurredAt, + clear: ["pinned", "snoozed"], + }); + return cleanupEvents.length > 0 ? [unsettledEvent, ...cleanupEvents] : unsettledEvent; } case "thread.snooze": { @@ -626,14 +665,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 +680,16 @@ 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 = yield* planThreadCategoryCleanup({ + thread, + threadId: command.threadId, + commandId: command.commandId, + occurredAt, + clear: ["pinned", "settled"], + }); + return cleanupEvents.length > 0 ? [snoozedEvent, ...cleanupEvents] : snoozedEvent; } case "thread.unsnooze": { @@ -649,25 +698,32 @@ 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 = yield* planThreadCategoryCleanup({ + thread, + threadId: command.threadId, + commandId: command.commandId, + occurredAt, + clear: ["pinned", "settled"], + }); + return cleanupEvents.length > 0 ? [unsnoozedEvent, ...cleanupEvents] : unsnoozedEvent; } case "thread.pin": { @@ -677,10 +733,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,53 +746,22 @@ 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({ - ...(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) { - promotionEvents.push({ - ...(yield* withEventBase({ - aggregateKind: "thread", - aggregateId: command.threadId, - occurredAt, - commandId: command.commandId, - })), - type: "thread.unsnoozed", - payload: { - threadId: command.threadId, - reason: "user", - updatedAt: occurredAt, - }, - }); - } - return promotionEvents.length > 0 ? [pinnedEvent, ...promotionEvents] : pinnedEvent; + // Pinning clears settled and snoozed state. + const cleanupEvents = yield* planThreadCategoryCleanup({ + thread, + threadId: command.threadId, + commandId: command.commandId, + occurredAt, + clear: ["settled", "snoozed"], + }); + return cleanupEvents.length > 0 ? [pinnedEvent, ...cleanupEvents] : pinnedEvent; } case "thread.unpin": { @@ -748,24 +770,32 @@ 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 = yield* planThreadCategoryCleanup({ + thread, + threadId: command.threadId, + commandId: command.commandId, + occurredAt, + clear: ["settled", "snoozed"], + }); + return cleanupEvents.length > 0 ? [unpinnedEvent, ...cleanupEvents] : unpinnedEvent; } case "thread.pin.reorder": { 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..a6460d433032 --- /dev/null +++ b/apps/web/src/components/Sidebar.dnd.board.ts @@ -0,0 +1,119 @@ +import { + createSidebarDndSectionId, + sidebarThreadKey, + SIDEBAR_DND_SECTIONS, + type SidebarDndSection, + type SidebarThreadDropTarget, +} from "./Sidebar.dnd.logic"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; + +export type SidebarDndBoardEntry = + | { + readonly kind: "boundary"; + readonly id: string; + readonly section: SidebarDndSection; + } + | { + readonly kind: "thread"; + readonly id: string; + readonly thread: EnvironmentThreadShell; + }; + +export function buildSidebarDndBoardEntries(input: { + pinnedThreads: readonly EnvironmentThreadShell[]; + regularThreads: readonly EnvironmentThreadShell[]; + snoozedThreads: readonly EnvironmentThreadShell[]; + settledThreads: readonly EnvironmentThreadShell[]; +}): 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) { + entries.push({ + kind: "boundary", + id: createSidebarDndSectionId({ section }), + section, + }); + entries.push( + ...threadsBySection[section].map((thread) => ({ + kind: "thread" as const, + id: sidebarThreadKey(thread), + thread, + })), + ); + } + 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 findSortedSidebarDndDropTarget(input: { + section: "regular" | "snoozed" | "settled"; + sourceThreadKey: string; + threads: readonly EnvironmentThreadShell[]; +}): SidebarThreadDropTarget { + const sourceIndex = input.threads.findIndex( + (thread) => sidebarThreadKey(thread) === input.sourceThreadKey, + ); + if (sourceIndex === -1) { + return { section: input.section, threadKey: null, edge: null }; + } + const nextThread = input.threads[sourceIndex + 1]; + if (nextThread !== undefined) { + return { + section: input.section, + threadKey: sidebarThreadKey(nextThread), + edge: "before", + }; + } + const previousThread = input.threads[sourceIndex - 1]; + return { + section: input.section, + threadKey: previousThread === undefined ? null : sidebarThreadKey(previousThread), + edge: previousThread === undefined ? null : "after", + }; +} + +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.collision.ts b/apps/web/src/components/Sidebar.dnd.collision.ts new file mode 100644 index 000000000000..e8c4f18bf573 --- /dev/null +++ b/apps/web/src/components/Sidebar.dnd.collision.ts @@ -0,0 +1,167 @@ +import { + closestCenter, + getClientRect, + pointerWithin, + rectIntersection, + type CollisionDetection, + type UniqueIdentifier, +} from "@dnd-kit/core"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; + +import { + parseSidebarDndSectionId, + SIDEBAR_DND_SECTIONS, + type SidebarDndSection, + type SidebarThreadDraggingTransaction, +} from "./Sidebar.dnd.logic"; + +type CollisionArguments = Parameters[0]; + +interface SidebarThreadCollisionInput { + readonly args: CollisionArguments; + readonly transaction: SidebarThreadDraggingTransaction; + readonly sourceThread: EnvironmentThreadShell; + readonly reorderablePinnedKeys: ReadonlySet; + readonly canDropThreadInSection: ( + thread: EnvironmentThreadShell, + source: SidebarDndSection, + destination: SidebarDndSection, + ) => boolean; +} + +function containerSection(input: { + readonly containerId: UniqueIdentifier; + readonly sectionByThreadKey: ReadonlyMap; +}): SidebarDndSection | null { + const boundarySection = parseSidebarDndSectionId(input.containerId); + if (boundarySection !== null) return boundarySection; + return typeof input.containerId === "string" + ? (input.sectionByThreadKey.get(input.containerId) ?? null) + : null; +} + +function visualRect( + args: CollisionArguments, + container: CollisionArguments["droppableContainers"][number], +) { + return container.node.current === null + ? args.droppableRects.get(container.id) + : getClientRect(container.node.current); +} + +export function detectSidebarThreadCollision(input: SidebarThreadCollisionInput) { + const { args, transaction } = input; + const sectionByThreadKey = new Map(); + let entrySection: SidebarDndSection = "pinned"; + for (const entry of transaction.initialEntries) { + if (entry.kind === "boundary") entrySection = entry.section; + else sectionByThreadKey.set(entry.id, entrySection); + } + + const sectionByContainerId = new Map(); + const validCandidates = args.droppableContainers.filter((container) => { + const section = containerSection({ containerId: container.id, sectionByThreadKey }); + if ( + section === null || + !input.canDropThreadInSection(input.sourceThread, transaction.sourceSection, section) + ) { + return false; + } + const targetThreadKey = + parseSidebarDndSectionId(container.id) === null && typeof container.id === "string" + ? container.id + : null; + const valid = + section !== "pinned" || + targetThreadKey === null || + input.reorderablePinnedKeys.has(targetThreadKey); + if (valid) sectionByContainerId.set(container.id, section); + return valid; + }); + + const visualDroppableRects = new Map(args.droppableRects); + const sectionTop = new Map(); + let pointerInsideBoardWidth = false; + for (const container of validCandidates) { + const rect = visualRect(args, container); + if (rect === undefined) continue; + visualDroppableRects.set(container.id, rect); + const section = sectionByContainerId.get(container.id); + if (section !== undefined) { + sectionTop.set(section, Math.min(sectionTop.get(section) ?? rect.top, rect.top)); + } + if ( + args.pointerCoordinates !== null && + rect.left <= args.pointerCoordinates.x && + args.pointerCoordinates.x <= rect.right + ) { + pointerInsideBoardWidth = true; + } + } + + const sourceCollisionRect = + args.pointerCoordinates === null + ? args.collisionRect + : { + top: + args.pointerCoordinates.y - transaction.pointerAnchor.y * transaction.sourceRect.height, + bottom: + args.pointerCoordinates.y + + (1 - transaction.pointerAnchor.y) * transaction.sourceRect.height, + left: + args.pointerCoordinates.x - transaction.pointerAnchor.x * transaction.sourceRect.width, + right: + args.pointerCoordinates.x + + (1 - transaction.pointerAnchor.x) * transaction.sourceRect.width, + width: transaction.sourceRect.width, + height: transaction.sourceRect.height, + }; + const cardCenterY = sourceCollisionRect.top + sourceCollisionRect.height / 2; + let ownedSection: SidebarDndSection | null = null; + for (const section of SIDEBAR_DND_SECTIONS) { + const top = sectionTop.get(section); + if (top === undefined) continue; + const ownershipY = + section === "regular" && args.pointerCoordinates !== null + ? args.pointerCoordinates.y + : cardCenterY; + if (ownedSection === null || ownershipY >= top) ownedSection = section; + } + const collisionCandidates = validCandidates.filter((container) => { + const section = sectionByContainerId.get(container.id); + 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, + droppableRects: visualDroppableRects, + }); + if (pointerCollisions.length > 0) return pointerCollisions; + + if (args.pointerCoordinates !== null) { + if (!pointerInsideBoardWidth) return []; + return closestCenter({ + ...args, + collisionRect: sourceCollisionRect, + droppableContainers: collisionCandidates, + droppableRects: visualDroppableRects, + }); + } + + return rectIntersection({ + ...args, + collisionRect: sourceCollisionRect, + droppableContainers: collisionCandidates, + droppableRects: visualDroppableRects, + }); +} 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.dnd.logic.ts b/apps/web/src/components/Sidebar.dnd.logic.ts new file mode 100644 index 000000000000..18d8f81358f0 --- /dev/null +++ b/apps/web/src/components/Sidebar.dnd.logic.ts @@ -0,0 +1,165 @@ +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; +import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled"; +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 = [ + "pinned", + "regular", + "snoozed", + "settled", +] satisfies ReadonlyArray; + +export type SidebarDndAction = + | "pin" + | "unpin" + | "unsettle" + | "unsnooze" + | "settle" + | "snooze" + | "reorder-pinned" + | "noop"; + +export type SidebarDndPreviewVariant = "card" | "slim"; + +interface SidebarDndPointerAnchor { + readonly x: number; + readonly y: number; +} + +export interface SidebarThreadDropTarget { + readonly section: SidebarDndSection; + readonly threadKey: string | null; + readonly edge: "before" | "after" | null; +} + +interface SidebarThreadDragTransactionBase { + readonly sourceThread: EnvironmentThreadShell; + readonly sourceThreadKey: string; + readonly sourceSection: SidebarDndSection; + readonly scopeKey: string | null; + readonly sourceRect: { + readonly top: number; + readonly left: number; + readonly width: number; + readonly height: number; + }; + readonly pointerAnchor: SidebarDndPointerAnchor; + readonly initialEntries: readonly SidebarDndBoardEntry[]; + readonly entries: readonly SidebarDndBoardEntry[]; + readonly sectionCounts: Readonly>; + readonly emptySections: ReadonlySet; +} + +interface SidebarThreadTargetedTransaction extends SidebarThreadDragTransactionBase { + readonly target: SidebarThreadDropTarget; +} + +export interface SidebarThreadDraggingTransaction extends SidebarThreadDragTransactionBase { + readonly phase: "dragging"; + readonly target: SidebarThreadDropTarget | null; +} + +interface SidebarThreadSnoozeChoiceTransaction extends SidebarThreadTargetedTransaction { + readonly phase: "awaiting-snooze-choice"; + readonly snoozePreset: SnoozePreset | null; +} + +type SidebarThreadDropAction = Exclude; +type SidebarThreadCommitAction = Exclude; + +export type SidebarThreadDroppingTransaction = + | (SidebarThreadTargetedTransaction & { + readonly phase: "dropping"; + readonly action: Exclude; + }) + | (SidebarThreadTargetedTransaction & { + readonly phase: "dropping"; + readonly action: "snooze"; + readonly snoozePreset: SnoozePreset; + }); + +export interface SidebarThreadCommittingTransaction extends SidebarThreadTargetedTransaction { + readonly phase: "committing"; + readonly action: SidebarThreadCommitAction; +} + +interface SidebarThreadReconcilingTransaction extends SidebarThreadTargetedTransaction { + readonly phase: "reconciling"; + readonly action: SidebarThreadCommitAction; + readonly receiptSequencesByEnvironment: ReadonlyMap< + EnvironmentThreadShell["environmentId"], + number + >; +} + +export type SidebarThreadDragTransaction = + | SidebarThreadDraggingTransaction + | SidebarThreadSnoozeChoiceTransaction + | SidebarThreadDroppingTransaction + | SidebarThreadCommittingTransaction + | SidebarThreadReconcilingTransaction; + +const DND_SECTION_ID_PREFIX = "sidebar-thread-section:"; + +export function sidebarThreadKey( + thread: Pick, +): string { + return scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); +} + +export function createSidebarDndSectionId(input: { section: SidebarDndSection }): string { + return `${DND_SECTION_ID_PREFIX}${input.section}`; +} + +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 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) { + return source === "pinned" ? "reorder-pinned" : "noop"; + } + + switch (destination) { + case "pinned": + return "pin"; + case "snoozed": + return "snooze"; + case "settled": + return "settle"; + case "regular": + return source === "pinned" ? "unpin" : source === "snoozed" ? "unsnooze" : "unsettle"; + 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; + return section === "snoozed" || section === "settled" ? "slim" : "card"; +} diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index ba75f2eaaf54..9a087f1c89cd 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1,7 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { defaultAnimateLayoutChanges, type AnimateLayoutChanges } from "@dnd-kit/sortable"; import { - animatePinnedLayoutChanges, archiveSelectedThreadEntries, buildBulkTitleRegenerationContextMenuItem, buildMultiSelectThreadContextMenuItems, @@ -29,6 +27,7 @@ import { shouldClearThreadSelectionOnMouseDown, sortLogicalProjectsForSidebar, sortSettledThreadsForSidebar, + sortSnoozedThreadsForSidebar, pinOrderKeyBetween, planPinnedReorder, sortPinnedThreadsForSidebar, @@ -55,32 +54,6 @@ import { const localEnvironmentId = EnvironmentId.make("environment-local"); -describe("animatePinnedLayoutChanges", () => { - const baseArgs: Parameters[0] = { - active: null, - containerId: "pinned-threads", - isDragging: false, - isSorting: false, - id: "thread-a", - index: 1, - items: ["thread-b", "thread-a"], - newIndex: 0, - previousItems: ["thread-a", "thread-b"], - previousContainerId: "pinned-threads", - transition: { duration: 200, easing: "ease" }, - wasDragging: true, - }; - - it("does not replay layout movement after the pointer is released", () => { - expect(defaultAnimateLayoutChanges(baseArgs)).toBe(true); - expect(animatePinnedLayoutChanges(baseArgs)).toBe(false); - }); - - it("keeps layout movement while the user is sorting", () => { - expect(animatePinnedLayoutChanges({ ...baseArgs, isSorting: true })).toBe(true); - }); -}); - describe("shouldNavigateAfterProjectRemoval", () => { const projectThreads = [{ environmentId: "environment-local", id: "thread-1" }]; @@ -809,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.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 747fc07d3daf..72021a3f3965 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,5 +1,4 @@ import * as React from "react"; -import { defaultAnimateLayoutChanges, type AnimateLayoutChanges } from "@dnd-kit/sortable"; import type { ContextMenuItem } from "@t3tools/contracts"; import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; import { @@ -24,12 +23,6 @@ export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 100; // it small; cold opens still render instantly from the cached snapshot. export const SIDEBAR_THREAD_PREWARM_LIMIT = 3; -// The list already reaches its destination through sortable transforms while -// the pointer is down. dnd-kit's default also animates the committed DOM order -// after release, replaying the same movement across every affected row. -export const animatePinnedLayoutChanges: AnimateLayoutChanges = (args) => - args.isSorting ? defaultAnimateLayoutChanges(args) : false; - type SidebarProject = { id: string; title: string; @@ -552,6 +545,16 @@ export function sortThreadsForSidebar< ); } +export function sortSnoozedThreadsForSidebar< + T extends { readonly id: string; readonly snoozedUntil?: string | null | undefined }, +>(threads: readonly T[]): T[] { + return [...threads].toSorted( + (left, right) => + firstValidTimestampMs(left.snoozedUntil) - firstValidTimestampMs(right.snoozedUntil) || + left.id.localeCompare(right.id), + ); +} + // 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 971ead810f07..f8df35d8c582 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,22 +1,5 @@ -import { autoAnimate } from "@formkit/auto-animate"; import { useAtomValue } from "@effect/atom-react"; import * as Schema from "effect/Schema"; -import { - DndContext, - PointerSensor, - closestCenter, - useSensor, - useSensors, - type DragEndEvent, -} from "@dnd-kit/core"; -import { - SortableContext, - arrayMove, - useSortable, - verticalListSortingStrategy, -} from "@dnd-kit/sortable"; -import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers"; -import { CSS } from "@dnd-kit/utilities"; import { canSnooze, changeRequestAutoSettles, @@ -64,7 +47,7 @@ import { useState, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, - type ReactNode, + type PointerEvent as ReactPointerEvent, } from "react"; import { useParams, useRouter } from "@tanstack/react-router"; @@ -105,6 +88,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"; @@ -122,15 +106,12 @@ import type { SidebarThreadSummary } from "../types"; import { cn } from "~/lib/utils"; import { buildThreadActionMenuItems } from "./threadActionMenu.logic"; import { - animatePinnedLayoutChanges, buildBulkTitleRegenerationContextMenuItem, formatWorkingDurationLabel, - firstValidTimestampMs, hasUnseenCompletion, isSidebarNestedLinkClick, isTrailingDoubleClick, orderItemsByPreferredIds, - planPinnedReorder, resolveAdjacentThreadId, resolveSettledTimestamp, resolveSidebarThreadStatus, @@ -139,9 +120,11 @@ import { resolveWorkingStartedAt, sortLogicalProjectsForSidebar, sortPinnedThreadsForSidebar, + sortSnoozedThreadsForSidebar, sortSettledThreadsForSidebar, sortThreadsForSidebar, } from "./Sidebar.logic"; +import { sidebarThreadKey, type SidebarDndSection } from "./Sidebar.dnd.logic"; import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; import { ThreadWorktreeIndicator, @@ -177,6 +160,12 @@ 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 { + SidebarThreadDndShell, + type SidebarThreadDndRowBag, + type SidebarThreadDragView, +} 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 { @@ -435,27 +424,6 @@ 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; -}) { - const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({ - id: props.id, - animateLayoutChanges: animatePinnedLayoutChanges, - }); - return props.children({ listeners, setNodeRef, transform, transition, isDragging }); -} - // 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 — @@ -705,10 +673,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { // rows. The marker can unpin the thread when the server supports pinning. 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 and transformed by dnd-kit. + dnd?: SidebarThreadDndRowBag | undefined; + dndDragView: SidebarThreadDragView | null; + dndHidden: boolean; + dndInert: boolean; // 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 @@ -765,9 +734,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], @@ -805,13 +774,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 +943,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 +1054,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 @@ -1106,6 +1098,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 ? ( @@ -1213,18 +1207,28 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ) ) : null; + const dnd = props.dnd; + const dragView = props.dndDragView; + if (variant === "slim") { return ( -
  • + ); } const diff = latestTurnDiff(thread); - const sortable = props.sortable; return ( - + ); }); @@ -2040,7 +2035,8 @@ 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. + // Snooze and settlement outrank pinning. Automatic settlement can move + // a pinned thread into the settled shelf without clearing its pin. if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) { snoozed.push(thread); } else if ( @@ -2077,11 +2073,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, }; @@ -2096,6 +2088,28 @@ 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 threadSearchInputRef = useRef(null); const [threadSearchQuery, setThreadSearchQuery] = useState(""); const [activeSearchResultIndex, setActiveSearchResultIndex] = useState(0); @@ -2539,84 +2553,12 @@ 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 pinnedDndSensors = useSensors( - useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), - ); - 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]); - 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) { - setOptimisticPinnedOrder(null); - } - }, [optimisticPinnedOrder, pinnedThreads, reorderablePinnedKeys]); const attemptPin = useCallback( (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,72 +2593,6 @@ 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 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 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; - 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. - 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], - ); // One snooze per thread at a time — same double-dispatch guard as settle. const snoozingThreadKeysRef = useRef(new Set()); const performSnooze = useCallback( @@ -2746,7 +2622,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 +2649,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 +2667,50 @@ export default function Sidebar() { [attemptUnsnooze, performSnooze, timestampFormat], ); + const threadDndActions = useMemo( + () => ({ + pinThread, + unpinThread, + reorderPinnedThread, + settleThread, + unsettleThread, + unsnoozeThread, + }), + [pinThread, reorderPinnedThread, settleThread, unpinThread, unsettleThread, unsnoozeThread], + ); + const getThreadDndCapabilities = useCallback( + (thread: EnvironmentThreadShell) => + serverConfigs.get(thread.environmentId)?.environment.capabilities, + [serverConfigs], + ); + const isRouteThread = useCallback( + (threadKey: string) => routeThreadKeyRef.current === threadKey, + [], + ); + 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 sidebarViewportRef = threadDnd.viewportRef; + const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection); const handleMultiSelectContextMenu = useCallback( async (position: { x: number; y: number }) => { @@ -2893,7 +2813,7 @@ export default function Sidebar() { : undefined, timeout: 5_000, actionProps: { - children: "Undo", + children: "Wake", onClick: () => { for (const threadRef of snoozedThreadRefs) attemptUnsnooze(threadRef); }, @@ -3033,9 +2953,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 +3245,78 @@ export default function Sidebar() { setShowJumpHints(shouldShowJumpHintsNow); }, [shouldShowJumpHintsNow]); - const attachListAutoAnimateRef = useCallback((node: HTMLUListElement | null) => { - if (!node) return; - autoAnimate(node, { duration: 150, easing: "ease-out" }); - }, []); + 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 @@ -3371,7 +3359,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 ? ( -
    • - - - 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( -
    • - -
    • , - ); - for (const thread of visibleSnoozedThreads) { - items.push(renderThreadRow(thread, "snoozed")); - } - } - if (settledThreads.length > 0) { - items.push( -
    • - -
    • , - ); - } - for (const thread of renderedSettledThreads) { - items.push(renderThreadRow(thread, "settled")); - } - return items; - })()} - {settledShelfExpanded && hiddenSettledCount > 0 ? ( -
    • - -
    • - ) : null} -
    + + } + renderThread={renderBoardThread} + snoozedShelf={{ + threadCount: snoozedThreads.length, + expanded: snoozedShelfExpanded, + onToggle: toggleSnoozedShelf, + }} + settledShelf={{ + threadCount: settledThreads.length, + expanded: settledShelfExpanded, + hiddenCount: hiddenSettledCount, + showMoreCount: Math.min(hiddenSettledCount, SETTLED_TAIL_PAGE_COUNT), + onToggle: toggleSettledShelf, + onShowMore: showMoreSettled, + }} + /> ) : 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..db0864e2e1ba --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarThreadBoard.tsx @@ -0,0 +1,491 @@ +import { + defaultDropAnimation, + DndContext, + DragOverlay, + type DndContextProps, + type DropAnimation, +} from "@dnd-kit/core"; +import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers"; +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 { useMemo, useRef, type CSSProperties, type ReactNode } from "react"; + +import { cn } from "~/lib/utils"; +import type { SidebarDndLayout } from "../../hooks/useSidebarDndLayout"; +import type { SidebarDndBoardEntry } from "../Sidebar.dnd.board"; +import { + sidebarThreadKey, + type SidebarDndPreviewVariant, + type SidebarDndSection, + type SidebarThreadDragTransaction, +} from "../Sidebar.dnd.logic"; +import { + SidebarThreadDndBoundary, + SidebarThreadDndRow, + SIDEBAR_THREAD_DRAG_PRESENTATION_HEIGHT, + type SidebarThreadDndRowBag, + type SidebarThreadDragView, +} from "./SidebarThreadDnd"; +import { SidebarThreadDropOutline } from "./SidebarThreadDropOutline"; + +type SidebarThreadDndContextProps = Pick< + DndContextProps, + | "sensors" + | "collisionDetection" + | "cancelDrop" + | "onDragStart" + | "onDragMove" + | "onDragOver" + | "onDragCancel" + | "onDragEnd" +>; + +export interface SidebarThreadRenderState { + readonly dnd: SidebarThreadDndRowBag | undefined; + readonly dragView: SidebarThreadDragView | null; + readonly hidden: boolean; + readonly inert: boolean; +} + +interface SidebarThreadBoardDnd { + readonly contextProps: SidebarThreadDndContextProps; + readonly layout: SidebarDndLayout; + readonly transaction: SidebarThreadDragTransaction | null; + readonly entries: readonly SidebarDndBoardEntry[]; + readonly threadByKey: ReadonlyMap; + 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, + source: SidebarDndSection, + destination: SidebarDndSection, + ) => boolean; +} + +function SidebarThreadShelfHeader(props: { + section: "snoozed" | "settled"; + count: number; + expanded: boolean; + dropActive: boolean; + setDroppableNodeRef: (node: HTMLElement | null) => void; + onToggle: () => void; +}) { + 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"; + const presentationExpanded = props.expanded || props.dropActive; + return ( +
    + +
    + ); +} + +function EmptySectionRail(props: { + section: SidebarDndSection; + label: string; + isOver: boolean; + setDroppableNodeRef: (node: HTMLElement | null) => void; +}) { + return ( +
    +
    + {props.label} +
    +
    + ); +} + +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; + }; +}) { + const { dnd } = props; + const emptyRailVisible = (section: SidebarDndSection) => + dnd.transaction?.phase === "dragging" && + dnd.transaction?.emptySections.has(section) === true && + dnd.canDropThreadInSection( + dnd.transaction.sourceThread, + dnd.transaction.sourceSection, + section, + ); + const pinnedSectionHasThreads = dnd.entries[1]?.kind === "thread"; + const projectedShelfCount = (section: "snoozed" | "settled", canonicalCount: number) => { + const transaction = dnd.transaction; + if (transaction === null) return canonicalCount; + const initialCount = transaction.sectionCounts[section]; + if (transaction.phase === "dragging") return initialCount; + return Math.max( + 0, + initialCount - + (transaction.sourceSection === section ? 1 : 0) + + (transaction.target.section === section ? 1 : 0), + ); + }; + const snoozedThreadCount = projectedShelfCount("snoozed", props.snoozedShelf.threadCount); + const settledThreadCount = projectedShelfCount("settled", props.settledShelf.threadCount); + + 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 = pinnedSectionHasThreads ? ( +
    + ) : ( +
    + ); + break; + case "snoozed": + content = + snoozedThreadCount > 0 || railVisible ? ( + + ) : null; + break; + case "settled": + content = + settledThreadCount > 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 sourceTransaction = + dnd.transaction?.sourceThreadKey === threadKey ? dnd.transaction : null; + const dragDisabled = + dnd.optimisticPinnedOrderActive || + !dnd.canDragThread(thread, section) || + (dnd.transaction !== null && dnd.transaction.phase !== "dragging"); + return ( + + {(rowDnd) => { + const hidden = + sourceTransaction !== null && + (sourceTransaction.phase === "dragging" || + sourceTransaction.phase === "awaiting-snooze-choice" || + sourceTransaction.phase === "dropping"); + return props.renderThread(thread, section, { + dnd: rowDnd, + dragView: null, + hidden, + inert: sourceTransaction !== null && sourceTransaction.phase !== "dragging", + }); + }} + + ); + }; + + let section: SidebarDndSection = "pinned"; + const boardEntries = dnd.entries.map((entry) => { + if (entry.kind === "boundary") { + section = entry.section; + return renderBoundary(entry); + } + return renderThread(entry, section); + }); + 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 || + dragPresentationHeight === null + ) { + return transform; + } + + const projectedIndex = dnd.sortingOverIndex ?? args.overIndex; + const activeHeight = args.rects[args.activeIndex]?.height ?? args.activeNodeRect?.height; + if (activeHeight === undefined) return transform; + + const heightDelta = dragPresentationHeight - activeHeight; + const shiftsUp = + projectedIndex > args.activeIndex && + args.index > args.activeIndex && + args.index <= projectedIndex; + const shiftsDown = + projectedIndex < args.activeIndex && + args.index < args.activeIndex && + args.index >= projectedIndex; + const extendsPastSource = + heightDelta > 0 && projectedIndex < args.activeIndex && args.index > args.activeIndex; + if (!shiftsUp && !shiftsDown && !extendsPastSource) return transform; + + return { + ...transform, + y: transform.y + (shiftsUp ? -heightDelta : heightDelta), + }; + }, + [dnd.sortingOverIndex, dragPresentationHeight], + ); + const sortableItems = useMemo(() => dnd.entries.map((entry) => entry.id), [dnd.entries]); + const listRef = useRef(null); + const overlayTransaction = + dnd.transaction?.phase === "dragging" || dnd.transaction?.phase === "awaiting-snooze-choice" + ? dnd.transaction + : null; + const overlayVariant = dnd.dragPreviewVariant; + const overlayHeight = + overlayVariant === null ? null : SIDEBAR_THREAD_DRAG_PRESENTATION_HEIGHT[overlayVariant]; + const overlay = + overlayTransaction === null || overlayVariant === null || overlayHeight === null + ? null + : props.renderThread(overlayTransaction.sourceThread, overlayTransaction.sourceSection, { + dnd: undefined, + dragView: { + variant: overlayVariant, + pointerAnchor: overlayTransaction.pointerAnchor, + }, + hidden: false, + inert: true, + }); + const overlayStyle = { + margin: 0, + padding: 0, + pointerEvents: "none", + ...(overlayTransaction === null || overlayHeight === null + ? {} + : { + top: + overlayTransaction.sourceRect.top + + overlayTransaction.pointerAnchor.y * + (overlayTransaction.sourceRect.height - overlayHeight), + left: overlayTransaction.sourceRect.left, + width: overlayTransaction.sourceRect.width, + height: overlayHeight, + transformOrigin: `${overlayTransaction.pointerAnchor.x * 100}% ${overlayTransaction.pointerAnchor.y * 100}%`, + }), + } 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(); + }; + }, + }), + [dnd.completeDropAnimation], + ); + const dragSessionActive = + dnd.transaction?.phase === "dragging" || dnd.transaction?.phase === "awaiting-snooze-choice"; + + return ( + element === dnd.layout.viewportRef.current, + }} + > +
      + {props.drafts} + + {boardEntries} + + {dnd.transaction?.phase === "dragging" && + dnd.transaction.target !== null && + dragPresentationHeight !== null && + (dnd.transaction.target.section === "snoozed" || + dnd.transaction.target.section === "settled") ? ( + + ) : null} + {props.settledShelf.expanded && props.settledShelf.hiddenCount > 0 ? ( +
    • + +
    • + ) : null} +
    + + {overlay} + +
    + ); +} diff --git a/apps/web/src/components/sidebar/SidebarThreadDnd.tsx b/apps/web/src/components/sidebar/SidebarThreadDnd.tsx new file mode 100644 index 000000000000..4ae9e84eef2f --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarThreadDnd.tsx @@ -0,0 +1,247 @@ +import { useSortable, type AnimateLayoutChanges } from "@dnd-kit/sortable"; +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, + type SidebarDndPreviewVariant, + type SidebarDndSection, +} from "../Sidebar.dnd.logic"; + +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"]; + readonly transition: string | undefined; + readonly isDragging: boolean; + readonly isSortable: boolean; +}; + +export function SidebarThreadDndRow(props: { + threadKey: string; + section: SidebarDndSection; + dragDisabled: boolean; + disableLayoutAnimation: boolean; + onNodeChange: (id: string, node: HTMLElement | null) => void; + children: (bag: SidebarThreadDndRowBag) => ReactNode; +}) { + const sortable = useSortable({ + id: props.threadKey, + disabled: { draggable: props.dragDisabled, droppable: false }, + data: { section: props.section }, + ...(props.disableLayoutAnimation ? { animateLayoutChanges: disableLayoutChanges } : {}), + }); + 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({ + section: props.section, + listeners: sortable.listeners, + setNodeRef, + transform: sortable.transform, + transition: sortable.transition, + isDragging: sortable.isDragging, + isSortable: true, + }); +} + +interface SidebarThreadDndBoundaryBag { + readonly setNodeRef: (node: HTMLElement | null) => void; + readonly setDroppableNodeRef: (node: HTMLElement | null) => void; + readonly transform: ReturnType["transform"]; + readonly transition: string | undefined; + readonly isOver: boolean; +} + +export function SidebarThreadDndBoundary(props: { + section: SidebarDndSection; + onNodeChange: (id: string, node: HTMLElement | null) => void; + children: (bag: SidebarThreadDndBoundaryBag) => ReactNode; +}) { + const id = createSidebarDndSectionId({ section: props.section }); + const sortable = useSortable({ + id, + disabled: { draggable: true, droppable: false }, + data: { section: props.section }, + }); + const setNodeRef = useCallback( + (node: HTMLElement | null) => { + sortable.setDraggableNodeRef(node); + props.onNodeChange(id, node); + }, + [id, props.onNodeChange, sortable.setDraggableNodeRef], + ); + useEffect( + () => () => { + props.onNodeChange(id, null); + }, + [id, props.onNodeChange], + ); + return props.children({ + setNodeRef, + setDroppableNodeRef: sortable.setDroppableNodeRef, + transform: sortable.transform, + transition: sortable.transition, + isOver: sortable.isOver, + }); +} + +export interface SidebarThreadDragView { + readonly variant: SidebarDndPreviewVariant; + readonly pointerAnchor: { readonly x: number; readonly y: number }; +} + +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; +}) { + const innerRef = useRef(null); + const morphAnimationRef = useRef(null); + const previousHeightRef = useRef(null); + const previewHeight = + props.dragView === null + ? null + : SIDEBAR_THREAD_DRAG_PRESENTATION_HEIGHT[props.dragView.variant]; + const pointerAnchorX = props.dragView?.pointerAnchor.x ?? null; + const pointerAnchorY = props.dragView?.pointerAnchor.y ?? null; + + useLayoutEffect(() => { + const node = innerRef.current; + if ( + node === null || + previewHeight === null || + pointerAnchorX === null || + pointerAnchorY === null + ) { + morphAnimationRef.current?.cancel(); + morphAnimationRef.current = null; + previousHeightRef.current = null; + return; + } + const previousHeight = previousHeightRef.current; + previousHeightRef.current = previewHeight; + if (previousHeight === null) return; + + const interruptedRect = + morphAnimationRef.current?.playState === "running" ? node.getBoundingClientRect() : null; + morphAnimationRef.current?.cancel(); + const settledRect = node.getBoundingClientRect(); + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; + + const fromHeight = interruptedRect?.height ?? previousHeight; + const scaleY = settledRect.height > 0 ? fromHeight / settledRect.height : 1; + const settledAnchorY = settledRect.top + pointerAnchorY * settledRect.height; + const translateY = + interruptedRect === null + ? 0 + : interruptedRect.top + pointerAnchorY * interruptedRect.height - settledAnchorY; + morphAnimationRef.current = node.animate( + [ + { + transform: `translateY(${translateY}px) scaleY(${scaleY})`, + opacity: 0.88, + }, + { transform: "translateY(0) scaleY(1)", opacity: 1 }, + ], + { duration: 160, easing: "cubic-bezier(0.2, 0, 0, 1)", fill: "both" }, + ); + }, [pointerAnchorX, pointerAnchorY, previewHeight]); + useEffect( + () => () => { + morphAnimationRef.current?.cancel(); + }, + [], + ); + + if (props.dragView === null || pointerAnchorX === null || pointerAnchorY === null) { + return props.children; + } + + return ( +
    + {props.children} +
    + ); +} 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/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..638baf4d6d32 100644 --- a/apps/web/src/components/ui/scroll-area.tsx +++ b/apps/web/src/components/ui/scroll-area.tsx @@ -1,9 +1,18 @@ "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; +} + function getVirtualizedScrollFadeClassName({ top, bottom }: { top: boolean; bottom: boolean }) { if (!top && !bottom) return undefined; @@ -28,19 +37,16 @@ function ScrollArea({ scrollbarGutter = false, hideScrollbars = false, chainVerticalScroll = false, + viewportRef, ...props -}: ScrollAreaPrimitive.Root.Props & { - scrollFade?: boolean; - scrollbarGutter?: boolean; - hideScrollbars?: boolean; - chainVerticalScroll?: boolean; -}) { +}: ScrollAreaProps) { return ( & { - fixedHeader?: React.ReactNode; -}) { +interface SidebarContentProps extends React.ComponentProps<"div"> { + readonly fixedHeader?: React.ReactNode; + readonly viewportRef?: React.Ref | undefined; +} + +function SidebarContent({ className, fixedHeader, viewportRef, ...props }: SidebarContentProps) { return ( <> {fixedHeader ?
    {fixedHeader}
    : null} - +
    ; + readonly handleEntryNodeChange: (id: string, node: HTMLElement | null) => void; + readonly getEntryNode: (id: string) => HTMLElement | null; + readonly captureEntryPosition: (id: string | null) => void; +} + +export function useSidebarDndLayout(revision: unknown): SidebarDndLayout { + const viewportRef = useRef(null); + const entryNodesRef = useRef(new Map()); + const pendingAnchorRef = useRef(null); + + 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: node.getBoundingClientRect().top }; + }, []); + + useLayoutEffect(() => { + const viewport = viewportRef.current; + const anchor = pendingAnchorRef.current; + pendingAnchorRef.current = null; + if (viewport === null || anchor === null || !anchor.node.isConnected) return; + + const delta = anchor.node.getBoundingClientRect().top - anchor.top; + if (Math.abs(delta) > 0.5) viewport.scrollTop += delta; + }, [revision]); + + useEffect(() => { + const viewport = viewportRef.current; + if (viewport === null) return; + const handleScroll = () => { + const anchor = pendingAnchorRef.current; + if (anchor === null || !anchor.node.isConnected) return; + anchor.top = anchor.node.getBoundingClientRect().top; + }; + viewport.addEventListener("scroll", handleScroll, { passive: true }); + return () => viewport.removeEventListener("scroll", handleScroll); + }, []); + + return { + viewportRef, + handleEntryNodeChange, + getEntryNode, + captureEntryPosition, + }; +} diff --git a/apps/web/src/hooks/useSidebarPinnedDnd.ts b/apps/web/src/hooks/useSidebarPinnedDnd.ts new file mode 100644 index 000000000000..f152d9255301 --- /dev/null +++ b/apps/web/src/hooks/useSidebarPinnedDnd.ts @@ -0,0 +1,243 @@ +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 SidebarThreadDroppingTransaction, +} from "../components/Sidebar.dnd.logic"; +import { orderItemsByPreferredIds, planPinnedReorder } from "../components/Sidebar.logic"; +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 assignments: readonly SidebarPinnedAssignment[]; +} + +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; + 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; + 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; + 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, + ); + if (membershipChanged || foreignKeyLanded || allAssignmentsLanded) { + 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: SidebarThreadDroppingTransaction): SidebarPinnedInsertionPlan | null => { + if (transaction.sourceSection === "pinned" || transaction.target.section !== "pinned") { + return null; + } + 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); + if (targetIndex === -1) return null; + insertionIndex = targetIndex + (transaction.target.edge === "after" ? 1 : 0); + } else { + 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; + const resolvedAssignments: SidebarPinnedAssignment[] = []; + 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; + } + resolvedAssignments.push({ + thread, + threadKey: assignment.id, + orderKey: assignment.orderKey, + }); + } + return { assignments: resolvedAssignments }; + }, + [input.allPinnedThreads, input.canPinWithOrder, input.canReorder], + ); + + return { + optimisticPinnedOrder, + orderedPinnedThreads, + 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..d3750863ede3 --- /dev/null +++ b/apps/web/src/hooks/useSidebarThreadDnd.ts @@ -0,0 +1,963 @@ +import { + getClientRect, + PointerSensor, + useSensor, + useSensors, + type CollisionDetection, + 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 { flushSync } from "react-dom"; + +import { readLocalApi } from "../localApi"; +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { environmentSnapshotAtom } from "../state/shell"; +import { + buildSidebarDndBoardEntries, + findSidebarDndBoardThreadSection, + findSortedSidebarDndDropTarget, + moveSidebarDndBoardThread, + type SidebarDndBoardEntry, +} from "../components/Sidebar.dnd.board"; +import { detectSidebarThreadCollision } from "../components/Sidebar.dnd.collision"; +import { + parseSidebarDndSectionId, + resolveSidebarDndAction, + resolveSidebarDndPreviewVariant, + SIDEBAR_DND_SECTIONS, + type SidebarDndAction, + type SidebarDndSection, + type SidebarThreadCommittingTransaction, + type SidebarThreadDropTarget, + type SidebarThreadDroppingTransaction, + type SidebarThreadDraggingTransaction, + type SidebarThreadDragTransaction, +} from "../components/Sidebar.dnd.logic"; +import { + sortSettledThreadsForSidebar, + sortSnoozedThreadsForSidebar, + sortThreadsForSidebar, +} from "../components/Sidebar.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 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 }; + +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, + reorderPinnedThread: input.actions.reorderPinnedThread, + canPinWithOrder, + canReorder: canReorderPinnedThread, + }); + const { + optimisticPinnedOrder, + orderedPinnedThreads, + pinnedReorderInFlightRef, + handlePinnedReorder, + planPinnedInsertion, + } = pinnedDnd; + 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?.phase === "dragging"; + 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 resolveSortedTarget = useCallback( + ( + current: SidebarThreadDragTransaction, + destination: "regular" | "snoozed" | "settled", + snoozedUntil: string | null = null, + ) => { + let threads: readonly EnvironmentThreadShell[]; + switch (destination) { + case "regular": + threads = sortThreadsForSidebar([...input.activeThreads, current.sourceThread]); + break; + case "snoozed": + threads = sortSnoozedThreadsForSidebar([ + ...input.visibleSnoozedThreads, + { ...current.sourceThread, snoozedUntil }, + ]); + break; + case "settled": + threads = sortSettledThreadsForSidebar([ + ...input.renderedSettledThreads, + { ...current.sourceThread, settledAt: new Date().toISOString() }, + ]); + break; + default: { + const _exhaustive: never = destination; + return _exhaustive; + } + } + return findSortedSidebarDndDropTarget({ + section: destination, + sourceThreadKey: current.sourceThreadKey, + threads, + }); + }, + [input.activeThreads, input.renderedSettledThreads, input.visibleSnoozedThreads], + ); + + const currentSourceThread = useCallback((current: SidebarThreadDragTransaction) => { + const source = allThreadByKeyRef.current.get(current.sourceThreadKey); + if ( + source === undefined || + source.archivedAt !== null || + canonicalSectionByThreadKeyRef.current.get(current.sourceThreadKey) !== current.sourceSection + ) { + return null; + } + return source; + }, []); + const dropStillValid = useCallback( + (current: SidebarThreadDragTransaction, target: SidebarThreadDropTarget) => { + const source = currentSourceThread(current); + return ( + source !== null && canDropThreadInSection(source, current.sourceSection, target.section) + ); + }, + [canDropThreadInSection, currentSourceThread], + ); + const clearTransaction = useCallback(() => { + const current = transactionRef.current; + snoozeDropEpochRef.current += 1; + if (current?.phase === "awaiting-snooze-choice") { + void readLocalApi()?.contextMenu.close(); + } + pointerCoordinatesRef.current = null; + setTransaction(null); + }, [setTransaction]); + const finishTransaction = useCallback(() => { + const current = transactionRef.current; + if (current !== null) captureInsertionPosition(current.entries, current.sourceThreadKey); + clearTransaction(); + }, [captureInsertionPosition, clearTransaction]); + const beginReconciliation = useCallback( + (reconciliation: { + transaction: SidebarThreadCommittingTransaction; + receiptSequencesByEnvironment: ReadonlyMap; + }) => { + if (transactionRef.current !== reconciliation.transaction) return; + setTransaction({ + ...reconciliation.transaction, + phase: "reconciling", + receiptSequencesByEnvironment: reconciliation.receiptSequencesByEnvironment, + }); + }, + [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: SidebarThreadDroppingTransaction, + action: Exclude, + pinnedPlan: SidebarPinnedInsertionPlan | null, + ) => { + void (async () => { + if (!dropStillValid(current, current.target)) { + finishTransaction(); + return; + } + const committing: SidebarThreadCommittingTransaction = { + ...current, + phase: "committing", + action, + }; + setTransaction(committing); + const threadRef = scopeThreadRef( + current.sourceThread.environmentId, + current.sourceThread.id, + ); + const receiptSequences = new Map(); + if (action === "pin") { + if (pinnedPlan === null) { + finishTransaction(); + return; + } + for (const assignment of pinnedPlan.assignments) { + if (assignment.threadKey === current.sourceThreadKey) continue; + const result = await input.actions.reorderPinnedThread( + scopeThreadRef(assignment.thread.environmentId, assignment.thread.id), + assignment.orderKey, + ); + if (result._tag === "Failure") { + finishTransaction(); + reportDropFailure("Failed to prepare pinned order", result); + return; + } + receiptSequences.set(assignment.thread.environmentId, result.value.sequence); + } + const sourceAssignment = pinnedPlan.assignments.find( + (assignment) => assignment.threadKey === 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; + } + receiptSequences.set(current.sourceThread.environmentId, result.value.sequence); + beginReconciliation({ + transaction: committing, + receiptSequencesByEnvironment: receiptSequences, + }); + 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?.(); + } + receiptSequences.set(current.sourceThread.environmentId, result.value.sequence); + beginReconciliation({ + transaction: committing, + receiptSequencesByEnvironment: receiptSequences, + }); + })(); + }, + [ + beginReconciliation, + finishTransaction, + input.actions, + input.isRouteThread, + input.planForwardNavigation, + reportDropFailure, + setTransaction, + dropStillValid, + ], + ); + const commitSnoozeDrop = useCallback( + (current: Extract) => { + void (async () => { + if (!dropStillValid(current, current.target)) { + finishTransaction(); + return; + } + const { snoozePreset, ...drop } = current; + const committing: SidebarThreadCommittingTransaction = { + ...drop, + phase: "committing", + }; + setTransaction(committing); + const threadRef = scopeThreadRef( + current.sourceThread.environmentId, + current.sourceThread.id, + ); + const outcome = await input.performSnooze(threadRef, snoozePreset); + 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(snoozePreset.snoozedUntil, new Date(), input.timestampFormat)}`, + timeout: 5_000, + actionProps: { + children: "Wake", + onClick: () => input.attemptUnsnooze(threadRef), + }, + }), + ); + beginReconciliation({ + transaction: committing, + receiptSequencesByEnvironment: new Map([ + [current.sourceThread.environmentId, outcome.sequence], + ]), + }); + })(); + }, + [ + beginReconciliation, + finishTransaction, + input.attemptUnsnooze, + input.performSnooze, + input.timestampFormat, + setTransaction, + dropStillValid, + ], + ); + const openSnoozeDropMenu = useCallback( + async ( + current: SidebarThreadDraggingTransaction, + position: { x: number; y: number }, + ): Promise => { + const target = current.target; + if (target === null) return false; + const entries = moveSidebarDndBoardThread({ + entries: current.initialEntries, + threadKey: current.sourceThreadKey, + target, + }); + const epoch = snoozeDropEpochRef.current + 1; + snoozeDropEpochRef.current = epoch; + captureInsertionPosition(entries, current.sourceThreadKey); + setTransaction({ + ...current, + phase: "awaiting-snooze-choice", + entries, + 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(); + const menuPresets = resolveSnoozePresets(new Date(), input.timestampFormat); + 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 || + preset === undefined || + !dropStillValid(current, target) + ) { + restoreCanceledDropPresentation(); + return false; + } + const projectedTarget = resolveSortedTarget(current, "snoozed", preset.snoozedUntil); + const projectedEntries = moveSidebarDndBoardThread({ + entries: current.initialEntries, + threadKey: current.sourceThreadKey, + target: projectedTarget, + }); + captureInsertionPosition(projectedEntries, current.sourceThreadKey); + setTransaction({ + ...current, + phase: "awaiting-snooze-choice", + entries: projectedEntries, + target: projectedTarget, + snoozePreset: preset, + }); + return true; + }, + [ + captureInsertionPosition, + input.timestampFormat, + resolveSortedTarget, + setTransaction, + dropStillValid, + ], + ); + + const collisionDetection = useCallback( + (args) => { + if (args.pointerCoordinates !== null) pointerCoordinatesRef.current = args.pointerCoordinates; + const current = transactionRef.current; + if (current === null || current.phase !== "dragging") return []; + const sourceThread = currentSourceThread(current); + if (sourceThread === null) return []; + return detectSidebarThreadCollision({ + args, + transaction: current, + sourceThread, + reorderablePinnedKeys: input.reorderablePinnedKeys, + canDropThreadInSection, + }); + }, + [canDropThreadInSection, currentSourceThread, input.reorderablePinnedKeys], + ); + 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 = layout.getEntryNode(threadKey); + if (sourceThread === undefined || sourceNode === null) return; + const sourceSection = canonicalSectionByThreadKeyRef.current.get(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 sectionCounts = { + pinned: orderedPinnedThreads.length, + regular: input.activeThreads.length, + snoozed: input.snoozedThreads.length, + settled: input.settledThreads.length, + } satisfies Readonly>; + const initialEntries = canonicalEntriesRef.current; + setTransaction({ + phase: "dragging", + sourceThread, + sourceThreadKey: threadKey, + sourceSection, + scopeKey: input.scopeKey, + sourceRect: { + top: sourceRect.top, + left: sourceRect.left, + width: sourceRect.width, + height: sourceRect.height, + }, + 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)), + }, + initialEntries, + entries: initialEntries, + sectionCounts, + emptySections: new Set( + SIDEBAR_DND_SECTIONS.filter((section) => sectionCounts[section] === 0), + ), + target: { section: sourceSection, threadKey, edge: null }, + }); + }, + [ + canDragThread, + layout, + input.activeThreads, + input.scopeKey, + input.settledThreads.length, + input.snoozedThreads.length, + orderedPinnedThreads, + pinnedReorderInFlightRef, + setTransaction, + ], + ); + const resolveDropTarget = useCallback( + ( + current: SidebarThreadDraggingTransaction, + over: DragMoveEvent["over"], + ): SidebarThreadDropTarget | null => { + if (over === null) return null; + const sectionDrop = parseSidebarDndSectionId(over.id); + const targetThreadKey = sectionDrop === null && typeof over.id === "string" ? over.id : null; + const destination = + sectionDrop ?? + (targetThreadKey === 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; + if (resolvedThreadKey !== null) { + if (destination === "pinned" && !input.reorderablePinnedKeys.has(resolvedThreadKey)) { + return null; + } + if (current.sourceSection === "pinned" && destination === "pinned") { + const sourceIndex = current.initialEntries.findIndex( + (entry) => entry.id === current.sourceThreadKey, + ); + const targetIndex = current.initialEntries.findIndex( + (entry) => entry.id === resolvedThreadKey, + ); + targetEdge = + sourceIndex === targetIndex ? null : targetIndex < sourceIndex ? "before" : "after"; + } else { + 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, layout], + ); + 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.target === null) return; + setTransaction({ + ...current, + target: null, + }); + return; + } + if ( + current.target?.section === target.section && + current.target.threadKey === target.threadKey && + current.target.edge === target.edge + ) { + return; + } + setTransaction({ + ...current, + target, + }); + }, + [resolveDropTarget, setTransaction], + ); + const handleCancelDrop = useCallback(async () => { + const current = transactionRef.current; + const target = current?.target ?? null; + if ( + current === null || + current.phase !== "dragging" || + target === null || + !dropStillValid(current, target) || + resolveSidebarDndAction({ + source: current.sourceSection, + destination: target.section, + }) !== "snooze" + ) { + return false; + } + const releasePoint = pointerCoordinatesRef.current; + if (releasePoint === null) return true; + return !(await openSnoozeDropMenu(current, releasePoint)); + }, [dropStillValid, openSnoozeDropMenu]); + const completeDropAnimation = useCallback(() => { + const current = transactionRef.current; + if (current === null || current.phase !== "dropping") return; + if (!dropStillValid(current, current.target)) { + finishTransaction(); + return; + } + + if (current.action === "reorder-pinned") { + const firstPinnedThread = current.entries.find( + (entry) => + entry.kind === "thread" && + entry.id !== current.sourceThreadKey && + findSidebarDndBoardThreadSection(current.entries, entry.id) === "pinned", + ); + handlePinnedReorder( + current.sourceThreadKey, + current.target.threadKey ?? firstPinnedThread?.id ?? null, + current.target.threadKey === null ? "before" : current.target.edge, + ); + clearTransaction(); + return; + } + if (current.action === "snooze") { + commitSnoozeDrop(current); + return; + } + const pinnedPlan = current.action === "pin" ? planPinnedInsertion(current) : null; + if (current.action === "pin" && pinnedPlan === null) { + finishTransaction(); + return; + } + commitLifecycleDrop(current, current.action, pinnedPlan); + }, [ + clearTransaction, + commitLifecycleDrop, + commitSnoozeDrop, + dropStillValid, + finishTransaction, + handlePinnedReorder, + planPinnedInsertion, + ]); + const handleDragEnd = useCallback( + (_event: DragEndEvent) => { + const current = transactionRef.current; + pointerCoordinatesRef.current = null; + const target = current?.target ?? null; + if ( + current === null || + (current.phase !== "dragging" && current.phase !== "awaiting-snooze-choice") || + target === null || + !dropStillValid(current, target) + ) { + finishTransaction(); + return; + } + const action = resolveSidebarDndAction({ + source: current.sourceSection, + destination: target.section, + }); + if (action === "noop") { + finishTransaction(); + return; + } + if (action === "snooze") { + if (current.phase !== "awaiting-snooze-choice" || current.snoozePreset === null) { + finishTransaction(); + return; + } + setTransaction({ + ...current, + phase: "dropping", + action, + snoozePreset: current.snoozePreset, + }); + return; + } + const projectedTarget = + target.section === "pinned" ? target : resolveSortedTarget(current, target.section); + const projectedEntries = moveSidebarDndBoardThread({ + entries: current.initialEntries, + threadKey: current.sourceThreadKey, + target: projectedTarget, + }); + if (action !== "reorder-pinned" && current.phase === "dragging") { + captureInsertionPosition(projectedEntries, current.sourceThreadKey); + } + setTransaction({ + ...current, + phase: "dropping", + action, + entries: projectedEntries, + target: projectedTarget, + }); + }, + [ + captureInsertionPosition, + dropStillValid, + finishTransaction, + resolveSortedTarget, + setTransaction, + ], + ); + + 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.isSearchingThreads, input.scopeKey, input.threads, transaction]); + useLayoutEffect(() => { + if (transaction === null) return; + if (input.isSearchingThreads || input.scopeKey !== transaction.scopeKey) { + clearTransaction(); + return; + } + if ( + transaction.phase !== "dragging" && + transaction.phase !== "dropping" && + transaction.phase !== "awaiting-snooze-choice" + ) { + return; + } + if (currentSourceThread(transaction) === null) finishTransaction(); + }, [ + clearTransaction, + finishTransaction, + input.isSearchingThreads, + input.scopeKey, + input.threads, + currentSourceThread, + transaction, + ]); + + const dragPreviewVariant = + transaction !== null && + (transaction.phase === "dragging" || transaction.phase === "awaiting-snooze-choice") + ? resolveSidebarDndPreviewVariant({ + source: transaction.sourceSection, + destination: transaction.target?.section ?? null, + }) + : null; + const sortingOverIndex = useMemo(() => { + if (transaction === null || transaction.phase !== "dragging" || transaction.target === null) { + return null; + } + if (transaction.sourceSection === "pinned" && transaction.target.section === "pinned") { + 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: layout.viewportRef, + boardDnd: { + contextProps: { + sensors, + collisionDetection, + cancelDrop: handleCancelDrop, + onDragStart: handleDragStart, + onDragMove: (event: DragMoveEvent) => updateDragTarget(event.over), + onDragOver: (event: DragOverEvent) => updateDragTarget(event.over), + onDragCancel: () => finishTransaction(), + onDragEnd: handleDragEnd, + }, + layout, + transaction, + entries: displayedEntries, + threadByKey: input.allThreadByKey, + optimisticPinnedOrderActive: optimisticPinnedOrder !== null, + dragPreviewVariant, + sortingOverIndex, + completeDropAnimation, + canDragThread, + canDropThreadInSection, + }, + }; +} diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index 4024fb6b7b7b..a14b00ef5b47 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -164,7 +164,7 @@ export function useThreadActionMenu(input: { title: `Snoozed until ${snoozeWakeDescription(preset.snoozedUntil, new Date(), timestampFormat)}`, timeout: 5_000, actionProps: { - children: "Undo", + children: "Wake", onClick: () => { 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..86e73c7d40ab --- /dev/null +++ b/docs/internals/sidebar-thread-dnd.md @@ -0,0 +1,194 @@ +# Sidebar thread drag and drop + +> For maintainers. Using T3 Code? See [docs/user](../user/). + +Status: accepted + +## Context + +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 drag board. Mobile keeps its menu actions. + +### One sortable board + +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 +Pinned threads +Regular boundary +Regular threads +Snoozed boundary +Snoozed threads +Settled boundary +Settled threads +``` + +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. + +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. + +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. + +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. 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 + +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 for Pinned and +Settled, the empty Snoozed header appears, projected entries mount on release, and canonical entries +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. + +### 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, prepares any neighbor keys, then pins the source with its key. Pinned preserves that +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 +a duration sorts the row by wake time before the drop animation. Cancelling restores the source +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. +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 + +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. + +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 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 every hover + +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. + +### Move the real row under the pointer + +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 insertion index. Another line duplicates the same state. Category +outlines communicate shelf ownership without consuming list space. + +### Target locks and category-specific scroll corrections + +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 274f596bbc50..e3fe2a7a4558 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, 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, Snoozed, 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 **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 +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)),