import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";import { planPinnedReorder } from "@t3tools/client-runtime/state/thread-sort";import { effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled";import type { EnvironmentId } from "@t3tools/contracts"; export type ThreadMoveDestination = | "up" | "down" | { readonly targetId: string | null; readonly section?: "pinned" | "active" | "settled"; readonly placement: "before" | "after"; }; /** Resolve against stable row identities, including rows hidden by a filter. */export function threadOrderAfterMove( orderedIds: readonly string[], movedId: string, destination: ThreadMoveDestination,): string[] | null { if (typeof destination === "object" && destination.section === "settled") return null; const from = orderedIds.indexOf(movedId); if (from < 0 && (typeof destination === "string" || destination.section === undefined)) return null; const result = orderedIds.filter((id) => id !== movedId); let to: number; if (typeof destination === "string") { to = from + (destination === "up" ? -1 : 1); if (to < 0 || to >= orderedIds.length) return null; } else { if (destination.targetId === null) { if (destination.section === undefined) return null; to = destination.placement === "before" ? 0 : result.length; } else { const target = result.indexOf(destination.targetId); if (target < 0) return null; to = target + (destination.placement === "after" ? 1 : 0); } } if (to === from) return null; result.splice(to, 0, movedId); return result;} type OrderRow = Pick< EnvironmentThreadShell, | "id" | "environmentId" | "pinOrderKey" | "activeOrderKey" | "createdAt" | "unsettledAt" | "pinnedAt">; export interface PendingThreadOrder { readonly section: "pinned" | "active"; readonly orderedIds: readonly string[]; readonly before: ReadonlyMap<string, { readonly key: string | null; readonly anchor: string }>; readonly assignments: ReadonlyMap<string, string>; readonly confirmed: ReadonlySet<string>; readonly commandsComplete: boolean;} function rowId(row: OrderRow): string { return `${row.environmentId}:${row.id}`;} function rowOrder(row: OrderRow, section: PendingThreadOrder["section"]) { return { key: (section === "pinned" ? row.pinOrderKey : row.activeOrderKey) ?? null, anchor: section === "pinned" ? (row.pinnedAt ?? "") : (row.unsettledAt ?? row.createdAt), };} /** Keep every visible row as an anchor, but only offer plans whose key writes * are supported. Menu availability and execution use this same planner. */export function createThreadMovePlanner(input: { readonly ordered: readonly OrderRow[]; readonly allThreads?: readonly OrderRow[]; readonly section: PendingThreadOrder["section"]; readonly reorderableEnvironmentIds: ReadonlySet<EnvironmentId>;}) { const orderedIds = input.ordered.map(rowId); const keysById = new Map( (input.allThreads ?? input.ordered).map((row) => [ rowId(row), rowOrder(row, input.section).key, ]), ); const writableIds = new Set( (input.allThreads ?? input.ordered) .filter((row) => input.reorderableEnvironmentIds.has(row.environmentId)) .map(rowId), ); return (movedId: string, direction: ThreadMoveDestination) => { if (!writableIds.has(movedId)) return null; const nextIds = threadOrderAfterMove(orderedIds, movedId, direction); if (nextIds === null) return null; const assignments = planPinnedReorder({ orderedIds: nextIds, keysById, movedId }); return assignments === null || assignments.length === 0 || assignments.some((assignment) => !writableIds.has(assignment.id)) ? null : assignments; };} export function createPendingThreadOrder(input: { readonly section: PendingThreadOrder["section"]; readonly ordered: readonly OrderRow[]; readonly movedId: string; readonly direction: ThreadMoveDestination; readonly assignments: readonly { readonly id: string; readonly orderKey: string }[];}): PendingThreadOrder { const orderedIds = threadOrderAfterMove(input.ordered.map(rowId), input.movedId, input.direction); if (orderedIds === null) throw new Error("Cannot begin an invalid thread move"); return { section: input.section, orderedIds, before: new Map(input.ordered.map((row) => [rowId(row), rowOrder(row, input.section)])), assignments: new Map(input.assignments.map(({ id, orderKey }) => [id, orderKey])), confirmed: new Set(), commandsComplete: false, };} /** Receipts and shell updates arrive independently. Only our own key writes * may pass through the hold; membership and other arrangement changes win. */export function reconcilePendingThreadOrder( pending: PendingThreadOrder, ordered: readonly OrderRow[],): PendingThreadOrder | null { if (ordered.length !== pending.before.size) return null; const confirmed = new Set(pending.confirmed); for (const row of ordered) { const id = rowId(row); const before = pending.before.get(id); const current = rowOrder(row, pending.section); if (before === undefined || current.anchor !== before.anchor) return null; const assigned = pending.assignments.get(id); if (assigned !== undefined && current.key === assigned) confirmed.add(id); else if (current.key !== before.key || confirmed.has(id)) return null; } if (pending.commandsComplete && confirmed.size === pending.assignments.size) return null; return confirmed.size === pending.confirmed.size ? pending : { ...pending, confirmed };} /** Apply the full section's pending order after search/environment filtering. */export function applyPendingThreadOrder<T extends OrderRow>( rows: readonly T[], section: PendingThreadOrder["section"], pending: PendingThreadOrder | null | undefined,): T[] { if (pending == null || pending.section !== section) return [...rows]; const rank = new Map(pending.orderedIds.map((id, index) => [id, index])); return [...rows].sort( (left, right) => (rank.get(rowId(left)) ?? Infinity) - (rank.get(rowId(right)) ?? Infinity), );} /** Match desktop re-entry: a pin wakes the thread on the server; Active clears * each underlying parked state before assigning its destination order key. */export function threadDropLifecycle( thread: EnvironmentThreadShell, section: "pinned" | "active", now: string,) { if (section === "pinned") return { pin: true, unpin: false, unsettle: false, unsnooze: false }; return { pin: false, unpin: thread.pinnedAt != null, unsettle: thread.settledOverride === "settled", unsnooze: effectiveSnoozed(thread, { now }), };} export type ThreadDragSection = "pinned" | "active" | "snoozed" | "settled"; /** The action shown during hover describes the lifecycle change made on drop. */export function threadDragAction(source: ThreadDragSection, destination: ThreadDragSection) { if (destination === "snoozed") return null; if (destination === "settled") return source === "settled" ? null : "Settle"; if (source === destination) return "Reorder"; if (destination === "pinned") return "Pin"; if (source === "pinned") return "Unpin"; if (source === "settled") return "Unsettle"; return "Unsnooze";}
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
import { planPinnedReorder } from "@t3tools/client-runtime/state/thread-sort";
import { effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled";
import type { EnvironmentId } from "@t3tools/contracts";
export type ThreadMoveDestination =
| "up"
| "down"
| {
readonly targetId: string | null;
readonly section?: "pinned" | "active" | "settled";
readonly placement: "before" | "after";
};
/** Resolve against stable row identities, including rows hidden by a filter. */
export function threadOrderAfterMove(
orderedIds: readonly string[],
movedId: string,
destination: ThreadMoveDestination,
): string[] | null {
if (typeof destination === "object" && destination.section === "settled") return null;
const from = orderedIds.indexOf(movedId);
if (from < 0 && (typeof destination === "string" || destination.section === undefined))
return null;
const result = orderedIds.filter((id) => id !== movedId);
let to: number;
if (typeof destination === "string") {
to = from + (destination === "up" ? -1 : 1);
if (to < 0 || to >= orderedIds.length) return null;
} else {
if (destination.targetId === null) {
if (destination.section === undefined) return null;
to = destination.placement === "before" ? 0 : result.length;
} else {
const target = result.indexOf(destination.targetId);
if (target < 0) return null;
to = target + (destination.placement === "after" ? 1 : 0);
}
}
if (to === from) return null;
result.splice(to, 0, movedId);
return result;
}
type OrderRow = Pick<
EnvironmentThreadShell,
| "id"
| "environmentId"
| "pinOrderKey"
| "activeOrderKey"
| "createdAt"
| "unsettledAt"
| "pinnedAt"
>;
export interface PendingThreadOrder {
readonly section: "pinned" | "active";
readonly orderedIds: readonly string[];
readonly before: ReadonlyMap<string, { readonly key: string | null; readonly anchor: string }>;
readonly assignments: ReadonlyMap<string, string>;
readonly confirmed: ReadonlySet<string>;
readonly commandsComplete: boolean;
}
function rowId(row: OrderRow): string {
return `${row.environmentId}:${row.id}`;
}
function rowOrder(row: OrderRow, section: PendingThreadOrder["section"]) {
return {
key: (section === "pinned" ? row.pinOrderKey : row.activeOrderKey) ?? null,
anchor: section === "pinned" ? (row.pinnedAt ?? "") : (row.unsettledAt ?? row.createdAt),
};
}
/** Keep every visible row as an anchor, but only offer plans whose key writes
* are supported. Menu availability and execution use this same planner. */
export function createThreadMovePlanner(input: {
readonly ordered: readonly OrderRow[];
readonly allThreads?: readonly OrderRow[];
readonly section: PendingThreadOrder["section"];
readonly reorderableEnvironmentIds: ReadonlySet<EnvironmentId>;
}) {
const orderedIds = input.ordered.map(rowId);
const keysById = new Map(
(input.allThreads ?? input.ordered).map((row) => [
rowId(row),
rowOrder(row, input.section).key,
]),
);
const writableIds = new Set(
(input.allThreads ?? input.ordered)
.filter((row) => input.reorderableEnvironmentIds.has(row.environmentId))
.map(rowId),
);
return (movedId: string, direction: ThreadMoveDestination) => {
if (!writableIds.has(movedId)) return null;
const nextIds = threadOrderAfterMove(orderedIds, movedId, direction);
if (nextIds === null) return null;
const assignments = planPinnedReorder({ orderedIds: nextIds, keysById, movedId });
return assignments === null ||
assignments.length === 0 ||
assignments.some((assignment) => !writableIds.has(assignment.id))
? null
: assignments;
};
}
export function createPendingThreadOrder(input: {
readonly section: PendingThreadOrder["section"];
readonly ordered: readonly OrderRow[];
readonly movedId: string;
readonly direction: ThreadMoveDestination;
readonly assignments: readonly { readonly id: string; readonly orderKey: string }[];
}): PendingThreadOrder {
const orderedIds = threadOrderAfterMove(input.ordered.map(rowId), input.movedId, input.direction);
if (orderedIds === null) throw new Error("Cannot begin an invalid thread move");
return {
section: input.section,
orderedIds,
before: new Map(input.ordered.map((row) => [rowId(row), rowOrder(row, input.section)])),
assignments: new Map(input.assignments.map(({ id, orderKey }) => [id, orderKey])),
confirmed: new Set(),
commandsComplete: false,
};
}
/** Receipts and shell updates arrive independently. Only our own key writes
* may pass through the hold; membership and other arrangement changes win. */
export function reconcilePendingThreadOrder(
pending: PendingThreadOrder,
ordered: readonly OrderRow[],
): PendingThreadOrder | null {
if (ordered.length !== pending.before.size) return null;
const confirmed = new Set(pending.confirmed);
for (const row of ordered) {
const id = rowId(row);
const before = pending.before.get(id);
const current = rowOrder(row, pending.section);
if (before === undefined || current.anchor !== before.anchor) return null;
const assigned = pending.assignments.get(id);
if (assigned !== undefined && current.key === assigned) confirmed.add(id);
else if (current.key !== before.key || confirmed.has(id)) return null;
}
if (pending.commandsComplete && confirmed.size === pending.assignments.size) return null;
return confirmed.size === pending.confirmed.size ? pending : { ...pending, confirmed };
}
/** Apply the full section's pending order after search/environment filtering. */
export function applyPendingThreadOrder<T extends OrderRow>(
rows: readonly T[],
section: PendingThreadOrder["section"],
pending: PendingThreadOrder | null | undefined,
): T[] {
if (pending == null || pending.section !== section) return [...rows];
const rank = new Map(pending.orderedIds.map((id, index) => [id, index]));
return [...rows].sort(
(left, right) => (rank.get(rowId(left)) ?? Infinity) - (rank.get(rowId(right)) ?? Infinity),
);
}
/** Match desktop re-entry: a pin wakes the thread on the server; Active clears
* each underlying parked state before assigning its destination order key. */
export function threadDropLifecycle(
thread: EnvironmentThreadShell,
section: "pinned" | "active",
now: string,
) {
if (section === "pinned") return { pin: true, unpin: false, unsettle: false, unsnooze: false };
return {
pin: false,
unpin: thread.pinnedAt != null,
unsettle: thread.settledOverride === "settled",
unsnooze: effectiveSnoozed(thread, { now }),
};
}
export type ThreadDragSection = "pinned" | "active" | "snoozed" | "settled";
/** The action shown during hover describes the lifecycle change made on drop. */
export function threadDragAction(source: ThreadDragSection, destination: ThreadDragSection) {
if (destination === "snoozed") return null;
if (destination === "settled") return source === "settled" ? null : "Settle";
if (source === destination) return "Reorder";
if (destination === "pinned") return "Pin";
if (source === "pinned") return "Unpin";
if (source === "settled") return "Unsettle";
return "Unsnooze";
}