From 64fafbdfcf91d3c44b533f5b8febbe8d6b19d482 Mon Sep 17 00:00:00 2001 From: Akshar Patel <123344143+AksharP5@users.noreply.github.com> Date: Sun, 6 Sep 2026 13:09:26 -0400 Subject: [PATCH 01/71] perf(web): speed up folder menu sorting (#10190) --- apps/web/src/components/files/filePath.test.ts | 11 +++++++++-- apps/web/src/components/files/filePath.ts | 4 +++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/files/filePath.test.ts b/apps/web/src/components/files/filePath.test.ts index 3018aa91a776..b501562fee00 100644 --- a/apps/web/src/components/files/filePath.test.ts +++ b/apps/web/src/components/files/filePath.test.ts @@ -67,8 +67,15 @@ describe("fileBreadcrumbChildren", () => { ]); }); - it("uses natural file-name ordering", () => { - expect(fileBreadcrumbChildren(entries, "src/lib").map((entry) => entry.label)).toEqual([ + it("uses natural file-name ordering and preserves input order for equivalent names", () => { + const files = ["file10.ts", "File2.ts", "file02.ts", "file2.ts"].map((name) => ({ + path: `src/lib/${name}`, + kind: "file" as const, + })); + + expect(fileBreadcrumbChildren(files, "src/lib").map((entry) => entry.label)).toEqual([ + "File2.ts", + "file02.ts", "file2.ts", "file10.ts", ]); diff --git a/apps/web/src/components/files/filePath.ts b/apps/web/src/components/files/filePath.ts index aea8266ec8b8..e819315310b8 100644 --- a/apps/web/src/components/files/filePath.ts +++ b/apps/web/src/components/files/filePath.ts @@ -36,6 +36,7 @@ export function fileBreadcrumbChildren( entries: readonly ProjectEntry[], directoryPath: string, ): FileBreadcrumbChild[] { + let collator: Intl.Collator | undefined; const prefix = directoryPath ? `${directoryPath}/` : ""; return entries .flatMap((entry) => { @@ -46,10 +47,11 @@ export function fileBreadcrumbChildren( }) .toSorted((left, right) => { if (left.kind !== right.kind) return left.kind === "directory" ? -1 : 1; - return left.label.localeCompare(right.label, undefined, { + collator ??= new Intl.Collator(undefined, { numeric: true, sensitivity: "base", }); + return collator.compare(left.label, right.label); }); } From 29d03ec556e94d5f847644730daf75ba4aa20678 Mon Sep 17 00:00:00 2001 From: Exotic <118054752+extoci@users.noreply.github.com> Date: Sun, 6 Sep 2026 20:19:01 +0300 Subject: [PATCH 02/71] style(web): fix inconsistencies in new settings layouts (#10177) Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com> --- apps/web/src/components/settings/ConnectionsSettings.tsx | 7 +++++-- apps/web/src/components/settings/SourceControlSettings.tsx | 4 ++-- apps/web/src/components/settings/itemRows.ts | 4 ++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index b5de1e124801..ad7665651171 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -395,7 +395,7 @@ function formatDesktopSshConnectionError(error: unknown): string { return withoutTaggedErrorPrefix.trim() || fallback; } -const ENDPOINT_ROW_CLASSNAME = "rounded-xl px-3 py-2.5 sm:px-4"; +const ENDPOINT_ROW_CLASSNAME = "first:rounded-t-xl last:rounded-b-xl px-3 py-2.5 sm:px-4"; type AccessSectionPresentation = "current" | "endpoint-rail"; @@ -405,7 +405,10 @@ function accessRowClassName(_presentation: AccessSectionPresentation) { function endpointRowClassName(presentation: AccessSectionPresentation, isAvailable: boolean) { if (presentation === "endpoint-rail") { - return cn("relative rounded-xl px-3 py-3 sm:px-4", !isAvailable && "bg-muted/15"); + return cn( + "relative first:rounded-t-xl last:rounded-b-xl px-3 py-3 sm:px-4", + !isAvailable && "bg-muted/15", + ); } return cn(ENDPOINT_ROW_CLASSNAME, !isAvailable && "bg-muted/24"); diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index 736b7f1b4b99..6d9d20105224 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -282,7 +282,7 @@ function DiscoveryItemRow({ return (
@@ -432,7 +432,7 @@ function SourceControlSectionSkeleton({ return ( {SOURCE_CONTROL_SKELETON_ROWS.map((row) => ( -
+
diff --git a/apps/web/src/components/settings/itemRows.ts b/apps/web/src/components/settings/itemRows.ts index e207c9ff7a78..0bad52bcb033 100644 --- a/apps/web/src/components/settings/itemRows.ts +++ b/apps/web/src/components/settings/itemRows.ts @@ -1,5 +1,5 @@ -/** Direct row in a settings section. Whitespace, rather than rules, separates peers. */ -export const ITEM_ROW_CLASSNAME = "rounded-xl px-3 py-3 sm:px-4"; +/** Direct row in a grouped settings section. Round only outer corners; the parent owns borders and separators. */ +export const ITEM_ROW_CLASSNAME = "first:rounded-t-xl last:rounded-b-xl px-3 py-3 sm:px-4"; export const ITEM_ROW_INNER_CLASSNAME = "flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"; From 2d645df474f0af5e261edb56a79f7a2a9a2d442e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 6 Sep 2026 11:10:13 -0700 Subject: [PATCH 03/71] feat(threads): persist manual active thread order (#9729) --- .../src/environment/ServerEnvironment.test.ts | 1 + .../src/environment/ServerEnvironment.ts | 1 + .../Layers/ProjectionPipeline.test.ts | 58 ++++- .../Layers/ProjectionPipeline.ts | 5 + .../Layers/ProjectionSnapshotQuery.test.ts | 6 + .../Layers/ProjectionSnapshotQuery.ts | 10 + .../decider.active-order.test.ts | 200 ++++++++++++++++++ apps/server/src/orchestration/decider.ts | 36 ++++ .../src/orchestration/projector.test.ts | 1 + apps/server/src/orchestration/projector.ts | 5 + .../persistence/Layers/ProjectionThreads.ts | 5 + apps/server/src/persistence/Migrations.ts | 2 + ...49_ProjectionThreadsActiveOrderKey.test.ts | 44 ++++ .../049_ProjectionThreadsActiveOrderKey.ts | 15 ++ .../persistence/Services/ProjectionThreads.ts | 1 + .../src/operations/commands.test.ts | 21 ++ .../client-runtime/src/operations/commands.ts | 11 + .../client-runtime/src/state/entities.test.ts | 6 + .../src/state/threadCommands.ts | 9 + .../client-runtime/src/state/threadDetail.ts | 2 + .../src/state/threadReducer.test.ts | 113 ++++++---- .../client-runtime/src/state/threadReducer.ts | 5 + .../src/state/threadSort.test.ts | 176 +++++++++++++++ .../client-runtime/src/state/threadSort.ts | 75 +++++-- packages/contracts/src/environment.ts | 2 + packages/contracts/src/orchestration.test.ts | 55 +++++ packages/contracts/src/orchestration.ts | 16 ++ 27 files changed, 824 insertions(+), 57 deletions(-) create mode 100644 apps/server/src/orchestration/decider.active-order.test.ts create mode 100644 apps/server/src/persistence/Migrations/049_ProjectionThreadsActiveOrderKey.test.ts create mode 100644 apps/server/src/persistence/Migrations/049_ProjectionThreadsActiveOrderKey.ts diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 141aa405af21..a674a25c1ec8 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -167,6 +167,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { expect(second.capabilities.fileAttachments).toEqual({ maxUploadBytes: 50 * 1024 * 1024 }); expect(second.capabilities.pullRequests).toBe(true); expect(second.capabilities.usagePriceOverrides).toBe(true); + expect(second.capabilities.threadActiveReorder).toBe(true); expect(second.capabilities.threadTitleRegeneration).toBe(true); expect(second.capabilities.threadPullRequestLinking).toBe(true); expect(second.capabilities.agentActivityPublishing).toBe(false); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index eab723d7909a..0b7f1761cf76 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -226,6 +226,7 @@ export const make = Effect.gen(function* () { usagePriceOverrides: true, threadPinning: true, threadPinReorder: true, + threadActiveReorder: true, threadTitleRegeneration: true, threadPullRequestLinking: true, environmentIcon: true, diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index f619f6a93916..1a989958f50c 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -488,6 +488,48 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { yield* sql`DROP TRIGGER count_thread_shell_updates`; yield* sql`DROP TABLE thread_shell_updates`; + // Replayed order events must survive later lifecycle upserts, whose + // complete SQL row writes otherwise risk dropping the placement. + const orderUpdatedAt = "2026-01-01T00:00:00.200Z"; + const orderEvents = [ + { type: "thread.meta-updated", payload: { activeOrderKey: "gm" } }, + { type: "thread.pinned", payload: { pinnedAt: now, pinOrderKey: "m" } }, + { + type: "thread.snoozed", + payload: { snoozedAt: now, snoozedUntil: "2026-01-02T00:00:00.000Z" }, + }, + { type: "thread.unsnoozed", payload: { reason: "user" } }, + { type: "thread.unpinned", payload: {} }, + { type: "thread.meta-updated", payload: { title: "Renamed" } }, + ] as const; + for (const [index, event] of orderEvents.entries()) { + yield* eventStore.append({ + type: event.type, + eventId: EventId.make(`evt-active-order-${index}`), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + occurredAt: "2026-01-01T00:00:00.500Z", + commandId: CommandId.make(`cmd-active-order-${index}`), + causationEventId: null, + correlationId: null, + metadata: {}, + payload: { + ...event.payload, + threadId: ThreadId.make("thread-1"), + updatedAt: orderUpdatedAt, + }, + }); + yield* projectionPipeline.bootstrap; + const rows = yield* sql<{ + readonly activeOrderKey: string | null; + readonly updatedAt: string; + }>` + SELECT active_order_key AS "activeOrderKey", updated_at AS "updatedAt" + FROM projection_threads WHERE thread_id = 'thread-1' + `; + assert.deepEqual(rows, [{ activeOrderKey: "gm", updatedAt: orderUpdatedAt }]); + } + // Settled lifecycle through the DB pipeline: thread.settled writes the // override + timestamp, thread.unsettled(user) flips to the active pin. yield* eventStore.append({ @@ -512,16 +554,23 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { readonly settledOverride: string | null; readonly settledAt: string | null; readonly unsettledAt: string | null; + readonly activeOrderKey: string | null; }>` SELECT settled_override AS "settledOverride", settled_at AS "settledAt", - unsettled_at AS "unsettledAt" + unsettled_at AS "unsettledAt", + active_order_key AS "activeOrderKey" FROM projection_threads WHERE thread_id = 'thread-1' `; assert.deepEqual(settledRows, [ - { settledOverride: "settled", settledAt: "2026-01-01T00:00:01.000Z", unsettledAt: null }, + { + settledOverride: "settled", + settledAt: "2026-01-01T00:00:01.000Z", + unsettledAt: null, + activeOrderKey: null, + }, ]); yield* eventStore.append({ @@ -546,11 +595,13 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { readonly settledOverride: string | null; readonly settledAt: string | null; readonly unsettledAt: string | null; + readonly activeOrderKey: string | null; }>` SELECT settled_override AS "settledOverride", settled_at AS "settledAt", - unsettled_at AS "unsettledAt" + unsettled_at AS "unsettledAt", + active_order_key AS "activeOrderKey" FROM projection_threads WHERE thread_id = 'thread-1' `; @@ -561,6 +612,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { settledOverride: "active", settledAt: null, unsettledAt: "2026-01-01T00:00:02.000Z", + activeOrderKey: null, }, ]); }), diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index bcef68170a49..050ad1a902ae 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -617,6 +617,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti snoozedAt: null, pinnedAt: null, pinOrderKey: null, + activeOrderKey: null, titleRegenerationRequestId: null, titleRegenerationStartedAt: null, latestUserMessageAt: null, @@ -671,6 +672,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti settledOverride: "settled", settledAt: event.payload.settledAt, unsettledAt: null, + activeOrderKey: null, updatedAt: event.payload.updatedAt, }); return; @@ -790,6 +792,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti yield* projectionThreadRepository.upsert({ ...existingRow.value, ...(event.payload.title !== undefined ? { title: event.payload.title } : {}), + ...(event.payload.activeOrderKey !== undefined + ? { activeOrderKey: event.payload.activeOrderKey } + : {}), ...(event.payload.titleRegeneration !== undefined ? { titleRegenerationRequestId: event.payload.titleRegeneration?.requestId ?? null, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 08a07c0be75b..e262bce34aaf 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -110,6 +110,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { has_actionable_proposed_plan, pinned_at, pin_order_key, + active_order_key, created_at, updated_at, deleted_at @@ -132,6 +133,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { 0, '2026-02-24T00:00:01.000Z', 'gm', + 'hq', '2026-02-24T00:00:02.000Z', '2026-02-24T00:00:03.000Z', NULL @@ -358,6 +360,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { snoozedAt: null, pinnedAt: "2026-02-24T00:00:01.000Z", pinOrderKey: "gm", + activeOrderKey: "hq", titleRegeneration: null, deletedAt: null, messages: [ @@ -487,6 +490,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { snoozedAt: null, pinnedAt: "2026-02-24T00:00:01.000Z", pinOrderKey: "gm", + activeOrderKey: "hq", titleRegeneration: null, session: { threadId: ThreadId.make("thread-1"), @@ -513,6 +517,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { } const commandSnapshot = yield* snapshotQuery.getCommandReadModel(); + assert.equal(commandSnapshot.threads[0]?.activeOrderKey, "hq"); assert.deepEqual(commandSnapshot.threads[0]?.branchPullRequest, branchPullRequest); const threadShell = yield* snapshotQuery.getThreadShellById(ThreadId.make("thread-1")); assert.equal(threadShell._tag, "Some"); @@ -560,6 +565,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { ); assert.equal(detailWithoutActivities._tag, "Some"); if (detailWithoutActivities._tag === "Some") { + assert.equal(detailWithoutActivities.value.activeOrderKey, "hq"); assert.deepEqual(detailWithoutActivities.value.activities, []); assert.deepEqual(detailWithoutActivities.value.messages, snapshot.threads[0]?.messages); assert.deepEqual( diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index b15b72ee5673..5f82a26e2a36 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -513,6 +513,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", pin_order_key AS "pinOrderKey", + active_order_key AS "activeOrderKey", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", @@ -552,6 +553,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", pin_order_key AS "pinOrderKey", + active_order_key AS "activeOrderKey", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", @@ -593,6 +595,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", pin_order_key AS "pinOrderKey", + active_order_key AS "activeOrderKey", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", @@ -1083,6 +1086,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", pin_order_key AS "pinOrderKey", + active_order_key AS "activeOrderKey", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", @@ -2082,6 +2086,7 @@ pending_approval_requests AS ( snoozedAt: row.snoozedAt, pinnedAt: row.pinnedAt, pinOrderKey: row.pinOrderKey ?? null, + activeOrderKey: row.activeOrderKey ?? null, titleRegeneration: mapTitleRegeneration(row), deletedAt: row.deletedAt, messages: messagesByThread.get(row.threadId) ?? [], @@ -2296,6 +2301,7 @@ pending_approval_requests AS ( snoozedAt: row.snoozedAt, pinnedAt: row.pinnedAt, pinOrderKey: row.pinOrderKey ?? null, + activeOrderKey: row.activeOrderKey ?? null, titleRegeneration: mapTitleRegeneration(row), deletedAt: row.deletedAt, messages: [], @@ -2437,6 +2443,7 @@ pending_approval_requests AS ( snoozedAt: row.snoozedAt, pinnedAt: row.pinnedAt, pinOrderKey: row.pinOrderKey ?? null, + activeOrderKey: row.activeOrderKey ?? null, titleRegeneration: mapTitleRegeneration(row), session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, @@ -2586,6 +2593,7 @@ pending_approval_requests AS ( snoozedAt: row.snoozedAt, pinnedAt: row.pinnedAt, pinOrderKey: row.pinOrderKey ?? null, + activeOrderKey: row.activeOrderKey ?? null, titleRegeneration: mapTitleRegeneration(row), session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, @@ -2908,6 +2916,7 @@ pending_approval_requests AS ( snoozedAt: threadRow.value.snoozedAt, pinnedAt: threadRow.value.pinnedAt, pinOrderKey: threadRow.value.pinOrderKey ?? null, + activeOrderKey: threadRow.value.activeOrderKey ?? null, titleRegeneration: mapTitleRegeneration(threadRow.value), session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null, latestUserMessageAt: threadRow.value.latestUserMessageAt, @@ -3190,6 +3199,7 @@ pending_approval_requests AS ( snoozedAt: threadRow.value.snoozedAt, pinnedAt: threadRow.value.pinnedAt, pinOrderKey: threadRow.value.pinOrderKey ?? null, + activeOrderKey: threadRow.value.activeOrderKey ?? null, titleRegeneration: mapTitleRegeneration(threadRow.value), deletedAt: null, messages: messageRows.map((row) => { diff --git a/apps/server/src/orchestration/decider.active-order.test.ts b/apps/server/src/orchestration/decider.active-order.test.ts new file mode 100644 index 000000000000..58a7f5c054ec --- /dev/null +++ b/apps/server/src/orchestration/decider.active-order.test.ts @@ -0,0 +1,200 @@ +import { + CommandId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationCommand, + type OrchestrationReadModel, + type OrchestrationThread, +} from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { decideOrchestrationCommand } from "./decider.ts"; +import { projectEvent } from "./projector.ts"; + +const NOW = "2026-01-01T00:00:00.000Z"; +// The Effect test clock starts at the epoch. +const BEFORE_NOW = "1969-12-30T00:00:00.000Z"; +const SNOOZED_AT = "1969-12-31T00:00:00.000Z"; +const FUTURE_WAKE = "1970-01-02T00:00:00.000Z"; +const THREAD_ID = ThreadId.make("thread-1"); + +function makeReadModel(overrides: Partial = {}): OrchestrationReadModel { + return { + snapshotSequence: 0, + projects: [], + threads: [ + { + id: THREAD_ID, + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: NOW, + updatedAt: NOW, + archivedAt: null, + settledOverride: null, + settledAt: null, + unsettledAt: null, + activeOrderKey: null, + snoozedUntil: null, + snoozedAt: null, + pinnedAt: null, + pinOrderKey: null, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + session: null, + ...overrides, + }, + ], + updatedAt: NOW, + }; +} + +const reorderCommand = { + type: "thread.active.reorder", + commandId: CommandId.make("cmd-active-reorder"), + threadId: THREAD_ID, + orderKey: "m", +} as const; + +it.layer(NodeServices.layer)("active thread ordering", (it) => { + it.effect("persists changed and repeated slots without changing thread activity timestamps", () => + Effect.gen(function* () { + let readModel = makeReadModel({ unsettledAt: BEFORE_NOW }); + for (const orderKey of ["m", "m", "g"]) { + const decided = yield* decideOrchestrationCommand({ + command: { ...reorderCommand, orderKey }, + readModel, + }); + const events = Array.isArray(decided) ? decided : [decided]; + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + type: "thread.meta-updated", + payload: { threadId: THREAD_ID, activeOrderKey: orderKey, updatedAt: NOW }, + }); + for (const event of events) { + readModel = yield* projectEvent(readModel, { + ...event, + sequence: readModel.snapshotSequence + 1, + }); + } + expect(readModel.threads[0]).toMatchObject({ + activeOrderKey: orderKey, + updatedAt: NOW, + createdAt: NOW, + unsettledAt: BEFORE_NOW, + }); + } + }), + ); + + for (const [label, overrides] of [ + ["archived", { archivedAt: NOW }], + ["deleted", { deletedAt: NOW }], + ["pinned", { pinnedAt: NOW }], + ["settled", { settledOverride: "settled", settledAt: NOW }], + ] satisfies ReadonlyArray]>) { + it.effect(`rejects reordering a ${label} thread`, () => + Effect.gen(function* () { + const error = yield* decideOrchestrationCommand({ + command: reorderCommand, + readModel: makeReadModel(overrides), + }).pipe(Effect.flip); + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); + } + + it.effect("reorders a running thread without affecting its session", () => + Effect.gen(function* () { + const readModel = makeReadModel({ + session: { + threadId: THREAD_ID, + status: "running", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: NOW, + }, + }); + const decided = yield* decideOrchestrationCommand({ command: reorderCommand, readModel }); + const events = Array.isArray(decided) ? decided : [decided]; + expect(events).toHaveLength(1); + for (const event of events) { + const projected = yield* projectEvent(readModel, { ...event, sequence: 1 }); + expect(projected.threads[0]).toEqual({ ...readModel.threads[0], activeOrderKey: "m" }); + } + }), + ); + + it.effect( + "changes a snoozed thread's retained slot without waking it or changing timestamps", + () => + Effect.gen(function* () { + const readModel = makeReadModel({ + activeOrderKey: "g", + snoozedAt: SNOOZED_AT, + snoozedUntil: FUTURE_WAKE, + unsettledAt: BEFORE_NOW, + }); + const decided = yield* decideOrchestrationCommand({ command: reorderCommand, readModel }); + const events = Array.isArray(decided) ? decided : [decided]; + expect(events).toHaveLength(1); + for (const event of events) { + const projected = yield* projectEvent(readModel, { ...event, sequence: 1 }); + expect(projected.threads[0]).toEqual({ ...readModel.threads[0], activeOrderKey: "m" }); + } + }), + ); + + it.effect("keeps placement through metadata, pin and snooze, then resets it on settlement", () => + Effect.gen(function* () { + let readModel = makeReadModel(); + const steps = [ + [reorderCommand, "m"], + [{ type: "thread.meta.update", title: "Renamed" }, "m"], + [{ type: "thread.pin", orderKey: "g" }, "m"], + [{ type: "thread.snooze", snoozedUntil: FUTURE_WAKE }, "m"], + [{ type: "thread.unsnooze", reason: "user" }, "m"], + [{ type: "thread.unpin" }, "m"], + [{ type: "thread.settle" }, null], + [{ type: "thread.unsettle", reason: "user" }, null], + [{ type: "thread.active.reorder", orderKey: "s" }, "s"], + ] as const; + for (const [index, [step, expectedKey]] of steps.entries()) { + const command: OrchestrationCommand = { + ...step, + commandId: CommandId.make(`lifecycle-${index}`), + threadId: THREAD_ID, + }; + const decided = yield* decideOrchestrationCommand({ command, readModel }); + const events = Array.isArray(decided) ? decided : [decided]; + for (const event of events) { + readModel = yield* projectEvent(readModel, { + ...event, + sequence: readModel.snapshotSequence + 1, + }); + } + expect(readModel.threads[0]?.activeOrderKey, command.type).toBe(expectedKey); + } + expect(readModel.threads[0]).toMatchObject({ + title: "Renamed", + settledOverride: "active", + settledAt: null, + snoozedUntil: null, + pinnedAt: null, + }); + }), + ); +}); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index a20dba468c65..9dc55e1194cc 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -788,6 +788,42 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.active.reorder": { + const thread = yield* requireThreadNotArchived({ + readModel, + command, + threadId: command.threadId, + }); + const occurredAt = yield* nowIso; + // Snooze retains this slot. Changing it cannot wake the thread, and + // accepting it handles races with snooze and retained wake timestamps. + if ( + thread.deletedAt !== null || + thread.pinnedAt != null || + thread.settledOverride === "settled" + ) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} is not active and cannot be reordered`, + }); + } + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.meta-updated", + payload: { + threadId: command.threadId, + activeOrderKey: command.orderKey, + // Arranging the list is not thread activity or a lifecycle transition. + updatedAt: thread.updatedAt, + }, + }; + } + case "thread.meta.update": { const thread = yield* requireThread({ readModel, diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index d4e1213b2abb..e973b523275f 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -91,6 +91,7 @@ describe("orchestration projector", () => { createdAt: now, updatedAt: now, archivedAt: null, + activeOrderKey: null, settledOverride: null, settledAt: null, unsettledAt: null, diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 58c4876905ec..c048247f4128 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -344,6 +344,7 @@ export function projectEvent( settledOverride: null, settledAt: null, unsettledAt: null, + activeOrderKey: null, snoozedUntil: null, snoozedAt: null, deletedAt: null, @@ -406,6 +407,7 @@ export function projectEvent( settledOverride: "settled", settledAt: payload.settledAt, unsettledAt: null, + activeOrderKey: null, updatedAt: payload.updatedAt, }), })), @@ -500,6 +502,9 @@ export function projectEvent( ...nextBase, threads: updateThread(nextBase.threads, payload.threadId, { ...(payload.title !== undefined ? { title: payload.title } : {}), + ...(payload.activeOrderKey !== undefined + ? { activeOrderKey: payload.activeOrderKey } + : {}), ...(payload.titleRegeneration !== undefined ? { titleRegeneration: payload.titleRegeneration } : {}), diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 799386845419..6406e8237bc9 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -54,6 +54,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { snoozed_at, pinned_at, pin_order_key, + active_order_key, title_regeneration_request_id, title_regeneration_started_at, latest_user_message_at, @@ -84,6 +85,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.snoozedAt}, ${row.pinnedAt}, ${row.pinOrderKey ?? null}, + ${row.activeOrderKey ?? null}, ${row.titleRegenerationRequestId ?? null}, ${row.titleRegenerationStartedAt ?? null}, ${row.latestUserMessageAt}, @@ -114,6 +116,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { snoozed_at = excluded.snoozed_at, pinned_at = excluded.pinned_at, pin_order_key = excluded.pin_order_key, + active_order_key = excluded.active_order_key, title_regeneration_request_id = excluded.title_regeneration_request_id, title_regeneration_started_at = excluded.title_regeneration_started_at, latest_user_message_at = excluded.latest_user_message_at, @@ -151,6 +154,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", pin_order_key AS "pinOrderKey", + active_order_key AS "activeOrderKey", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", @@ -190,6 +194,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", pin_order_key AS "pinOrderKey", + active_order_key AS "activeOrderKey", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 7f89170d29f5..c95f746d3648 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -60,6 +60,7 @@ import Migration0045 from "./Migrations/045_ProjectionProjectsAutoPull.ts"; import Migration0046 from "./Migrations/046_RepairAutomaticSettlementTimestamps.ts"; import Migration0047 from "./Migrations/047_ProjectionProjectIcon.ts"; import Migration0048 from "./Migrations/048_ProjectionThreadBranchPullRequest.ts"; +import Migration0049 from "./Migrations/049_ProjectionThreadsActiveOrderKey.ts"; /** * Migration loader with all migrations defined inline. @@ -120,6 +121,7 @@ export const migrationEntries = [ [46, "RepairAutomaticSettlementTimestamps", Migration0046], [47, "ProjectionProjectIcon", Migration0047], [48, "ProjectionThreadBranchPullRequest", Migration0048], + [49, "ProjectionThreadsActiveOrderKey", Migration0049], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/049_ProjectionThreadsActiveOrderKey.test.ts b/apps/server/src/persistence/Migrations/049_ProjectionThreadsActiveOrderKey.test.ts new file mode 100644 index 000000000000..138d25754d7e --- /dev/null +++ b/apps/server/src/persistence/Migrations/049_ProjectionThreadsActiveOrderKey.test.ts @@ -0,0 +1,44 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; + +import { runMigrations } from "../Migrations.ts"; +import migrateActiveOrderKey from "./049_ProjectionThreadsActiveOrderKey.ts"; + +it.layer(NodeSqliteClient.layerMemory())("049_ProjectionThreadsActiveOrderKey", (it) => { + it.effect("migrates old threads without changing their timestamps or assigning an order", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 48 }); + const now = "2026-01-01T00:00:00.000Z"; + yield* sql` + INSERT INTO projection_threads ( + thread_id, project_id, title, model_selection_json, runtime_mode, + created_at, updated_at + ) VALUES ( + 'thread-1', 'project-1', 'Existing thread', + '{"instanceId":"codex","model":"gpt-5.4"}', 'full-access', ${now}, ${now} + ) + `; + yield* runMigrations({ toMigrationInclusive: 49 }); + const migrated = yield* sql<{ readonly activeOrderKey: string | null }>` + SELECT active_order_key AS "activeOrderKey" FROM projection_threads WHERE thread_id = 'thread-1' + `; + assert.deepEqual(migrated, [{ activeOrderKey: null }]); + // Recovery may run the same migration against a database that already + // has the column, including a placement written after the upgrade. + yield* sql`UPDATE projection_threads SET active_order_key = 'gm' WHERE thread_id = 'thread-1'`; + yield* migrateActiveOrderKey; + const rows = yield* sql<{ + readonly activeOrderKey: string | null; + readonly createdAt: string; + readonly updatedAt: string; + }>` + SELECT active_order_key AS "activeOrderKey", created_at AS "createdAt", updated_at AS "updatedAt" + FROM projection_threads WHERE thread_id = 'thread-1' + `; + assert.deepEqual(rows, [{ activeOrderKey: "gm", createdAt: now, updatedAt: now }]); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/049_ProjectionThreadsActiveOrderKey.ts b/apps/server/src/persistence/Migrations/049_ProjectionThreadsActiveOrderKey.ts new file mode 100644 index 000000000000..6f40ec38d081 --- /dev/null +++ b/apps/server/src/persistence/Migrations/049_ProjectionThreadsActiveOrderKey.ts @@ -0,0 +1,15 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + if (!columns.some((column) => column.name === "active_order_key")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN active_order_key TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index ee442624be51..0a8b2e31c5ab 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -47,6 +47,7 @@ export const ProjectionThread = Schema.Struct({ snoozedAt: Schema.NullOr(IsoDateTime), pinnedAt: Schema.NullOr(IsoDateTime), pinOrderKey: Schema.optional(Schema.NullOr(Schema.String)), + activeOrderKey: Schema.optional(Schema.NullOr(Schema.String)), titleRegenerationRequestId: Schema.optional(Schema.NullOr(CommandId)), titleRegenerationStartedAt: Schema.optional(Schema.NullOr(IsoDateTime)), latestUserMessageAt: Schema.NullOr(IsoDateTime), diff --git a/packages/client-runtime/src/operations/commands.test.ts b/packages/client-runtime/src/operations/commands.test.ts index 36bc6a7b296f..5cc17586471d 100644 --- a/packages/client-runtime/src/operations/commands.test.ts +++ b/packages/client-runtime/src/operations/commands.test.ts @@ -24,6 +24,7 @@ import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; import { archiveThread, createProject, + reorderActiveThread, settleThread, stopThreadSession, unsettleThread, @@ -172,4 +173,24 @@ describe("environment commands", () => { ]); }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), ); + + it.effect("sends an active order key without changing activity timestamps", () => + Effect.gen(function* () { + const dispatched: ClientOrchestrationCommand[] = []; + const supervisor = yield* makeSupervisor(dispatched); + yield* reorderActiveThread({ + commandId: CommandId.make("reorder-command"), + threadId: ThreadId.make("thread-1"), + orderKey: "mf", + }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); + expect(dispatched).toEqual([ + { + type: "thread.active.reorder", + commandId: "reorder-command", + threadId: "thread-1", + orderKey: "mf", + }, + ]); + }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), + ); }); diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index cb74f117b772..9bf75c838a99 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -42,6 +42,7 @@ export type UnsnoozeThreadInput = CommandInput<"thread.unsnooze">; export type PinThreadInput = CommandInput<"thread.pin">; export type UnpinThreadInput = CommandInput<"thread.unpin">; export type ReorderPinnedThreadInput = CommandInput<"thread.pin.reorder">; +export type ReorderActiveThreadInput = CommandInput<"thread.active.reorder">; export type UpdateThreadMetadataInput = CommandInput<"thread.meta.update">; export type SetThreadRuntimeModeInput = CommandInput<"thread.runtime-mode.set">; export type SetThreadInteractionModeInput = CommandInput<"thread.interaction-mode.set">; @@ -230,6 +231,16 @@ export const reorderPinnedThread: (input: ReorderPinnedThreadInput) => CommandEf }); }); +export const reorderActiveThread: (input: ReorderActiveThreadInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.reorderActiveThread", +)(function* (input) { + return yield* dispatch({ + ...input, + type: "thread.active.reorder", + commandId: yield* commandId(input), + }); +}); + export const updateThreadMetadata: (input: UpdateThreadMetadataInput) => CommandEffect = Effect.fn( "EnvironmentCommands.updateThreadMetadata", )(function* (input) { diff --git a/packages/client-runtime/src/state/entities.test.ts b/packages/client-runtime/src/state/entities.test.ts index d02f63c0b69a..b8d2aef40697 100644 --- a/packages/client-runtime/src/state/entities.test.ts +++ b/packages/client-runtime/src/state/entities.test.ts @@ -208,6 +208,8 @@ describe("environment entity projections", () => { title: "Cached thread", branch: "stale-branch", worktreePath: "/repo/stale-worktree", + activeOrderKey: "t", + unsettledAt: "2026-03-09T10:00:00.000Z", deletedAt: null, messages, proposedPlans: [], @@ -220,6 +222,8 @@ describe("environment entity projections", () => { title: "Current thread", branch: "current-branch", worktreePath: "/repo/current-worktree", + activeOrderKey: "f", + unsettledAt: "2026-03-09T12:00:00.000Z", }; const merged = mergeEnvironmentThread(detail, shell); @@ -228,6 +232,8 @@ describe("environment entity projections", () => { title: "Current thread", branch: "current-branch", worktreePath: "/repo/current-worktree", + activeOrderKey: "f", + unsettledAt: "2026-03-09T12:00:00.000Z", }); expect(merged?.messages).toBe(messages); }); diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index c540644289df..83881f7ec15f 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -19,6 +19,7 @@ import { type SetThreadRuntimeModeInput, type PinThreadInput, type ReorderPinnedThreadInput, + type ReorderActiveThreadInput, type SettleThreadInput, type SnoozeThreadInput, type StartThreadTurnInput, @@ -39,6 +40,7 @@ import { setThreadRuntimeMode, pinThread, reorderPinnedThread, + reorderActiveThread, settleThread, snoozeThread, startThreadTurn, @@ -63,6 +65,7 @@ export type { SetThreadRuntimeModeInput, PinThreadInput, ReorderPinnedThreadInput, + ReorderActiveThreadInput, SettleThreadInput, SnoozeThreadInput, StartThreadTurnInput, @@ -150,6 +153,12 @@ export function createThreadEnvironmentAtoms( scheduler, concurrency, }), + reorderActive: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:reorder-active", + execute: (input: ReorderActiveThreadInput) => reorderActiveThread(input), + scheduler, + concurrency, + }), updateMetadata: createEnvironmentCommand(runtime, { label: "environment-data:commands:thread:update-metadata", execute: (input: UpdateThreadMetadataInput) => updateThreadMetadata(input), diff --git a/packages/client-runtime/src/state/threadDetail.ts b/packages/client-runtime/src/state/threadDetail.ts index 0233cee0e22e..379985b71243 100644 --- a/packages/client-runtime/src/state/threadDetail.ts +++ b/packages/client-runtime/src/state/threadDetail.ts @@ -58,6 +58,8 @@ export function mergeEnvironmentThread( archivedAt: shell.archivedAt, settledOverride: shell.settledOverride, settledAt: shell.settledAt, + unsettledAt: shell.unsettledAt, + activeOrderKey: shell.activeOrderKey, snoozedUntil: shell.snoozedUntil, snoozedAt: shell.snoozedAt, pinnedAt: shell.pinnedAt, diff --git a/packages/client-runtime/src/state/threadReducer.test.ts b/packages/client-runtime/src/state/threadReducer.test.ts index 59b3cb0551e4..38afce2d5cb7 100644 --- a/packages/client-runtime/src/state/threadReducer.test.ts +++ b/packages/client-runtime/src/state/threadReducer.test.ts @@ -178,24 +178,28 @@ describe("applyThreadDetailEvent", () => { describe("thread.settled / thread.unsettled", () => { it("sets the settled override and timestamp", () => { const settledAt = "2026-04-01T05:00:00.000Z"; - const result = applyThreadDetailEvent(baseThread, { - ...baseEventFields, - sequence: 5, - occurredAt: settledAt, - aggregateKind: "thread", - aggregateId: ThreadId.make("thread-1"), - type: "thread.settled", - payload: { - threadId: ThreadId.make("thread-1"), - settledAt, - updatedAt: settledAt, + const result = applyThreadDetailEvent( + { ...baseThread, activeOrderKey: "m" }, + { + ...baseEventFields, + sequence: 5, + occurredAt: settledAt, + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.settled", + payload: { + threadId: ThreadId.make("thread-1"), + settledAt, + updatedAt: settledAt, + }, }, - }); + ); expect(result.kind).toBe("updated"); if (result.kind === "updated") { expect(result.thread.settledOverride).toBe("settled"); expect(result.thread.settledAt).toBe(settledAt); + expect(result.thread.activeOrderKey).toBeNull(); } }); @@ -234,23 +238,27 @@ describe("applyThreadDetailEvent", () => { describe("thread.pinned / thread.unpinned", () => { it("sets pinnedAt", () => { const pinnedAt = "2026-04-01T05:00:00.000Z"; - const result = applyThreadDetailEvent(baseThread, { - ...baseEventFields, - sequence: 5, - occurredAt: pinnedAt, - aggregateKind: "thread", - aggregateId: ThreadId.make("thread-1"), - type: "thread.pinned", - payload: { - threadId: ThreadId.make("thread-1"), - pinnedAt, - updatedAt: pinnedAt, + const result = applyThreadDetailEvent( + { ...baseThread, activeOrderKey: "m" }, + { + ...baseEventFields, + sequence: 5, + occurredAt: pinnedAt, + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.pinned", + payload: { + threadId: ThreadId.make("thread-1"), + pinnedAt, + updatedAt: pinnedAt, + }, }, - }); + ); expect(result.kind).toBe("updated"); if (result.kind === "updated") { expect(result.thread.pinnedAt).toBe(pinnedAt); + expect(result.thread.activeOrderKey).toBe("m"); } }); @@ -281,26 +289,57 @@ describe("applyThreadDetailEvent", () => { }); describe("thread.meta-updated", () => { + it.each(["f", null] as const)( + "updates the active key to %s without activity", + (activeOrderKey) => { + const result = applyThreadDetailEvent( + { ...baseThread, activeOrderKey: "m" }, + { + ...baseEventFields, + sequence: 5, + occurredAt: "2026-04-01T05:00:00.000Z", + aggregateKind: "thread", + aggregateId: baseThread.id, + type: "thread.meta-updated", + payload: { + threadId: baseThread.id, + activeOrderKey, + updatedAt: baseThread.updatedAt, + }, + }, + ); + expect(result.kind).toBe("updated"); + if (result.kind === "updated") { + expect(result.thread.activeOrderKey).toBe(activeOrderKey); + expect(result.thread.updatedAt).toBe(baseThread.updatedAt); + } + }, + ); + it("patches title and branch", () => { - const result = applyThreadDetailEvent(baseThread, { - ...baseEventFields, - sequence: 5, - occurredAt: "2026-04-01T05:00:00.000Z", - aggregateKind: "thread", - aggregateId: ThreadId.make("thread-1"), - type: "thread.meta-updated", - payload: { - threadId: ThreadId.make("thread-1"), - title: "Updated Title", - branch: "feature/demo", - updatedAt: "2026-04-01T05:00:00.000Z", + const result = applyThreadDetailEvent( + { ...baseThread, activeOrderKey: "m" }, + { + ...baseEventFields, + sequence: 5, + occurredAt: "2026-04-01T05:00:00.000Z", + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.meta-updated", + payload: { + threadId: ThreadId.make("thread-1"), + title: "Updated Title", + branch: "feature/demo", + updatedAt: "2026-04-01T05:00:00.000Z", + }, }, - }); + ); expect(result.kind).toBe("updated"); if (result.kind === "updated") { expect(result.thread.title).toBe("Updated Title"); expect(result.thread.branch).toBe("feature/demo"); + expect(result.thread.activeOrderKey).toBe("m"); // Model selection should be unchanged since it wasn't in the payload expect(result.thread.modelSelection).toEqual(baseThread.modelSelection); } diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index 09272cd065cf..a3481fdc729c 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -105,6 +105,7 @@ export function applyThreadDetailEvent( settledOverride: null, settledAt: null, unsettledAt: null, + activeOrderKey: null, snoozedUntil: null, snoozedAt: null, deletedAt: null, @@ -144,6 +145,7 @@ export function applyThreadDetailEvent( settledOverride: "settled", settledAt: event.payload.settledAt, unsettledAt: null, + activeOrderKey: null, updatedAt: event.payload.updatedAt, }, }; @@ -244,6 +246,9 @@ export function applyThreadDetailEvent( ...(event.payload.branchPullRequest !== undefined ? { branchPullRequest: event.payload.branchPullRequest } : {}), + ...(event.payload.activeOrderKey !== undefined + ? { activeOrderKey: event.payload.activeOrderKey } + : {}), updatedAt: event.payload.updatedAt, }, }; diff --git a/packages/client-runtime/src/state/threadSort.test.ts b/packages/client-runtime/src/state/threadSort.test.ts index a51d4cfed093..d9a2c124ee7d 100644 --- a/packages/client-runtime/src/state/threadSort.test.ts +++ b/packages/client-runtime/src/state/threadSort.test.ts @@ -1,8 +1,12 @@ import { describe, expect, it } from "vite-plus/test"; import { + generateSpreadPinOrderKeys, + pinOrderKeyBetween, planPinnedMove, + planPinnedReorder, resolveSettledThreadTimestamp, + sortActiveThreadsByOrderKey, sortPinnedThreadsByOrderKey, sortThreads, type ThreadSortInput, @@ -108,6 +112,44 @@ describe("sortThreads", () => { }); }); +describe("planPinnedReorder with hidden rows", () => { + it("keeps hidden slots available when inserting between visible neighbors", () => { + const midpoint = pinOrderKeyBetween("f", "t")!; + const keysById = new Map([ + ["a", "f"], + ["b", "t"], + ["moved", "z"], + ["snoozed", midpoint], + ]); + const assignments = planPinnedReorder({ + orderedIds: ["a", "moved", "b"], + keysById, + movedId: "moved", + }); + expect(assignments).toHaveLength(1); + const key = assignments[0]!.orderKey; + expect(key > "f" && key < "t").toBe(true); + expect(key).not.toBe(midpoint); + expect(assignments[0]!.id).toBe("moved"); + }); + + it("materializes keyless rows without overwriting hidden slots", () => { + const reserved = generateSpreadPinOrderKeys(6); + const keysById = new Map([ + ["a", null], + ["b", null], + ["c", null], + ...reserved.map((key, i) => [`hidden-${i}`, key] as const), + ]); + const assignments = planPinnedReorder({ orderedIds: ["c", "a", "b"], keysById, movedId: "c" }); + expect(assignments.map(({ id }) => id)).toEqual(["c", "a", "b"]); + const keys = assignments.map(({ orderKey }) => orderKey); + expect(keys).toEqual([...keys].sort()); + expect(new Set(keys).size).toBe(3); + expect(keys.every((key) => !reserved.includes(key))).toBe(true); + }); +}); + describe("planPinnedMove", () => { it("moves a thread up with a single key write", () => { const assignments = planPinnedMove({ @@ -173,3 +215,137 @@ describe("sortPinnedThreadsByOrderKey", () => { expect(sorted.map((thread) => thread.environmentId)).toEqual(["env-a", "env-b"]); }); }); + +describe("generateSpreadPinOrderKeys", () => { + it.each([0, 1, 650, 675, 676, 1_001, 2_000])( + "leaves unique, insertable keys for %i threads", + (count) => { + const keys = generateSpreadPinOrderKeys(count); + expect(keys).toHaveLength(count); + expect(new Set(keys).size).toBe(count); + expect([...keys].sort()).toEqual(keys); + for (let index = 0; index < keys.length; index += 1) { + const before = keys[index - 1] ?? null; + const after = keys[index]!; + expect(after).toMatch(/^[a-z]*[b-z]$/); + const between = pinOrderKeyBetween(before, after); + expect(between).not.toBeNull(); + expect(between! < after).toBe(true); + if (before !== null) expect(between! > before).toBe(true); + } + }, + ); +}); + +describe("sortActiveThreadsByOrderKey", () => { + it("keeps new and reopened threads ahead of the saved order", () => { + const sorted = sortActiveThreadsByOrderKey([ + { + id: "arranged-first", + createdAt: "2026-03-09T09:00:00.000Z", + activeOrderKey: "f", + }, + { + id: "new", + createdAt: "2026-03-09T11:00:00.000Z", + activeOrderKey: null, + }, + { + id: "arranged-last", + createdAt: "2026-03-09T12:00:00.000Z", + unsettledAt: "2026-03-09T13:00:00.000Z", + activeOrderKey: "t", + }, + { + id: "reopened", + createdAt: "2026-03-01T09:00:00.000Z", + unsettledAt: "2026-03-09T12:00:00.000Z", + }, + ]); + expect(sorted.map((thread) => thread.id)).toEqual([ + "reopened", + "new", + "arranged-first", + "arranged-last", + ]); + }); + + it("breaks equal order keys and timestamps by thread then environment", () => { + for (const activeOrderKey of [null, "m"]) { + const threads = [ + { id: "thread-b", environmentId: "env-a" }, + { id: "thread-a", environmentId: "env-b" }, + { id: "thread-a", environmentId: "env-a" }, + ].map((thread) => ({ + ...thread, + createdAt: "2026-03-09T10:00:00.000Z", + activeOrderKey, + })); + expect( + sortActiveThreadsByOrderKey(threads).map( + (thread) => `${thread.id}:${thread.environmentId}`, + ), + ).toEqual(["thread-a:env-a", "thread-a:env-b", "thread-b:env-a"]); + } + }); + + it("applies every move across a mixed keyless and keyed section", () => { + const threads = Array.from({ length: 6 }, (_, index) => ({ + id: String(index), + createdAt: `2026-03-09T0${6 - index}:00:00.000Z`, + activeOrderKey: index < 3 ? null : ["f", "m", "t"][index - 3]!, + })); + const ids = threads.map((thread) => thread.id); + const keysById = new Map(threads.map((thread) => [thread.id, thread.activeOrderKey])); + for (const movedId of ids) { + for (let targetIndex = 0; targetIndex < ids.length; targetIndex += 1) { + const desired = ids.filter((id) => id !== movedId); + desired.splice(targetIndex, 0, movedId); + const assignments = planPinnedReorder({ orderedIds: desired, keysById, movedId }); + const nextKeys = new Map( + assignments.map((assignment) => [assignment.id, assignment.orderKey]), + ); + const updated = threads.map((thread) => ({ + ...thread, + activeOrderKey: nextKeys.get(thread.id) ?? thread.activeOrderKey, + })); + expect(sortActiveThreadsByOrderKey(updated).map((thread) => thread.id)).toEqual(desired); + } + } + }); + + it("moves a keyless thread into the arranged run with one write", () => { + const assignments = planPinnedMove({ + orderedIds: ["new", "reopened", "first", "last"], + keysById: new Map([ + ["new", null], + ["reopened", null], + ["first", "f"], + ["last", "t"], + ]), + movedId: "reopened", + direction: "down", + }); + expect(assignments).toHaveLength(1); + expect(assignments![0]!.id).toBe("reopened"); + expect(assignments![0]!.orderKey > "f").toBe(true); + expect(assignments![0]!.orderKey < "t").toBe(true); + }); + + it("materializes a large active list without changing the requested order", () => { + const threads = Array.from({ length: 1_200 }, (_, index) => ({ + id: String(index), + createdAt: "2026-03-09T10:00:00.000Z", + activeOrderKey: null as string | null, + })); + const orderedIds = threads.map((thread) => thread.id).toReversed(); + const assignments = planPinnedReorder({ + orderedIds, + movedId: orderedIds[0]!, + keysById: new Map(threads.map((thread) => [thread.id, thread.activeOrderKey])), + }); + const keys = new Map(assignments.map((assignment) => [assignment.id, assignment.orderKey])); + const updated = threads.map((thread) => ({ ...thread, activeOrderKey: keys.get(thread.id) })); + expect(sortActiveThreadsByOrderKey(updated).map((thread) => thread.id)).toEqual(orderedIds); + }); +}); diff --git a/packages/client-runtime/src/state/threadSort.ts b/packages/client-runtime/src/state/threadSort.ts index cf89d4a21ac9..f06c95919554 100644 --- a/packages/client-runtime/src/state/threadSort.ts +++ b/packages/client-runtime/src/state/threadSort.ts @@ -205,25 +205,27 @@ export function pinOrderKeyBetween(before: string | null, after: string | null): return pinOrderMidpoint(a, b); } -/** Evenly spaced keys for rewriting a whole pinned section (used when a - drop lands next to keyless threads, so single-key insertion has nothing - to anchor on). Two base-26 digits give 675 slots — far beyond any real - pinned section — with monotonicity enforced as a belt-and-braces. */ -function generateSpreadPinOrderKeys(count: number): string[] { - const space = PIN_ORDER_DIGITS.length * PIN_ORDER_DIGITS.length; +/** Evenly spaced keys for materializing an order. Wider keys keep a large + active list from exhausting the space between two-digit keys. */ +export function generateSpreadPinOrderKeys(count: number): string[] { + let width = 2; + let space = PIN_ORDER_DIGITS.length ** width; + while (space <= (count + 1) * 2) { + width += 1; + space *= PIN_ORDER_DIGITS.length; + } const step = space / (count + 1); const keys: string[] = []; - let previous = 0; for (let i = 0; i < count; i += 1) { - let value = Math.max(Math.round(step * (i + 1)), previous + 1); + let value = Math.round(step * (i + 1)); // Skip values whose low digit is the minimum (a trailing "a" key). if (value % PIN_ORDER_DIGITS.length === 0) value += 1; - value = Math.min(value, space - 1); - previous = value; - keys.push( - PIN_ORDER_DIGITS.charAt(Math.floor(value / PIN_ORDER_DIGITS.length)) + - PIN_ORDER_DIGITS.charAt(value % PIN_ORDER_DIGITS.length), - ); + let key = ""; + for (let digit = 0; digit < width; digit += 1) { + key = PIN_ORDER_DIGITS.charAt(value % PIN_ORDER_DIGITS.length) + key; + value = Math.floor(value / PIN_ORDER_DIGITS.length); + } + keys.push(key); } return keys; } @@ -233,15 +235,21 @@ function generateSpreadPinOrderKeys(count: number): string[] { * sits between two keyed (or absent) neighbors, this is a single write to * the moved thread. When a neighbor is keyless (threads pinned before * reordering shipped), the whole section gets fresh spread keys — a - * one-time materialization; every move after that is single-write. + * one-time materialization; every move after that is single-write. Active + * reordering uses the same planner with activeOrderKey values. */ export function planPinnedReorder(input: { /** Thread ids in the desired visual order (after the move). */ readonly orderedIds: readonly string[]; + /** Include retained keys from hidden rows; only orderedIds receive writes. */ readonly keysById: ReadonlyMap; readonly movedId: string; }): ReadonlyArray<{ readonly id: string; readonly orderKey: string }> { const { orderedIds, keysById, movedId } = input; + const visibleIds = new Set(orderedIds); + const reservedKeys = new Set( + [...keysById].flatMap(([id, key]) => (!visibleIds.has(id) && key != null ? [key] : [])), + ); const movedIndex = orderedIds.indexOf(movedId); if (movedIndex === -1) return []; const beforeId = movedIndex > 0 ? orderedIds[movedIndex - 1] : null; @@ -251,11 +259,14 @@ export function planPinnedReorder(input: { const beforeUsable = beforeId === null || beforeKey != null; const afterUsable = afterId === null || afterKey != null; if (beforeUsable && afterUsable) { - const key = pinOrderKeyBetween(beforeKey, afterKey); + let key = pinOrderKeyBetween(beforeKey, afterKey); + while (key !== null && reservedKeys.has(key)) key = pinOrderKeyBetween(key, afterKey); if (key !== null) return [{ id: movedId, orderKey: key }]; } // Keyless neighbor (or corrupt keys): rewrite the section in the new order. - const keys = generateSpreadPinOrderKeys(orderedIds.length); + const keys = generateSpreadPinOrderKeys(orderedIds.length + reservedKeys.size) + .filter((key) => !reservedKeys.has(key)) + .slice(0, orderedIds.length); return orderedIds.flatMap((id, index) => { const key = keys[index]!; return keysById.get(id) === key ? [] : [{ id, orderKey: key }]; @@ -303,6 +314,36 @@ export function sortPinnedThreadsByOrderKey< return [...keyed, ...keyless]; } +/** New and reopened threads lead the active list. Arranged threads follow + their saved keys; activity leaves both groups in place. */ +export function sortActiveThreadsByOrderKey< + T extends { + readonly id: string; + readonly createdAt: string; + readonly unsettledAt?: string | null | undefined; + readonly activeOrderKey?: string | null | undefined; + readonly environmentId?: string | undefined; + }, +>(threads: readonly T[]): T[] { + return [...threads].sort((left, right) => { + const leftKey = left.activeOrderKey; + const rightKey = right.activeOrderKey; + if (leftKey == null && rightKey != null) return -1; + if (leftKey != null && rightKey == null) return 1; + let order = 0; + if (leftKey != null && rightKey != null) { + order = leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0; + } else { + order = activeThreadAnchorTimestampMs(right) - activeThreadAnchorTimestampMs(left); + } + return ( + order || + left.id.localeCompare(right.id) || + (left.environmentId ?? "").localeCompare(right.environmentId ?? "") + ); + }); +} + /** * planPinnedReorder specialized for mobile's Move up / Move down menu * actions: swap the moved thread with its displayed neighbor. Null when the diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 6b815591ff2b..94d7c6081250 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -116,6 +116,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ /** Server understands thread.pin.reorder (and orderKey on thread.pin). Same version-skew contract as threadSettlement. */ threadPinReorder: Schema.optionalKey(Schema.Boolean), + /** Server persists manual Active order through thread.active.reorder. */ + threadActiveReorder: Schema.optionalKey(Schema.Boolean), /** Server understands regenerateTitle on thread.meta.update. Absent on older servers, so clients hide the action instead of sending it. */ threadTitleRegeneration: Schema.optionalKey(Schema.Boolean), diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 61dbdb9a5512..b3dbc4188c23 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -784,6 +784,61 @@ it.effect("accepts a title seed in thread.turn.start", () => }), ); +it.effect("decodes active reorder commands through client and orchestration boundaries", () => + Effect.gen(function* () { + const input = { + type: "thread.active.reorder", + commandId: "cmd-active-reorder", + threadId: "thread-1", + orderKey: "gm", + }; + const clientCommand = yield* decodeClientOrchestrationCommand(input); + const command = yield* decodeOrchestrationCommand(input); + for (const decoded of [clientCommand, command]) { + assert.strictEqual(decoded.type, "thread.active.reorder"); + if (decoded.type === "thread.active.reorder") { + assert.strictEqual(decoded.threadId, "thread-1"); + assert.strictEqual(decoded.orderKey, "gm"); + } + } + const emptyKey = yield* Effect.exit( + decodeClientOrchestrationCommand({ ...input, orderKey: " " }), + ); + assert.isTrue(Exit.isFailure(emptyKey)); + }), +); + +it.effect("decodes active placement on existing metadata events while accepting old payloads", () => + Effect.gen(function* () { + const payload = { threadId: "thread-1", updatedAt: "2026-01-01T00:00:00.000Z" }; + const oldPayload = yield* decodeThreadMetaUpdatedPayload(payload); + assert.strictEqual(oldPayload.activeOrderKey, undefined); + const resetPayload = yield* decodeThreadMetaUpdatedPayload({ + ...payload, + activeOrderKey: null, + }); + assert.strictEqual(resetPayload.activeOrderKey, null); + const event = yield* decodeOrchestrationEvent({ + type: "thread.meta-updated", + sequence: 1, + eventId: "event-active-reorder", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: "2026-01-02T00:00:00.000Z", + commandId: "cmd-active-reorder", + causationEventId: null, + correlationId: null, + metadata: {}, + payload: { ...payload, activeOrderKey: "gm" }, + }); + assert.strictEqual(event.type, "thread.meta-updated"); + if (event.type === "thread.meta-updated") { + assert.strictEqual(event.payload.activeOrderKey, "gm"); + assert.strictEqual(event.payload.updatedAt, payload.updatedAt); + } + }), +); + it.effect("accepts a title regeneration intent in thread.meta.update", () => Effect.gen(function* () { const parsed = yield* decodeOrchestrationCommand({ diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 47501e323441..37f5476fecc8 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -523,6 +523,9 @@ export const OrchestrationThread = Schema.Struct({ // servers never need each other's threads to agree on the merged list. // Optional so payloads from pre-reorder servers still decode. pinOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + // Manual Active placement. Keyless threads retain their creation/re-entry + // order above the arranged run. Settling clears this slot. + activeOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), // Pending-only state. Optional so older servers remain compatible. titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)), deletedAt: Schema.NullOr(IsoDateTime), @@ -588,6 +591,7 @@ export const OrchestrationThreadShell = Schema.Struct({ snoozedAt: Schema.optional(Schema.NullOr(IsoDateTime)), pinnedAt: Schema.optional(Schema.NullOr(IsoDateTime)), pinOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + activeOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)), session: Schema.NullOr(OrchestrationSession), latestUserMessageAt: Schema.NullOr(IsoDateTime), @@ -895,6 +899,13 @@ const ThreadPinReorderCommand = Schema.Struct({ orderKey: TrimmedNonEmptyString, }); +const ThreadActiveReorderCommand = Schema.Struct({ + type: Schema.Literal("thread.active.reorder"), + commandId: CommandId, + threadId: ThreadId, + orderKey: TrimmedNonEmptyString, +}); + const ThreadMetaUpdateCommand = Schema.Struct({ type: Schema.Literal("thread.meta.update"), commandId: CommandId, @@ -1058,6 +1069,7 @@ const DispatchableClientOrchestrationCommand = Schema.Union([ ThreadPinCommand, ThreadUnpinCommand, ThreadPinReorderCommand, + ThreadActiveReorderCommand, ThreadMetaUpdateCommand, ThreadRuntimeModeSetCommand, ThreadInteractionModeSetCommand, @@ -1086,6 +1098,7 @@ export const ClientOrchestrationCommand = Schema.Union([ ThreadPinCommand, ThreadUnpinCommand, ThreadPinReorderCommand, + ThreadActiveReorderCommand, ThreadMetaUpdateCommand, ThreadRuntimeModeSetCommand, ThreadInteractionModeSetCommand, @@ -1375,6 +1388,9 @@ export const ThreadPinReorderedPayload = Schema.Struct({ export const ThreadMetaUpdatedPayload = Schema.Struct({ threadId: ThreadId, + // Order updates use this existing event so older clients can ignore the + // new field while continuing to decode the event stream. + activeOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), title: Schema.optional(TrimmedNonEmptyString), /** Intent marker consumed by the title-generation reactor. Keeping this on the existing event lets older clients safely ignore the new field. */ From 6766e682ac1a0aa33020358ba98707f4f8fee467 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 6 Sep 2026 11:10:14 -0700 Subject: [PATCH 04/71] feat(mobile): arrange active threads from both thread lists (#9730) --- .../src/features/home/HomeRouteScreen.tsx | 4 +- apps/mobile/src/features/home/HomeScreen.tsx | 94 ++++-- .../src/features/home/useThreadListActions.ts | 108 ++++--- .../threads/ThreadNavigationSidebar.tsx | 89 ++++-- .../features/threads/thread-list-v2-items.tsx | 103 +++--- .../src/features/threads/threadListV2.test.ts | 293 ++++++++++++++++++ .../src/features/threads/threadListV2.ts | 86 +++-- .../src/features/threads/threadOrder.ts | 120 +++++++ apps/mobile/src/state/thread-order.test.ts | 117 +++++++ apps/mobile/src/state/thread-order.ts | 89 ++++++ apps/mobile/src/state/use-thread-selection.ts | 4 + 11 files changed, 934 insertions(+), 173 deletions(-) create mode 100644 apps/mobile/src/features/threads/threadOrder.ts create mode 100644 apps/mobile/src/state/thread-order.test.ts create mode 100644 apps/mobile/src/state/thread-order.ts diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 943303202216..a9833d2d619f 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -49,7 +49,7 @@ export function HomeRouteScreen() { unsnoozeThread, pinThread, unpinThread, - movePinnedThread, + moveThread, regenerateThreadTitle, unsettleThread, } = useThreadListActions(); @@ -199,7 +199,7 @@ export function HomeRouteScreen() { onUnsettleThread={unsettleThread} onPinThread={pinThread} onUnpinThread={unpinThread} - onMovePinnedThread={movePinnedThread} + onMoveThread={moveThread} onRegenerateThreadTitle={regenerateThreadTitle} onEnvironmentChange={setSelectedEnvironmentId} onProjectChange={setSelectedProjectKey} diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 798a6a840c94..4a0095165a2c 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -1,3 +1,4 @@ +import { createThreadMovePlanner } from "../threads/threadOrder"; import { LegendList, type LegendListRef, @@ -11,7 +12,6 @@ import { threadSearchMatchKey, type EnvironmentThreadSearchMatch, } from "@t3tools/client-runtime/state/thread-search"; -import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort"; import { type EnvironmentId, resolveEnvironmentMachineKind, @@ -35,6 +35,7 @@ import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import { useThreadSearch } from "../../state/queries"; import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; +import { usePendingThreadOrder } from "../../state/thread-order"; import { environmentServerConfigsAtom } from "../../state/server"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { @@ -51,6 +52,7 @@ import { } from "../threads/thread-list-v2-items"; import { buildThreadListV2Items, + getThreadListV2OrderedSection, buildThreadListV2ListItems, THREAD_LIST_V2_SETTLED_INITIAL_COUNT, THREAD_LIST_V2_SETTLED_PAGE_COUNT, @@ -114,7 +116,7 @@ interface HomeScreenProps { readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void; readonly onPinThread: (thread: EnvironmentThreadShell) => Promise; readonly onUnpinThread: (thread: EnvironmentThreadShell) => Promise; - readonly onMovePinnedThread: ( + readonly onMoveThread: ( thread: EnvironmentThreadShell, direction: "up" | "down", ) => Promise; @@ -512,11 +514,11 @@ export function HomeScreen(props: HomeScreenProps) { }, [props.onPinThread], ); - const handleMovePinnedThread = useCallback( + const handleMoveThread = useCallback( (thread: EnvironmentThreadShell, direction: "up" | "down") => { - void props.onMovePinnedThread(thread, direction); + void props.onMoveThread(thread, direction); }, - [props.onMovePinnedThread], + [props.onMoveThread], ); const handleUnpinThread = useCallback( (thread: EnvironmentThreadShell) => { @@ -608,6 +610,15 @@ export function HomeScreen(props: HomeScreenProps) { } return supported; }, [serverConfigs]); + const activeReorderEnvironmentIds = useMemo(() => { + const supported = new Set(); + for (const [environmentId, config] of serverConfigs) { + if (config.environment.capabilities.threadActiveReorder === true) { + supported.add(environmentId); + } + } + return supported; + }, [serverConfigs]); const titleRegenerationEnvironmentIds = useMemo(() => { const supported = new Set(); for (const [environmentId, config] of serverConfigs) { @@ -627,20 +638,40 @@ export function HomeScreen(props: HomeScreenProps) { ), [serverConfigs], ); - // Canonical arranged pinned order (reorder-capable threads only) for the - // Move up/down position flags. Computed from all shells, not the rendered - // list, so search/scope filtering never disables or misdirects a move. - const arrangedPinnedKeys = useMemo(() => { - const pinned = sortPinnedThreadsByOrderKey( - props.threads.filter( - (thread) => - thread.pinnedAt != null && - thread.archivedAt === null && - pinReorderEnvironmentIds.has(thread.environmentId), - ), - ); - return pinned.map((thread) => `${thread.environmentId}:${thread.id}`); - }, [pinReorderEnvironmentIds, props.threads]); + const pendingOrder = usePendingThreadOrder(nowMinute, snoozeWakeTick); + const threadMovePlanners = useMemo(() => { + const sectionPlanner = (section: "pinned" | "active") => + createThreadMovePlanner({ + allThreads: props.threads, + section, + reorderableEnvironmentIds: new Set( + [...serverConfigs].flatMap(([id, config]) => + (section === "pinned" + ? config.environment.capabilities.threadPinReorder + : config.environment.capabilities.threadActiveReorder) === true + ? [id] + : [], + ), + ), + ordered: getThreadListV2OrderedSection({ + threads: props.threads, + section, + pendingOrder, + now: new Date().toISOString(), + settlementEnvironmentIds, + snoozeEnvironmentIds, + }), + }); + return { pinned: sectionPlanner("pinned"), active: sectionPlanner("active") }; + }, [ + serverConfigs, + props.threads, + pendingOrder, + settlementEnvironmentIds, + snoozeEnvironmentIds, + nowMinute, + snoozeWakeTick, + ]); const threadListV2Layout = useMemo(() => { if (!threadListV2Enabled) return { @@ -655,6 +686,7 @@ export function HomeScreen(props: HomeScreenProps) { // Settled threads are live shells; archived threads keep their original // "hidden from lists" meaning. return buildThreadListV2Items({ + pendingOrder, threads: props.threads.filter((thread) => thread.archivedAt === null), environmentId: props.selectedEnvironmentId, projectRefs: v2ScopedProjectGroup === null ? null : v2ScopedProjectGroup.projectRefs, @@ -669,6 +701,7 @@ export function HomeScreen(props: HomeScreenProps) { selectedThreadKey: null, }); }, [ + pendingOrder, nowMinute, snoozeWakeTick, snoozedShelfExpanded, @@ -784,6 +817,8 @@ export function HomeScreen(props: HomeScreenProps) { ); } const thread = item.item.thread; + const movePlanner = item.item.pinned ? threadMovePlanners.pinned : threadMovePlanners.active; + const movedId = `${thread.environmentId}:${thread.id}`; return ( 0} - canMovePinnedDown={(() => { - const index = arrangedPinnedKeys.indexOf(`${thread.environmentId}:${thread.id}`); - return index !== -1 && index < arrangedPinnedKeys.length - 1; - })()} + reorderSupported={ + item.item.pinned + ? pinReorderEnvironmentIds.has(thread.environmentId) + : activeReorderEnvironmentIds.has(thread.environmentId) + } + canMoveUp={pendingOrder === null && movePlanner(movedId, "up") !== null} + canMoveDown={pendingOrder === null && movePlanner(movedId, "down") !== null} onSnoozeThread={handleSnoozeThread} onUnsnoozeThread={handleUnsnoozeThread} onUnsettleThread={handleUnsettleThread} onPinThread={handlePinThread} onUnpinThread={handleUnpinThread} - onMovePinnedThread={handleMovePinnedThread} + onMoveThread={handleMoveThread} onSwipeableClose={handleSwipeableClose} onSwipeableWillOpen={handleSwipeableWillOpen} /> @@ -849,8 +885,10 @@ export function HomeScreen(props: HomeScreenProps) { }, [ handleDeleteThread, - arrangedPinnedKeys, - handleMovePinnedThread, + activeReorderEnvironmentIds, + threadMovePlanners, + pendingOrder, + handleMoveThread, handlePinThread, handleRegenerateThreadTitle, handleSettleThread, diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index dae6c46a89dd..7b0b7b701106 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -8,15 +8,14 @@ import { Alert } from "react-native"; import { showConfirmDialog } from "../../components/ConfirmDialogHost"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThreadSnapshots"; -import { - pinOrderKeyBetween, - planPinnedMove, - sortPinnedThreadsByOrderKey, -} from "@t3tools/client-runtime/state/thread-sort"; +import { pinOrderKeyBetween } from "@t3tools/client-runtime/state/thread-sort"; import { appAtomRegistry } from "../../state/atom-registry"; import { environmentServerConfigsAtom } from "../../state/server"; import { environmentThreadShells, threadEnvironment } from "../../state/threads"; import { useAtomCommand } from "../../state/use-atom-command"; +import { beginPendingThreadOrder, getPendingThreadOrder } from "../../state/thread-order"; +import { createPendingThreadOrder, createThreadMovePlanner } from "../threads/threadOrder"; +import { getThreadListV2OrderedSection } from "../threads/threadListV2"; /** Version skew: never send settle/unsettle to a server that predates them (capability defaults false on decode for older servers). */ @@ -222,7 +221,7 @@ export function useThreadListActions(): { readonly unsettleThread: (thread: EnvironmentThreadShell) => Promise; readonly pinThread: (thread: EnvironmentThreadShell) => Promise; readonly unpinThread: (thread: EnvironmentThreadShell) => Promise; - readonly movePinnedThread: ( + readonly moveThread: ( thread: EnvironmentThreadShell, direction: "up" | "down", ) => Promise; @@ -451,60 +450,75 @@ export function useThreadListActions(): { [updateThreadMetadata], ); - // Move up / Move down for the pinned block. Computed against the CANONICAL - // keyed pinned order (not the rendered list), so the move is valid even - // while search or a project scope filters rows: the same fractional-key - // scheme web dragging uses, one write to one thread per move (plus a - // one-time section materialization when legacy keyless pins are involved). + // Plan against the complete section so filtering does not change a move. const reorderPinnedMutation = useAtomCommand(threadEnvironment.reorderPin, { reportFailure: false, }); - // One move at a time: a second tap before the first write's event lands - // would plan from the same stale snapshot and silently collapse two moves - // into one — same double-dispatch guard as snoozeThread. - const movePinnedInFlightRef = useRef(false); - const movePinnedThread = useCallback( + const reorderActiveMutation = useAtomCommand(threadEnvironment.reorderActive, { + reportFailure: false, + }); + const moveThread = useCallback( async (thread: EnvironmentThreadShell, direction: "up" | "down") => { - if (movePinnedInFlightRef.current) return false; - if (!environmentSupportsPinReorder(thread.environmentId)) { + if (getPendingThreadOrder() !== null) return false; + const section = thread.pinnedAt != null ? "pinned" : "active"; + const configs = appAtomRegistry.get(environmentServerConfigsAtom); + const supportsReorder = (environmentId: EnvironmentThreadShell["environmentId"]) => { + const capabilities = configs.get(environmentId)?.environment.capabilities; + return section === "pinned" + ? capabilities?.threadPinReorder === true + : capabilities?.threadActiveReorder === true; + }; + if (!supportsReorder(thread.environmentId)) { Alert.alert( "Could not move thread", - "This environment's server does not support pinned reordering yet. Update the server to reorder pins.", + "This environment's server does not support reordering these threads. Update the server to arrange them.", ); return false; } const shells = appAtomRegistry.get(environmentThreadShells.threadShellsAtom); - const pinned = sortPinnedThreadsByOrderKey( - shells.filter( - (shell) => - shell.pinnedAt != null && - shell.archivedAt === null && - environmentSupportsPinReorder(shell.environmentId), + const ordered = getThreadListV2OrderedSection({ + threads: shells, + section, + now: new Date().toISOString(), + settlementEnvironmentIds: new Set( + [...configs].flatMap(([id, config]) => + config.environment.capabilities.threadSettlement === true ? [id] : [], + ), ), - ); - const orderedIds = pinned.map((shell) => scopedThreadKey(shell.environmentId, shell.id)); - const assignments = planPinnedMove({ - orderedIds, - keysById: new Map( - pinned.map((shell) => [ - scopedThreadKey(shell.environmentId, shell.id), - shell.pinOrderKey ?? null, - ]), + snoozeEnvironmentIds: new Set( + [...configs].flatMap(([id, config]) => + config.environment.capabilities.threadSnooze === true ? [id] : [], + ), ), - movedId: scopedThreadKey(thread.environmentId, thread.id), - direction, }); - if (assignments === null || assignments.length === 0) return false; + const assignments = createThreadMovePlanner({ + allThreads: shells, + ordered, + section, + reorderableEnvironmentIds: new Set([...configs.keys()].filter(supportsReorder)), + })(scopedThreadKey(thread.environmentId, thread.id), direction); + if (assignments === null) return false; const shellByKey = new Map( - pinned.map((shell) => [scopedThreadKey(shell.environmentId, shell.id), shell]), + ordered.map((shell) => [scopedThreadKey(shell.environmentId, shell.id), shell]), ); selectionHaptic(); - movePinnedInFlightRef.current = true; + const pending = beginPendingThreadOrder( + createPendingThreadOrder({ + section, + ordered, + movedId: scopedThreadKey(thread.environmentId, thread.id), + direction, + assignments, + }), + ); + let succeeded = false; + const reorder = section === "pinned" ? reorderPinnedMutation : reorderActiveMutation; try { for (const assignment of assignments) { + if (!pending.isPending()) return false; const target = shellByKey.get(assignment.id); if (target === undefined) continue; - const result = await reorderPinnedMutation({ + const result = await reorder({ environmentId: target.environmentId, input: { threadId: target.id, orderKey: assignment.orderKey }, }); @@ -514,20 +528,20 @@ export function useThreadListActions(): { "Could not move thread", error instanceof Error && error.message.trim().length > 0 ? error.message - : "The pinned thread could not be moved.", + : "The thread could not be moved.", ); - // No rollback: keys already written are valid orderings on their - // own (each write is a complete, consistent placement), so a - // partial materialization leaves the list sensible, not corrupt. + // Keep confirmed keys when a later environment rejects its write. return false; } } + succeeded = true; + pending.complete(); return true; } finally { - movePinnedInFlightRef.current = false; + if (!succeeded) pending.cancel(); } }, - [reorderPinnedMutation], + [reorderActiveMutation, reorderPinnedMutation], ); const confirmDeleteThread = useConfirmDeleteThread(executeAction); @@ -541,7 +555,7 @@ export function useThreadListActions(): { unsettleThread, pinThread, unpinThread, - movePinnedThread, + moveThread, regenerateThreadTitle, }; } diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index f529432070bf..12f5ac8ce4f3 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -1,3 +1,4 @@ +import { createThreadMovePlanner } from "./threadOrder"; import type { EnvironmentProject, EnvironmentThreadShell, @@ -10,7 +11,6 @@ import { LegendList } from "@legendapp/list/react-native"; import type { MenuAction } from "@react-native-menu/menu"; import { useAtomValue } from "@effect/atom-react"; import { type EnvironmentId, resolveEnvironmentMachineKind } from "@t3tools/contracts"; -import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { LayoutChangeEvent } from "react-native"; import { Platform, Pressable, StyleSheet, TextInput, View } from "react-native"; @@ -30,6 +30,7 @@ import { useProjects, useThreadShells } from "../../state/entities"; import { useThreadSearch } from "../../state/queries"; import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled"; import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences"; +import { usePendingThreadOrder } from "../../state/thread-order"; import { environmentServerConfigsAtom } from "../../state/server"; import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; import { useWorkspaceState } from "../../state/workspace"; @@ -78,6 +79,7 @@ import { } from "./thread-list-v2-items"; import { buildThreadListV2Items, + getThreadListV2OrderedSection, buildThreadListV2ListItems, THREAD_LIST_V2_SETTLED_INITIAL_COUNT, THREAD_LIST_V2_SETTLED_PAGE_COUNT, @@ -158,7 +160,7 @@ function ThreadNavigationSidebarPane( unsettleThread, pinThread, unpinThread, - movePinnedThread, + moveThread, regenerateThreadTitle, } = useThreadListActions(); const threadListV2Enabled = useThreadListV2Enabled(); @@ -437,6 +439,15 @@ function ThreadNavigationSidebarPane( } return supported; }, [serverConfigs]); + const activeReorderEnvironmentIds = useMemo(() => { + const supported = new Set(); + for (const [environmentId, config] of serverConfigs) { + if (config.environment.capabilities.threadActiveReorder === true) { + supported.add(environmentId); + } + } + return supported; + }, [serverConfigs]); const titleRegenerationEnvironmentIds = useMemo(() => { const supported = new Set(); for (const [environmentId, config] of serverConfigs) { @@ -456,19 +467,40 @@ function ThreadNavigationSidebarPane( ), [serverConfigs], ); - // Canonical arranged pinned order for Move up/down flags — computed from - // all shells so search/scope filtering never disables a valid move. - const arrangedPinnedKeys = useMemo(() => { - const pinned = sortPinnedThreadsByOrderKey( - threads.filter( - (thread) => - thread.pinnedAt != null && - thread.archivedAt === null && - pinReorderEnvironmentIds.has(thread.environmentId), - ), - ); - return pinned.map((thread) => `${thread.environmentId}:${thread.id}`); - }, [pinReorderEnvironmentIds, threads]); + const pendingOrder = usePendingThreadOrder(nowMinute, snoozeWakeTick); + const threadMovePlanners = useMemo(() => { + const sectionPlanner = (section: "pinned" | "active") => + createThreadMovePlanner({ + allThreads: threads, + section, + reorderableEnvironmentIds: new Set( + [...serverConfigs].flatMap(([id, config]) => + (section === "pinned" + ? config.environment.capabilities.threadPinReorder + : config.environment.capabilities.threadActiveReorder) === true + ? [id] + : [], + ), + ), + ordered: getThreadListV2OrderedSection({ + threads, + section, + pendingOrder, + now: new Date().toISOString(), + settlementEnvironmentIds, + snoozeEnvironmentIds, + }), + }); + return { pinned: sectionPlanner("pinned"), active: sectionPlanner("active") }; + }, [ + serverConfigs, + threads, + pendingOrder, + settlementEnvironmentIds, + snoozeEnvironmentIds, + nowMinute, + snoozeWakeTick, + ]); const threadListV2Layout = useMemo(() => { if (!threadListV2Enabled) return { @@ -481,6 +513,7 @@ function ThreadNavigationSidebarPane( nextSnoozeWakeAt: null, }; return buildThreadListV2Items({ + pendingOrder, threads: threads.filter((thread) => thread.archivedAt === null), environmentId: options.selectedEnvironmentId, projectRefs: selectedProjectScope === null ? null : selectedProjectScope.projectRefs, @@ -495,6 +528,7 @@ function ThreadNavigationSidebarPane( selectedThreadKey: props.selectedThreadKey ?? null, }); }, [ + pendingOrder, nowMinute, snoozeWakeTick, snoozedShelfExpanded, @@ -844,6 +878,10 @@ function ThreadNavigationSidebarPane( } case "v2-thread": { const thread = item.item.thread; + const movePlanner = item.item.pinned + ? threadMovePlanners.pinned + : threadMovePlanners.active; + const movedId = `${thread.environmentId}:${thread.id}`; const scopeKey = scopedProjectKey(thread.environmentId, thread.projectId); return ( 0 + reorderSupported={ + item.item.pinned + ? pinReorderEnvironmentIds.has(thread.environmentId) + : activeReorderEnvironmentIds.has(thread.environmentId) } - canMovePinnedDown={(() => { - const index = arrangedPinnedKeys.indexOf(`${thread.environmentId}:${thread.id}`); - return index !== -1 && index < arrangedPinnedKeys.length - 1; - })()} + canMoveUp={pendingOrder === null && movePlanner(movedId, "up") !== null} + canMoveDown={pendingOrder === null && movePlanner(movedId, "down") !== null} onSnoozeThread={snoozeThread} onUnsnoozeThread={unsnoozeThread} onUnsettleThread={unsettleThread} onPinThread={pinThread} onUnpinThread={unpinThread} - onMovePinnedThread={movePinnedThread} + onMoveThread={moveThread} onSwipeableClose={handleSwipeableClose} onSwipeableWillOpen={handleSwipeableWillOpen} simultaneousSwipeGesture={sidebarScrollGesture} @@ -1027,14 +1064,16 @@ function ThreadNavigationSidebarPane( }, [ archiveThread, - arrangedPinnedKeys, + activeReorderEnvironmentIds, + threadMovePlanners, + pendingOrder, confirmDeletePendingTask, confirmDeleteThread, handleSelectThread, handleSwipeableClose, handleSwipeableWillOpen, machineByEnvironmentId, - movePinnedThread, + moveThread, openPendingTask, pinReorderEnvironmentIds, pinThread, 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 e66fa778476b..37b211e835f9 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -367,14 +367,13 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly pinningSupported: boolean; /** False on servers that predate thread title regeneration. */ readonly titleRegenerationSupported: boolean; - /** False on servers that predate thread.pin.reorder. Gates the pinned - Move up / Move down menu items. */ - readonly pinReorderSupported?: boolean; - readonly onMovePinnedThread?: (thread: EnvironmentThreadShell, direction: "up" | "down") => void; - /** Position flags for the pinned block so the menu disables the move that + /** Server supports reordering this card's section. */ + readonly reorderSupported?: boolean; + readonly onMoveThread?: (thread: EnvironmentThreadShell, direction: "up" | "down") => void; + /** Position flags for the card's section so the menu disables the move that would fall off the end of the list. */ - readonly canMovePinnedUp?: boolean; - readonly canMovePinnedDown?: boolean; + readonly canMoveUp?: boolean; + readonly canMoveDown?: boolean; readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; readonly onSwipeableClose: (methods: SwipeableMethods) => void; readonly searchMatch?: EnvironmentThreadSearchMatch; @@ -397,7 +396,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { onArchiveThread, onPinThread, onUnpinThread, - onMovePinnedThread, + onMoveThread, } = props; const snoozedRow = props.snoozed === true; const pinnedRow = props.pinned === true; @@ -436,14 +435,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const handleUnsettle = useCallback(() => onUnsettleThread(thread), [onUnsettleThread, thread]); const handlePin = useCallback(() => onPinThread(thread), [onPinThread, thread]); const handleUnpin = useCallback(() => onUnpinThread(thread), [onUnpinThread, thread]); - const handleMovePinnedUp = useCallback( - () => onMovePinnedThread?.(thread, "up"), - [onMovePinnedThread, thread], - ); - const handleMovePinnedDown = useCallback( - () => onMovePinnedThread?.(thread, "down"), - [onMovePinnedThread, thread], - ); + const handleMoveUp = useCallback(() => onMoveThread?.(thread, "up"), [onMoveThread, thread]); + const handleMoveDown = useCallback(() => onMoveThread?.(thread, "down"), [onMoveThread, thread]); const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]); // Swipe: the v2 primary action is the lifecycle transition. Un-settling a @@ -482,38 +475,39 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { // 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.) - const pinMenuItem = useMemo( - () => - props.pinningSupported + const arrangementMenuItems = useMemo( + () => [ + ...(variant === "card" && props.reorderSupported === true + ? [ + { + id: "move-up", + title: "Move up", + image: "arrow.up", + attributes: { disabled: props.canMoveUp !== true }, + } satisfies MenuAction, + { + id: "move-down", + title: "Move down", + image: "arrow.down", + attributes: { disabled: props.canMoveDown !== true }, + } satisfies MenuAction, + ] + : []), + ...(props.pinningSupported ? [ - ...(pinnedRow && props.pinReorderSupported === true - ? [ - { - id: "move-pin-up", - title: "Move up", - image: "arrow.up", - attributes: { disabled: props.canMovePinnedUp !== true }, - } satisfies MenuAction, - { - id: "move-pin-down", - title: "Move down", - image: "arrow.down", - attributes: { disabled: props.canMovePinnedDown !== true }, - } satisfies MenuAction, - ] - : []), thread.pinnedAt != null ? { id: "unpin", title: "Unpin", image: "pin.slash" } : { id: "pin", title: "Pin", image: "pin" }, ] - : [], + : []), + ], [ - pinnedRow, - props.canMovePinnedDown, - props.canMovePinnedUp, - props.pinReorderSupported, + props.canMoveDown, + props.canMoveUp, + props.reorderSupported, props.pinningSupported, thread.pinnedAt, + variant, ], ); const titleRegenerationMenuItems = useMemo( @@ -533,37 +527,42 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { image: "clock", subactions: snoozePresetActions, }, - ...pinMenuItem, + ...arrangementMenuItems, ...titleRegenerationMenuItems, { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, ], - [pinMenuItem, snoozePresetActions, titleRegenerationMenuItems], + [arrangementMenuItems, snoozePresetActions, titleRegenerationMenuItems], ); const cardMenuActions = useMemo( () => [ CARD_MENU_ACTIONS[0]!, - ...pinMenuItem, + ...arrangementMenuItems, ...titleRegenerationMenuItems, ...CARD_MENU_ACTIONS.slice(1), ], - [pinMenuItem, titleRegenerationMenuItems], + [arrangementMenuItems, titleRegenerationMenuItems], ); const slimMenuActions = useMemo( () => [ SLIM_MENU_ACTIONS[0]!, - ...(thread.pinnedAt != null ? pinMenuItem : []), + ...(thread.pinnedAt != null ? arrangementMenuItems : []), ...titleRegenerationMenuItems, SLIM_MENU_ACTIONS[1]!, ], - [pinMenuItem, thread.pinnedAt, titleRegenerationMenuItems], + [arrangementMenuItems, thread.pinnedAt, titleRegenerationMenuItems], ); const snoozedMenuActions = useMemo( () => [SNOOZED_MENU_ACTIONS[0]!, ...titleRegenerationMenuItems, SNOOZED_MENU_ACTIONS[1]!], [titleRegenerationMenuItems], ); const legacyMenuActions = useMemo( - () => [LEGACY_MENU_ACTIONS[0]!, ...titleRegenerationMenuItems, LEGACY_MENU_ACTIONS[1]!], - [titleRegenerationMenuItems], + () => [ + LEGACY_MENU_ACTIONS[0]!, + ...arrangementMenuItems, + ...titleRegenerationMenuItems, + LEGACY_MENU_ACTIONS[1]!, + ], + [arrangementMenuItems, titleRegenerationMenuItems], ); const handleMenuAction = useCallback( ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { @@ -572,8 +571,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { if (nativeEvent.event === "unsnooze") handleUnsnooze(); if (nativeEvent.event === "pin") handlePin(); if (nativeEvent.event === "unpin") handleUnpin(); - if (nativeEvent.event === "move-pin-up") handleMovePinnedUp(); - if (nativeEvent.event === "move-pin-down") handleMovePinnedDown(); + if (nativeEvent.event === "move-up") handleMoveUp(); + if (nativeEvent.event === "move-down") handleMoveDown(); if (nativeEvent.event === "archive") handleArchive(); if (nativeEvent.event === "regenerate-title") handleRegenerateTitle(); if (nativeEvent.event === "delete") handleDelete(); @@ -592,8 +591,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { handleArchive, handleDelete, handleRegenerateTitle, - handleMovePinnedDown, - handleMovePinnedUp, + handleMoveDown, + handleMoveUp, handlePin, handleSettle, handleSnooze, diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index 33ae27cc0638..4b1abb78c272 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -1,3 +1,10 @@ +import { planPinnedMove } from "@t3tools/client-runtime/state/thread-sort"; +import { + createPendingThreadOrder, + createThreadMovePlanner, + reconcilePendingThreadOrder, + type PendingThreadOrder, +} from "./threadOrder"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import { resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled"; @@ -16,6 +23,7 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { buildThreadListV2Items, buildThreadListV2ListItems, + getThreadListV2OrderedSection, resolveThreadListV2Enabled, resolveThreadListV2SnoozeMenuSelection, resolveThreadListV2SnoozeGateExpiryMs, @@ -258,6 +266,15 @@ describe("resolveThreadListV2SnoozeGateExpiryMs", () => { }); describe("sortThreadsForListV2", () => { + it("honors a saved active order and leaves new threads above it", () => { + const sorted = sortThreadsForListV2([ + { id: "newer-arranged", createdAt: "2026-06-01T12:00:00.000Z", activeOrderKey: "t" }, + { id: "older-arranged", createdAt: "2026-06-01T08:00:00.000Z", activeOrderKey: "f" }, + { id: "new", createdAt: "2026-06-01T13:00:00.000Z" }, + ]); + expect(sorted.map((thread) => thread.id)).toEqual(["new", "older-arranged", "newer-arranged"]); + }); + it("orders by creation time, newest first, ignoring activity", () => { const sorted = sortThreadsForListV2([ { id: "oldest", createdAt: "2026-06-01T08:00:00.000Z" }, @@ -281,6 +298,55 @@ describe("sortThreadsForListV2", () => { }); }); +describe("getThreadListV2OrderedSection", () => { + it("uses each saved order and excludes settled, snoozed, and archived rows", () => { + const threads = [ + makeThread({ id: ThreadId.make("active-later"), title: "Later", activeOrderKey: "t" }), + makeThread({ id: ThreadId.make("active-first"), title: "First", activeOrderKey: "f" }), + makeThread({ id: ThreadId.make("active-new"), title: "New" }), + makeThread({ + id: ThreadId.make("pinned-later"), + title: "Pinned later", + pinnedAt: NOW, + pinOrderKey: "t", + activeOrderKey: "f", + }), + makeThread({ + id: ThreadId.make("pinned-first"), + title: "Pinned first", + pinnedAt: NOW, + pinOrderKey: "f", + activeOrderKey: "t", + }), + makeThread({ id: ThreadId.make("settled"), title: "Settled", settledOverride: "settled" }), + makeThread({ id: ThreadId.make("archived"), title: "Archived", archivedAt: NOW }), + makeThread({ + id: ThreadId.make("snoozed"), + title: "Snoozed", + snoozedUntil: "2026-06-03T10:00:00.000Z", + snoozedAt: NOW, + }), + makeThread({ + id: ThreadId.make("pinned-snoozed"), + title: "Pinned snoozed", + pinnedAt: NOW, + snoozedUntil: "2026-06-03T10:00:00.000Z", + snoozedAt: NOW, + }), + ]; + expect( + getThreadListV2OrderedSection({ threads, section: "active", now: NOW }).map( + (thread) => thread.id, + ), + ).toEqual(["active-new", "active-first", "active-later"]); + expect( + getThreadListV2OrderedSection({ threads, section: "pinned", now: NOW }).map( + (thread) => thread.id, + ), + ).toEqual(["pinned-first", "pinned-later"]); + }); +}); + describe("buildThreadListV2Items", () => { it("places a persisted settled thread in the settled shelf", () => { const thread = makeThread({ @@ -937,3 +1003,230 @@ describe("buildThreadListV2ListItems", () => { ]); }); }); + +describe("pending mobile thread moves", () => { + function fixture(section: "active" | "pinned" = "active") { + const rows = ["a", "b", "c"].map((id, index) => + makeThread({ + id: ThreadId.make(id), + title: id === "a" ? "hidden" : "match", + createdAt: `2026-06-01T0${3 - index}:00:00.000Z`, + pinnedAt: section === "pinned" ? `2026-06-01T0${3 - index}:00:00.000Z` : null, + }), + ); + const ordered = getThreadListV2OrderedSection({ threads: rows, section, now: NOW }); + const orderedIds = ordered.map((row) => `${row.environmentId}:${row.id}`); + const movedId = orderedIds[2]!; + const assignments = planPinnedMove({ + orderedIds, + keysById: new Map(orderedIds.map((id) => [id, null])), + movedId, + direction: "up", + })!; + const pending = createPendingThreadOrder({ + section, + ordered, + movedId, + direction: "up", + assignments, + }); + const update = (current: EnvironmentThreadShell[], assignment: (typeof assignments)[number]) => + current.map((row) => + `${row.environmentId}:${row.id}` === assignment.id + ? { + ...row, + [section === "pinned" ? "pinOrderKey" : "activeOrderKey"]: assignment.orderKey, + } + : row, + ); + return { rows, assignments, pending, update }; + } + + function layout( + rows: EnvironmentThreadShell[], + pendingOrder: PendingThreadOrder | null, + searchQuery = "", + ) { + return buildThreadListV2Items({ + threads: rows, + pendingOrder, + environmentId: null, + searchQuery, + now: NOW, + }).items.map((item) => item.thread.id); + } + + it.each(["active", "pinned"] as const)( + "holds %s order through every intermediate key upsert", + (section) => { + const { rows, assignments, pending, update } = fixture(section); + let current = rows; + let hold: PendingThreadOrder | null = pending; + const desired = pending.orderedIds.map((id) => id.split(":")[1]); + expect(layout(current, hold)).toEqual(desired); + for (const assignment of assignments) { + current = update(current, assignment); + hold = reconcilePendingThreadOrder( + hold!, + getThreadListV2OrderedSection({ threads: current, section, now: NOW }), + ); + expect(hold).not.toBeNull(); + expect(layout(current, hold)).toEqual(desired); + } + expect(reconcilePendingThreadOrder({ ...hold!, commandsComplete: true }, current)).toBeNull(); + expect(layout(current, null)).toEqual(desired); + }, + ); + + it("keeps the action guard pending when receipts precede canonical shells", () => { + const { rows, assignments, pending, update } = fixture(); + let hold: PendingThreadOrder | null = { ...pending, commandsComplete: true }; + let current = rows; + expect(reconcilePendingThreadOrder(hold, current)).toBe(hold); + for (const [index, assignment] of assignments.entries()) { + current = update(current, assignment); + hold = reconcilePendingThreadOrder(hold!, current); + expect(hold === null).toBe(index === assignments.length - 1); + expect(layout(current, hold)).toEqual(["a", "c", "b"]); + } + }); + + it("keeps search results in the full pending section order", () => { + const { rows, assignments, pending, update } = fixture(); + const current = update(update(rows, assignments[0]!), assignments[1]!); + expect(layout(current, pending, "match")).toEqual(["c", "b"]); + }); + + it("releases for real section membership and foreign key changes", () => { + const { rows, pending } = fixture(); + expect(reconcilePendingThreadOrder(pending, rows.slice(1))).toBeNull(); + const newRow = makeThread({ id: ThreadId.make("new"), title: "new" }); + expect(reconcilePendingThreadOrder(pending, [...rows, newRow])).toBeNull(); + expect( + reconcilePendingThreadOrder( + pending, + rows.map((row, index) => (index === 0 ? { ...row, activeOrderKey: "zz" } : row)), + ), + ).toBeNull(); + const settled = rows.map((row, index) => + index === 0 ? { ...row, settledOverride: "settled" as const } : row, + ); + expect(layout(settled, pending)).toEqual(layout(settled, null)); + }); + + it("does not hide a concurrent return to a previously confirmed key", () => { + const { rows, assignments, pending, update } = fixture(); + const confirmed = reconcilePendingThreadOrder(pending, update(rows, assignments[0]!))!; + expect(reconcilePendingThreadOrder(confirmed, rows)).toBeNull(); + }); + + it("preserves the hold for activity but releases for a reopened sort anchor", () => { + const { rows, pending } = fixture(); + expect( + reconcilePendingThreadOrder( + pending, + rows.map((row) => ({ ...row, updatedAt: NOW })), + ), + ).toBe(pending); + expect( + reconcilePendingThreadOrder( + pending, + rows.map((row, index) => (index === 0 ? { ...row, unsettledAt: NOW } : row)), + ), + ).toBeNull(); + }); +}); + +describe("mobile move availability", () => { + const oldEnvironment = EnvironmentId.make("older-server"); + function rows(section: "active" | "pinned", keys: readonly (string | null)[]) { + return keys.map((key, index) => + makeThread({ + id: ThreadId.make(`move-${index}`), + title: `Move ${index}`, + environmentId: index === 1 ? oldEnvironment : environmentId, + activeOrderKey: section === "active" ? key : null, + pinOrderKey: section === "pinned" ? key : null, + pinnedAt: section === "pinned" ? NOW : null, + }), + ); + } + + it.each(["active", "pinned"] as const)( + "keeps unsupported keyed %s neighbors as usable anchors", + (section) => { + const ordered = rows(section, ["bb", "dd", "ff"]); + const plan = createThreadMovePlanner({ + ordered, + section, + reorderableEnvironmentIds: new Set([environmentId]), + }); + const assignments = plan(`${environmentId}:move-0`, "down"); + expect(assignments).toHaveLength(1); + expect(assignments![0]!.id).toBe(`${environmentId}:move-0`); + expect(assignments![0]!.orderKey > "dd").toBe(true); + expect(assignments![0]!.orderKey < "ff").toBe(true); + expect(plan(`${oldEnvironment}:move-1`, "up")).toBeNull(); + expect(plan(`${environmentId}:move-0`, "up")).toBeNull(); + }, + ); + + it.each(["active", "pinned"] as const)( + "disables %s moves requiring unsupported keyless materialization", + (section) => { + const ordered = rows(section, [null, null, null]); + const plan = createThreadMovePlanner({ + ordered, + section, + reorderableEnvironmentIds: new Set([environmentId]), + }); + expect(plan(`${environmentId}:move-0`, "down")).toBeNull(); + expect(plan(`${environmentId}:move-2`, "up")).toBeNull(); + const supported = createThreadMovePlanner({ + ordered, + section, + reorderableEnvironmentIds: new Set([environmentId, oldEnvironment]), + }); + expect(supported(`${environmentId}:move-0`, "down")).toHaveLength(3); + }, + ); + + it.each(["active", "pinned"] as const)( + "reserves snoozed %s keys when moving visible rows", + (section) => { + const ordered = rows(section, ["bb", "dd", "ff"]); + const input = { ordered, section, reorderableEnvironmentIds: new Set([environmentId]) }; + const collision = createThreadMovePlanner(input)(`${environmentId}:move-0`, "down")![0]! + .orderKey; + const hidden = { + ...ordered[0]!, + id: ThreadId.make("snoozed"), + snoozedAt: NOW, + snoozedUntil: "2099-01-01T00:00:00.000Z", + pinOrderKey: section === "pinned" ? collision : null, + activeOrderKey: section === "active" ? collision : null, + }; + const assignments = createThreadMovePlanner({ ...input, allThreads: [...ordered, hidden] })( + `${environmentId}:move-0`, + "down", + ); + expect(assignments).toHaveLength(1); + expect(assignments![0]!.orderKey).not.toBe(collision); + expect(assignments![0]!.orderKey > "dd" && assignments![0]!.orderKey < "ff").toBe(true); + }, + ); + + it("allows an independent keyed move despite an unsupported keyless row elsewhere", () => { + const ordered = rows("active", [null, null, "bb", "dd", "ff"]); + const plan = createThreadMovePlanner({ + ordered, + section: "active", + reorderableEnvironmentIds: new Set([environmentId]), + }); + const assignments = plan(`${environmentId}:move-4`, "up"); + expect(assignments).toHaveLength(1); + expect(assignments![0]!.id).toBe(`${environmentId}:move-4`); + expect(assignments![0]!.orderKey > "bb").toBe(true); + expect(assignments![0]!.orderKey < "dd").toBe(true); + }); +}); diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 2b44851f9309..7b6b44c00bdf 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -9,7 +9,7 @@ import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled" import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import { - activeThreadAnchorTimestampMs, + sortActiveThreadsByOrderKey, resolveSettledThreadTimestamp, sortPinnedThreadsByOrderKey, } from "@t3tools/client-runtime/state/thread-sort"; @@ -17,6 +17,12 @@ import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; +import { + applyPendingThreadOrder, + reconcilePendingThreadOrder, + type PendingThreadOrder, +} from "./threadOrder"; + export { snoozeWakeLabel }; /** @@ -150,28 +156,54 @@ function parseTimestampMs(isoDate: string): number { return Number.isNaN(parsed) ? 0 : parsed; } -/** - * v2 sort: static order, newest anchor on top. Activity NEVER reorders the - * list — a row holds its position between lifecycle transitions. The anchor - * is creation time until an un-settle re-anchors it (see - * activeThreadAnchorTimestampMs), so an un-settled thread surfaces at the - * top instead of sinking back to its creation-order slot. Mirrors web's - * sortThreadsForSidebar. - */ +/** The active order shared by web and native: new/reopened rows, then the + saved arrangement. Activity does not move a thread. */ export function sortThreadsForListV2< T extends { readonly id: string; readonly createdAt: string; readonly unsettledAt?: string | null | undefined; + readonly activeOrderKey?: string | null | undefined; + readonly environmentId?: string | undefined; }, >(threads: readonly T[]): T[] { - // .sort() on a copy, not .toSorted(): Hermes doesn't ship the ES2023 - // change-by-copy array methods. - return [...threads].sort( - (left, right) => - activeThreadAnchorTimestampMs(right) - activeThreadAnchorTimestampMs(left) || - left.id.localeCompare(right.id), - ); + return sortActiveThreadsByOrderKey(threads); +} + +/** Canonical card section for Move up/down, independent of search or scope. */ +export function getThreadListV2OrderedSection(input: { + readonly threads: readonly EnvironmentThreadShell[]; + readonly section: "pinned" | "active"; + readonly pendingOrder?: PendingThreadOrder | null; + readonly now: string; + readonly settlementEnvironmentIds?: ReadonlySet; + readonly snoozeEnvironmentIds?: ReadonlySet; +}): EnvironmentThreadShell[] { + const threads = input.threads.filter((thread) => { + if (thread.archivedAt !== null) return false; + if ( + (input.settlementEnvironmentIds?.has(thread.environmentId) ?? true) && + thread.settledOverride === "settled" + ) { + return false; + } + if ( + (input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true) && + effectiveSnoozed(thread, { now: input.now }) + ) { + return false; + } + return (thread.pinnedAt != null) === (input.section === "pinned"); + }); + const ordered = + input.section === "pinned" + ? sortPinnedThreadsByOrderKey(threads) + : sortActiveThreadsByOrderKey(threads); + const pending = + input.pendingOrder?.section === input.section + ? reconcilePendingThreadOrder(input.pendingOrder, ordered) + : null; + return applyPendingThreadOrder(ordered, input.section, pending); } export interface ThreadListV2Item { @@ -299,10 +331,11 @@ export function buildThreadListV2ListItems(input: { } /** - * Partitions visible threads into the active card block (creation order) and + * Partitions visible threads into the active card block (saved order) and * the settled recency tail, matching the web v2 list. */ export function buildThreadListV2Items(input: { + readonly pendingOrder?: PendingThreadOrder | null; readonly threads: ReadonlyArray; readonly environmentId: EnvironmentId | null; readonly projectRefs?: ReadonlyArray<{ @@ -331,6 +364,17 @@ export function buildThreadListV2Items(input: { readonly selectedThreadKey?: string | null; }): ThreadListV2Layout { const now = input.now; + const pending = + input.pendingOrder == null + ? null + : reconcilePendingThreadOrder( + input.pendingOrder, + getThreadListV2OrderedSection({ + ...input, + section: input.pendingOrder.section, + pendingOrder: null, + }), + ); const query = input.searchQuery.trim().toLocaleLowerCase(); const projectKeys = input.projectRefs ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`)) @@ -382,7 +426,7 @@ export function buildThreadListV2Items(input: { } } - const orderedActive = sortThreadsForListV2(active); + const orderedActive = applyPendingThreadOrder(sortThreadsForListV2(active), "active", pending); const orderedSnoozed = [...snoozed].sort( (left, right) => parseTimestampMs(left.snoozedUntil ?? "") - parseTimestampMs(right.snoozedUntil ?? ""), @@ -414,7 +458,11 @@ export function buildThreadListV2Items(input: { ); const items: ThreadListV2Item[] = []; - for (const thread of sortPinnedThreadsByOrderKey(pinned)) { + for (const thread of applyPendingThreadOrder( + sortPinnedThreadsByOrderKey(pinned), + "pinned", + pending, + )) { items.push({ thread, variant: "card", diff --git a/apps/mobile/src/features/threads/threadOrder.ts b/apps/mobile/src/features/threads/threadOrder.ts new file mode 100644 index 000000000000..2b722e69428b --- /dev/null +++ b/apps/mobile/src/features/threads/threadOrder.ts @@ -0,0 +1,120 @@ +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { planPinnedMove } from "@t3tools/client-runtime/state/thread-sort"; +import type { EnvironmentId } from "@t3tools/contracts"; + +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; + readonly assignments: ReadonlyMap; + readonly confirmed: ReadonlySet; + 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; +}) { + 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.ordered + .filter((row) => input.reorderableEnvironmentIds.has(row.environmentId)) + .map(rowId), + ); + return (movedId: string, direction: "up" | "down") => { + if (!writableIds.has(movedId)) return null; + const assignments = planPinnedMove({ orderedIds, keysById, movedId, direction }); + 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: "up" | "down"; + readonly assignments: readonly { readonly id: string; readonly orderKey: string }[]; +}): PendingThreadOrder { + const orderedIds = input.ordered.map(rowId); + const from = orderedIds.indexOf(input.movedId); + orderedIds.splice(from, 1); + orderedIds.splice(from + (input.direction === "up" ? -1 : 1), 0, input.movedId); + 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( + 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), + ); +} diff --git a/apps/mobile/src/state/thread-order.test.ts b/apps/mobile/src/state/thread-order.test.ts new file mode 100644 index 000000000000..4959ad989626 --- /dev/null +++ b/apps/mobile/src/state/thread-order.test.ts @@ -0,0 +1,117 @@ +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import type { Atom } from "effect/unstable/reactivity"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { createPendingThreadOrder } from "../features/threads/threadOrder"; +import { appAtomRegistry } from "./atom-registry"; +import { + beginPendingThreadOrder, + getPendingThreadOrder, + pendingThreadOrderAtom, +} from "./thread-order"; +import { environmentThreadShells } from "./threads"; + +vi.mock("./atom-registry", async () => { + const { AtomRegistry } = await import("effect/unstable/reactivity"); + return { appAtomRegistry: AtomRegistry.make() }; +}); +vi.mock("./threads", async () => { + const { Atom } = await import("effect/unstable/reactivity"); + return { environmentThreadShells: { threadShellsAtom: Atom.make([]).pipe(Atom.keepAlive) } }; +}); +vi.mock("./server", async () => { + const { Atom } = await import("effect/unstable/reactivity"); + return { environmentServerConfigsAtom: Atom.make(new Map()).pipe(Atom.keepAlive) }; +}); + +// The mocked shell source is writable so tests can deliver canonical upserts. +const shellsAtom = environmentThreadShells.threadShellsAtom as Atom.Writable< + readonly EnvironmentThreadShell[], + readonly EnvironmentThreadShell[] +>; + +function fixture() { + // Only section membership and order fields are read by this coordinator. + const rows = ["a", "b"].map( + (id, index) => + ({ + id: ThreadId.make(id), + environmentId: EnvironmentId.make("env"), + createdAt: `2026-06-01T0${2 - index}:00:00.000Z`, + archivedAt: null, + pinnedAt: null, + activeOrderKey: null, + }) as EnvironmentThreadShell, + ); + appAtomRegistry.set(shellsAtom, rows); + const pending = createPendingThreadOrder({ + section: "active", + ordered: rows, + movedId: "env:b", + direction: "up", + assignments: [ + { id: "env:b", orderKey: "aa" }, + { id: "env:a", orderKey: "bb" }, + ], + }); + const start = () => beginPendingThreadOrder(pending); + const upsert = (id: string, key: string) => { + const current = appAtomRegistry.get(shellsAtom); + appAtomRegistry.set( + shellsAtom, + current.map((row) => (row.id === id ? { ...row, activeOrderKey: key } : row)), + ); + }; + return { rows, start, upsert }; +} + +afterEach(() => appAtomRegistry.reset()); + +describe("shared mobile pending move", () => { + it("blocks another pickup after receipts and clears on final canonical upsert", () => { + const { start, upsert } = fixture(); + const move = start(); + move.complete(); + expect(getPendingThreadOrder()).not.toBeNull(); + upsert("b", "aa"); + expect(getPendingThreadOrder()).not.toBeNull(); + upsert("a", "bb"); + expect(getPendingThreadOrder()).toBeNull(); + expect(move.isPending()).toBe(false); + }); + + it("waits for receipts when shells arrive first", () => { + const { start, upsert } = fixture(); + const move = start(); + upsert("b", "aa"); + upsert("a", "bb"); + expect(getPendingThreadOrder()).not.toBeNull(); + move.complete(); + expect(getPendingThreadOrder()).toBeNull(); + }); + + it.each(["failure", "interruption"])("releases a %s without restoring old canonical keys", () => { + const { start, upsert } = fixture(); + const move = start(); + upsert("b", "aa"); + move.cancel(); + expect(getPendingThreadOrder()).toBeNull(); + expect(appAtomRegistry.get(shellsAtom)[1]?.activeOrderKey).toBe("aa"); + const next = start(); + move.cancel(); + expect(next.isPending()).toBe(true); + next.cancel(); + }); + + it("stops remaining writes when a canonical membership change invalidates the move", () => { + const { rows, start } = fixture(); + const move = start(); + appAtomRegistry.set(shellsAtom, rows.slice(1)); + expect(move.isPending()).toBe(false); + expect(appAtomRegistry.get(pendingThreadOrderAtom)).toBeNull(); + move.complete(); + appAtomRegistry.set(shellsAtom, rows); + expect(getPendingThreadOrder()).toBeNull(); + }); +}); diff --git a/apps/mobile/src/state/thread-order.ts b/apps/mobile/src/state/thread-order.ts new file mode 100644 index 000000000000..0fb57fc826e0 --- /dev/null +++ b/apps/mobile/src/state/thread-order.ts @@ -0,0 +1,89 @@ +import { useAtomValue } from "@effect/atom-react"; +import { useEffect } from "react"; +import { Atom } from "effect/unstable/reactivity"; + +import { + reconcilePendingThreadOrder, + type PendingThreadOrder, +} from "../features/threads/threadOrder"; +import { getThreadListV2OrderedSection } from "../features/threads/threadListV2"; +import { appAtomRegistry } from "./atom-registry"; +import { environmentServerConfigsAtom } from "./server"; +import { environmentThreadShells } from "./threads"; + +export const pendingThreadOrderAtom = Atom.make(null).pipe( + Atom.keepAlive, +); + +export function usePendingThreadOrder(nowMinute: string, snoozeWakeTick: number) { + const pending = useAtomValue(pendingThreadOrderAtom); + // A timed wake can change section membership without a shell event. Use the + // lists' existing clocks to retire that hold and re-enable their move menus. + useEffect(() => { + getPendingThreadOrder(); + }, [nowMinute, snoozeWakeTick]); + return pending; +} + +let refreshPendingOrder: (() => void) | undefined; + +/** Shared by Home and the navigation sidebar, including their action guards. */ +export function getPendingThreadOrder(): PendingThreadOrder | null { + refreshPendingOrder?.(); + return appAtomRegistry.get(pendingThreadOrderAtom); +} + +export function beginPendingThreadOrder(pending: PendingThreadOrder) { + const unsubscribers: (() => void)[] = []; + const cancel = () => { + if (refreshPendingOrder !== refresh) return; + refreshPendingOrder = undefined; + for (const unsubscribe of unsubscribers) unsubscribe(); + appAtomRegistry.set(pendingThreadOrderAtom, null); + }; + const refresh = () => { + if (refreshPendingOrder !== refresh) return; + const current = appAtomRegistry.get(pendingThreadOrderAtom); + if (current === null) return; + const configs = appAtomRegistry.get(environmentServerConfigsAtom); + const ordered = getThreadListV2OrderedSection({ + threads: appAtomRegistry.get(environmentThreadShells.threadShellsAtom), + section: current.section, + now: new Date().toISOString(), + settlementEnvironmentIds: new Set( + [...configs].flatMap(([id, config]) => + config.environment.capabilities.threadSettlement === true ? [id] : [], + ), + ), + snoozeEnvironmentIds: new Set( + [...configs].flatMap(([id, config]) => + config.environment.capabilities.threadSnooze === true ? [id] : [], + ), + ), + }); + const next = reconcilePendingThreadOrder(current, ordered); + if (next === null) cancel(); + else if (next !== current) appAtomRegistry.set(pendingThreadOrderAtom, next); + }; + refreshPendingOrder = refresh; + appAtomRegistry.set(pendingThreadOrderAtom, pending); + unsubscribers.push( + appAtomRegistry.subscribe(environmentThreadShells.threadShellsAtom, refresh), + appAtomRegistry.subscribe(environmentServerConfigsAtom, refresh), + ); + return { + isPending: () => { + refresh(); + return refreshPendingOrder === refresh; + }, + complete: () => { + if (refreshPendingOrder !== refresh) return; + const current = appAtomRegistry.get(pendingThreadOrderAtom); + if (current !== null) { + appAtomRegistry.set(pendingThreadOrderAtom, { ...current, commandsComplete: true }); + refresh(); + } + }, + cancel, + }; +} diff --git a/apps/mobile/src/state/use-thread-selection.ts b/apps/mobile/src/state/use-thread-selection.ts index b7350dd5dddf..7e012cb78903 100644 --- a/apps/mobile/src/state/use-thread-selection.ts +++ b/apps/mobile/src/state/use-thread-selection.ts @@ -62,6 +62,10 @@ function threadDetailToShell( archivedAt: thread.archivedAt, settledOverride: thread.settledOverride, settledAt: thread.settledAt, + unsettledAt: thread.unsettledAt, + activeOrderKey: thread.activeOrderKey, + pinnedAt: thread.pinnedAt, + pinOrderKey: thread.pinOrderKey, snoozedUntil: thread.snoozedUntil ?? null, snoozedAt: thread.snoozedAt ?? null, session: thread.session, From 4023d93bce3f610349fbf38e991f737c1c5e0c98 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 6 Sep 2026 11:10:14 -0700 Subject: [PATCH 05/71] feat(web): drag threads across sections with consistent motion (#9731) --- apps/web/src/components/Sidebar.drag.test.ts | 616 ++++++ apps/web/src/components/Sidebar.drag.ts | 175 ++ apps/web/src/components/Sidebar.logic.test.ts | 726 ++++++- apps/web/src/components/Sidebar.logic.ts | 276 ++- .../web/src/components/Sidebar.motion.test.ts | 339 +++ apps/web/src/components/Sidebar.motion.ts | 168 ++ apps/web/src/components/Sidebar.tsx | 1810 +++++++++++------ apps/web/src/hooks/useThreadActions.ts | 38 + apps/web/src/lib/threadSort.ts | 1 - apps/web/src/state/entities.ts | 7 + docs/user/thread-sidebar.md | 34 +- .../client-runtime/src/state/threadSort.ts | 2 +- 12 files changed, 3534 insertions(+), 658 deletions(-) create mode 100644 apps/web/src/components/Sidebar.drag.test.ts create mode 100644 apps/web/src/components/Sidebar.drag.ts create mode 100644 apps/web/src/components/Sidebar.motion.test.ts create mode 100644 apps/web/src/components/Sidebar.motion.ts diff --git a/apps/web/src/components/Sidebar.drag.test.ts b/apps/web/src/components/Sidebar.drag.test.ts new file mode 100644 index 000000000000..894fb7b4fee1 --- /dev/null +++ b/apps/web/src/components/Sidebar.drag.test.ts @@ -0,0 +1,616 @@ +import { describe, expect, it, vi } from "vite-plus/test"; +import { closestCenter, type CollisionDetection } from "@dnd-kit/core"; +import { verticalListSortingStrategy, type SortingStrategy } from "@dnd-kit/sortable"; +import { createSidebarCollisionDetection, createSidebarSortingStrategy } from "./Sidebar.drag"; +import { + sidebarListItemId, + sidebarMarkerId, + type SidebarListItem, + type SidebarListMarker, + type SidebarSection, +} from "./Sidebar.logic"; + +const thread = (key: string, section: SidebarSection): SidebarListItem => ({ + kind: "thread", + key, + section, +}); +const marker = (marker: SidebarListMarker): SidebarListItem => ({ kind: "marker", marker }); +const pinnedHeader = marker("pinned-header"); +const divider = marker("pinned-divider"); +const settledHeader = marker("settled-header"); +const stationary = { x: 0, y: 0, scaleX: 1, scaleY: 1 }; + +function layout( + items: readonly SidebarListItem[], + active: string, + over: string, + scale = 1, + cardHeight = 82, +) { + let top = 100; + const rects = items.map((item) => { + const height = + item.kind === "thread" + ? (item.section === "pinned" || item.section === "active" ? cardHeight : 36) * scale + : item.marker === "pinned-header" || item.marker === "pinned-divider" + ? 0 + : (item.marker.endsWith("placeholder") ? 36 : 32) * scale; + const rect = { top, height, bottom: top + height, left: 0, right: 260, width: 260 }; + top += height + 1; + return rect; + }); + const activeIndex = items.findIndex((item) => sidebarListItemId(item) === active); + return { + activeIndex, + overIndex: items.findIndex((item) => sidebarListItemId(item) === over), + activeNodeRect: rects[activeIndex]!, + rects, + index: 0, + } satisfies Parameters[0]; +} + +function preview( + input: Parameters[0], + active: string, + over: string, + scale = 1, +) { + const strategy = createSidebarSortingStrategy(input); + const args = layout(input.items, active, over, scale); + return new Map( + input.items.map((item, index) => [sidebarListItemId(item), strategy({ ...args, index })]), + ); +} + +describe("sidebar collision detection", () => { + function collisionArgs(blockedAboveSource = false) { + const rows = [thread("source", "active"), thread("blocked", "active")]; + const items = [ + pinnedHeader, + divider, + ...(blockedAboveSource ? rows.toReversed() : rows), + settledHeader, + marker("settled-placeholder"), + ]; + const { rects, activeIndex, overIndex } = layout(items, "source", "blocked"); + const collisionRect = rects[overIndex]!; + return { + active: { + id: "source", + data: { current: {} }, + rect: { current: { initial: rects[activeIndex]!, translated: collisionRect } }, + }, + collisionRect, + droppableRects: new Map(items.map((item, index) => [sidebarListItemId(item), rects[index]!])), + droppableContainers: items.map((item, index) => ({ + id: sidebarListItemId(item), + key: sidebarListItemId(item), + disabled: false, + data: { current: {} }, + node: { current: null }, + rect: { current: rects[index]! }, + })), + pointerCoordinates: null, + } satisfies Parameters[0]; + } + + it.each([ + [false, sidebarMarkerId("settled-header")], + [true, sidebarMarkerId("pinned-divider")], + ] as const)( + "rejects unsupported Active instead of selecting %s / %s", + (blockedAboveSource, nearbyTarget) => { + const args = collisionArgs(blockedAboveSource); + const detector = createSidebarCollisionDetection((id) => id !== "blocked"); + const filtered = closestCenter({ + ...args, + droppableContainers: args.droppableContainers.filter( + (container) => container.id !== "blocked", + ), + }); + expect(filtered[0]?.id).toBe(nearbyTarget); + expect(detector(args).map((collision) => collision.id)).toEqual(["source"]); + }, + ); + + it("selects the nearest supported target", () => { + const detector = createSidebarCollisionDetection(() => true); + expect(detector(collisionArgs())[0]?.id).toBe("blocked"); + }); + + function clampedArgs() { + const args = collisionArgs(); + const pinned = args.droppableRects.get(sidebarMarkerId("pinned-header"))!; + const source = args.droppableRects.get("source")!; + const collisionRect = { + ...source, + top: pinned.top - 8, + bottom: pinned.top - 8 + source.height, + }; + return { + ...args, + active: { + ...args.active, + rect: { current: { initial: source, translated: collisionRect } }, + }, + collisionRect, + pointerCoordinates: { x: pinned.left + pinned.width / 2, y: pinned.top + 8 }, + }; + } + + it("reaches empty Pins with an upward pointer while the card is clamped at the top", () => { + const args = clampedArgs(); + const detector = createSidebarCollisionDetection(() => true, { + emptyPins: true, + activationY: args.pointerCoordinates.y + 6, + }); + expect(args.droppableRects.get(sidebarMarkerId("pinned-header"))?.height).toBe(0); + expect(closestCenter(args)[0]?.id).toBe("source"); + expect(detector(args)[0]?.id).toBe(sidebarMarkerId("pinned-header")); + }); + + it.each([ + { reason: "below the boundary cue", x: 130, y: 109, activationY: 140, emptyPins: true }, + { reason: "left of the list", x: -1, y: 108, activationY: 140, emptyPins: true }, + { reason: "right of the list", x: 261, y: 108, activationY: 140, emptyPins: true }, + { reason: "less than 6px upward", x: 130, y: 108, activationY: 113, emptyPins: true }, + { reason: "without an activation point", x: 130, y: 108, activationY: null, emptyPins: true }, + { reason: "with populated Pins", x: 130, y: 108, activationY: 140, emptyPins: false }, + ])("keeps ordinary collision behavior $reason", ({ x, y, activationY, emptyPins }) => { + const detector = createSidebarCollisionDetection(() => true, { emptyPins, activationY }); + const args = { ...clampedArgs(), pointerCoordinates: { x, y } }; + expect(detector(args)[0]?.id).toBe("source"); + }); + + it("keeps ordinary collision behavior without pointer coordinates", () => { + const detector = createSidebarCollisionDetection(() => true, { + emptyPins: true, + activationY: 140, + }); + expect(detector({ ...clampedArgs(), pointerCoordinates: null })[0]?.id).toBe("source"); + }); + + it("validates the empty Pins override and caches an unsupported result", () => { + const isValid = vi.fn(() => false); + const detector = createSidebarCollisionDetection(isValid, { + emptyPins: true, + activationY: 140, + }); + const args = clampedArgs(); + expect(detector(args).map((collision) => collision.id)).toEqual(["source"]); + expect(detector(args).map((collision) => collision.id)).toEqual(["source"]); + expect(isValid.mock.calls).toEqual([[sidebarMarkerId("pinned-header")]]); + }); + + it("returns no collision if an unsupported target has no source fallback", () => { + const args = collisionArgs(); + const detector = createSidebarCollisionDetection(() => false); + expect( + detector({ + ...args, + droppableContainers: args.droppableContainers.filter( + (container) => container.id !== "source", + ), + }), + ).toEqual([]); + }); + + it("validates each hovered target once and always allows returning to the source", () => { + const args = collisionArgs(); + const isValid = vi.fn((id: string) => id !== "blocked"); + const detector = createSidebarCollisionDetection(isValid); + expect(detector(args)[0]?.id).toBe("source"); + expect( + detector({ + ...args, + collisionRect: { + ...args.collisionRect, + top: args.collisionRect.top + 3, + bottom: args.collisionRect.bottom + 3, + }, + })[0]?.id, + ).toBe("source"); + expect(detector({ ...args, collisionRect: args.droppableRects.get("source")! })[0]?.id).toBe( + "source", + ); + expect( + detector({ + ...args, + collisionRect: args.droppableRects.get(sidebarMarkerId("settled-placeholder"))!, + })[0]?.id, + ).toBe(sidebarMarkerId("settled-placeholder")); + expect(isValid.mock.calls).toEqual([["blocked"], [sidebarMarkerId("settled-placeholder")]]); + }); +}); + +describe("sidebar drag projection", () => { + const pinned = [ + pinnedHeader, + thread("p1", "pinned"), + thread("p2", "pinned"), + divider, + thread("a1", "active"), + settledHeader, + thread("s1", "settled"), + ]; + + it.each([ + ["p1", "p2"], + ["p2", "p1"], + ])("preserves existing pinned transforms from %s to %s", (active, over) => { + const strategy = createSidebarSortingStrategy({ + items: pinned, + settledOrder: [], + settledExpanded: true, + }); + const args = layout(pinned, active, over); + for (let index = 0; index < pinned.length; index += 1) { + expect(strategy({ ...args, index })).toEqual(verticalListSortingStrategy({ ...args, index })); + } + }); + + it("keeps the pinned header above the gap when a lower pin moves to the top", () => { + const result = preview( + { items: pinned, settledOrder: [], settledExpanded: true }, + "p2", + sidebarMarkerId("pinned-header"), + ); + expect(result.get(sidebarMarkerId("pinned-header"))).toEqual(stationary); + expect(result.get("p1")).toEqual({ ...stationary, y: 83 }); + expect(result.get(sidebarMarkerId("pinned-divider"))).toEqual(stationary); + expect(result.get("a1")).toEqual(stationary); + }); + + it.each([ + ["a1", "a2"], + ["a2", "a1"], + ])("uses pinned dragging behavior for Active from %s to %s", (active, over) => { + const items = [ + pinnedHeader, + thread("p", "pinned"), + divider, + thread("a1", "active"), + thread("a2", "active"), + settledHeader, + thread("s", "settled"), + ]; + const strategy = createSidebarSortingStrategy({ + items, + settledOrder: [], + settledExpanded: true, + }); + const args = layout(items, active, over); + for (let index = 0; index < items.length; index += 1) { + expect(strategy({ ...args, index })).toEqual(verticalListSortingStrategy({ ...args, index })); + } + }); + + it("leaves canonically sorted settled peers in place", () => { + const items = [ + pinnedHeader, + divider, + marker("active-placeholder"), + settledHeader, + thread("first", "settled"), + thread("second", "settled"), + ]; + const result = preview( + { items, settledOrder: ["first", "second"], settledExpanded: true }, + "second", + "first", + ); + expect([...result.values()]).toEqual(items.map(() => stationary)); + }); + + it.each([ + [sidebarMarkerId("pinned-divider"), 0, 0], + ["a1", -83, 0], + ["a2", -83, -83], + ] as const)( + "opens the active pointer slot over %s without adding an empty pinned row", + (over, a1Offset, a2Offset) => { + const items = [ + pinnedHeader, + thread("p", "pinned"), + divider, + thread("a1", "active"), + thread("a2", "active"), + settledHeader, + thread("s", "settled"), + ]; + const result = preview({ items, settledOrder: [], settledExpanded: true }, "p", over); + expect(result.get(sidebarMarkerId("pinned-header"))).toEqual(stationary); + expect(result.get(sidebarMarkerId("pinned-divider"))?.y).toBe(-83); + expect(result.get("a1")?.y).toBe(a1Offset); + expect(result.get("a2")?.y).toBe(a2Offset); + expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(0); + }, + ); + + it("keeps the pinned header above the first arriving pin", () => { + const items = [ + pinnedHeader, + divider, + thread("a1", "active"), + thread("a2", "active"), + settledHeader, + thread("s", "settled"), + ]; + const result = preview( + { items, settledOrder: [], settledExpanded: true }, + "a2", + sidebarMarkerId("pinned-header"), + ); + expect(result.get(sidebarMarkerId("pinned-header"))).toEqual(stationary); + expect(result.get(sidebarMarkerId("pinned-divider"))?.y).toBe(83); + expect(result.get("a1")?.y).toBe(83); + expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(0); + }); + + it.each([ + ["p", -83, -37], + ["s", 0, 46], + ] as const)( + "replaces the empty Active target when %s enters", + (active, dividerOffset, settledOffset) => { + const items = [ + pinnedHeader, + thread("p", "pinned"), + divider, + marker("active-placeholder"), + settledHeader, + thread("s", "settled"), + ]; + const result = preview( + { items, settledOrder: [], settledExpanded: true }, + active, + sidebarMarkerId("active-placeholder"), + ); + expect(result.get(sidebarMarkerId("active-placeholder"))?.scaleY).toBe(0); + expect(result.get(sidebarMarkerId("pinned-divider"))?.y).toBe(dividerOffset); + expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(settledOffset); + }, + ); + + it("uses the canonical settled rank and the destination's slim height", () => { + const items = [ + pinnedHeader, + thread("p", "pinned"), + divider, + thread("a", "active"), + settledHeader, + thread("s1", "settled"), + thread("s2", "settled"), + ]; + const result = preview( + { items, settledOrder: ["s1", "a", "s2"], settledExpanded: true }, + "a", + "s2", + ); + expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(-46); + expect(result.get("s1")?.y).toBe(-46); + expect(result.get("s2")?.y).toBe(-9); + }); + + it.each([ + ["a1", 83], + ["a2", 0], + ] as const)( + "reserves a full card at the pointer slot over %s when a slim row enters Active", + (over, firstOffset) => { + const items = [ + pinnedHeader, + thread("p", "pinned"), + divider, + thread("a1", "active"), + thread("a2", "active"), + settledHeader, + thread("s", "settled"), + ]; + const result = preview({ items, settledOrder: [], settledExpanded: true }, "s", over); + expect(result.get("a1")?.y).toBe(firstOffset); + expect(result.get("a2")?.y).toBe(83); + expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(83); + }, + ); + + it("removes the snoozed header when its last row leaves", () => { + const items = [ + pinnedHeader, + thread("p", "pinned"), + divider, + thread("a", "active"), + marker("snoozed-header"), + thread("z", "snoozed"), + settledHeader, + thread("s", "settled"), + ]; + const result = preview({ items, settledOrder: [], settledExpanded: true }, "z", "a"); + expect(result.get(sidebarMarkerId("snoozed-header"))?.scaleY).toBe(0); + expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(13); + expect(result.get("s")?.y).toBe(13); + }); + + it("keeps a collapsed settled target without inserting a hidden row", () => { + const items = [ + pinnedHeader, + thread("p", "pinned"), + divider, + thread("a1", "active"), + thread("a2", "active"), + settledHeader, + marker("settled-placeholder"), + ]; + const result = preview( + { items, settledOrder: [], settledExpanded: false }, + "a2", + sidebarMarkerId("settled-placeholder"), + ); + expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(-83); + expect(result.get(sidebarMarkerId("settled-placeholder"))).toEqual({ ...stationary, y: -83 }); + }); + + it("preserves a collapsed snoozed header while another section changes", () => { + const items = [ + pinnedHeader, + thread("p", "pinned"), + divider, + thread("a", "active"), + marker("snoozed-header"), + settledHeader, + marker("settled-placeholder"), + ]; + const result = preview( + { items, settledOrder: [], settledExpanded: false }, + "a", + sidebarMarkerId("settled-placeholder"), + ); + expect(result.get(sidebarMarkerId("snoozed-header"))).toEqual({ ...stationary, y: -46 }); + }); + + it("derives missing card geometry from the measured root scale", () => { + const items = [ + pinnedHeader, + divider, + marker("active-placeholder"), + settledHeader, + thread("s", "settled"), + ]; + const result = preview( + { items, settledOrder: [], settledExpanded: true }, + "s", + sidebarMarkerId("pinned-header"), + 0.75, + ); + expect(result.get(sidebarMarkerId("pinned-header"))).toEqual(stationary); + expect(result.get(sidebarMarkerId("pinned-divider"))?.y).toBe(62.5); + expect(result.get(sidebarMarkerId("active-placeholder"))?.y).toBe(62.5); + }); + + it("updates the projection when the target or measured geometry changes", () => { + const strategy = createSidebarSortingStrategy({ + items: pinned, + settledOrder: [], + settledExpanded: true, + }); + const args = layout(pinned, "p1", "p1"); + expect(strategy({ ...args, index: 2 })?.y).toBe(0); + expect(strategy({ ...args, index: 2, overIndex: 4 })?.y).toBe(-83); + const smaller = layout(pinned, "p1", "a1", 0.75); + expect(strategy({ ...smaller, index: 2 })?.y).toBe(-62.5); + }); + + it("uses measured placeholder sizing when card height differs from its default", () => { + const items = [ + pinnedHeader, + thread("p", "pinned"), + divider, + thread("a", "active"), + settledHeader, + marker("settled-placeholder"), + ]; + const strategy = createSidebarSortingStrategy({ + items, + settledOrder: [], + settledExpanded: false, + }); + const args = layout(items, "a", sidebarMarkerId("settled-placeholder"), 1, 78); + expect(strategy({ ...args, index: 4 })?.y).toBe(-42); + }); + + it("keeps the route row visible after a settled drop pushes it beyond the page", () => { + const items = [ + pinnedHeader, + thread("p", "pinned"), + divider, + thread("a", "active"), + settledHeader, + thread("s", "settled"), + ]; + const input = { + items, + settledOrder: ["a", "s", "hidden"], + settledExpanded: true, + settledVisibleCount: 1, + }; + const withRoute = preview({ ...input, routeThreadKey: "s" }, "a", "s"); + const withoutRoute = preview(input, "a", "s"); + expect(withRoute.get("s")).toEqual({ ...stationary, y: -9 }); + expect(withoutRoute.get("s")?.scaleY).toBe(0); + }); + + it("reserves the next page row when a visible settled thread leaves", () => { + const items = [ + pinnedHeader, + thread("p", "pinned"), + divider, + thread("a", "active"), + settledHeader, + thread("s1", "settled"), + thread("route", "settled"), + ]; + const result = preview( + { + items, + settledOrder: ["s1", "hidden", "route"], + settledExpanded: true, + settledVisibleCount: 1, + routeThreadKey: "route", + }, + "s1", + "a", + ); + expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(83); + expect(result.get("route")?.y).toBe(83); + }); + + it("keeps the dropped route thread visible in a collapsed settled shelf", () => { + const items = [ + pinnedHeader, + thread("p", "pinned"), + divider, + thread("a", "active"), + settledHeader, + marker("settled-placeholder"), + ]; + const result = preview( + { + items, + settledOrder: ["a", "hidden"], + settledExpanded: false, + settledVisibleCount: 1, + routeThreadKey: "a", + }, + "a", + sidebarMarkerId("settled-placeholder"), + ); + expect(result.get(sidebarMarkerId("settled-placeholder"))?.scaleY).toBe(0); + expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(-46); + }); + + it("preserves hidden snoozed membership when the only rendered route row leaves", () => { + const items = [ + pinnedHeader, + thread("p", "pinned"), + divider, + thread("a", "active"), + marker("snoozed-header"), + thread("z", "snoozed"), + settledHeader, + thread("s", "settled"), + ]; + const result = preview( + { + items, + settledOrder: ["s"], + settledExpanded: true, + snoozedThreadCount: 2, + }, + "z", + "a", + ); + expect(result.get(sidebarMarkerId("snoozed-header"))).toEqual({ ...stationary, y: 83 }); + expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(46); + }); +}); diff --git a/apps/web/src/components/Sidebar.drag.ts b/apps/web/src/components/Sidebar.drag.ts new file mode 100644 index 000000000000..9c27a31c2224 --- /dev/null +++ b/apps/web/src/components/Sidebar.drag.ts @@ -0,0 +1,175 @@ +import { closestCenter, type CollisionDetection } from "@dnd-kit/core"; +import { verticalListSortingStrategy, type SortingStrategy } from "@dnd-kit/sortable"; +import { + resolveSidebarDropTarget, + sidebarListItemId, + sidebarMarkerId, + type SidebarListItem, + type SidebarListMarker, + type SidebarSection, +} from "./Sidebar.logic"; + +const stationary = { x: 0, y: 0, scaleX: 1, scaleY: 1 }; +const hidden = { ...stationary, scaleY: 0 }; +type ThreadItem = Extract; +type Layout = Parameters[0]; + +/** Reject the nearest unsupported target without selecting another section. + * Recreate this detector when drop eligibility changes. */ +export function createSidebarCollisionDetection( + isValidTarget: (id: string) => boolean, + options: { emptyPins?: boolean; activationY?: number | null } = {}, +): CollisionDetection { + const validity = new Map(); + const pinnedHeaderId = sidebarMarkerId("pinned-header"); + return (args) => { + let collisions = closestCenter(args); + const pinnedRect = options.emptyPins ? args.droppableRects.get(pinnedHeaderId) : undefined; + const pointer = args.pointerCoordinates; + // The card itself is clamped by the scroll container. An upward pointer + // gesture can still reach the empty pinned boundary without reserving a row. + if ( + pinnedRect && + pointer && + options.activationY != null && + pointer.y <= options.activationY - 6 && + pointer.y <= pinnedRect.top + 8 && + pointer.x >= pinnedRect.left && + pointer.x <= pinnedRect.right + ) { + const pinned = collisions.find((collision) => collision.id === pinnedHeaderId); + if (pinned) { + collisions = [pinned, ...collisions.filter((collision) => collision !== pinned)]; + } + } + const nearest = collisions[0]; + if (!nearest || nearest.id === args.active.id) return collisions; + const id = String(nearest.id); + const valid = validity.get(id) ?? isValidTarget(id); + validity.set(id, valid); + return valid ? collisions : collisions.filter((collision) => collision.id === args.active.id); + }; +} + +/** Preview the committed section layout without moving or mounting DOM nodes. + * A zero scaleY marks rows/markers to hide while retaining their measured nodes. */ +export function createSidebarSortingStrategy(input: { + items: readonly SidebarListItem[]; + settledOrder: readonly string[]; + settledExpanded: boolean; + settledVisibleCount?: number; + routeThreadKey?: string | null; + snoozedThreadCount?: number; + cardHeight?: number; + slimHeight?: number; +}): SortingStrategy { + const { items } = input; + const indices = new Map(items.map((item, index) => [sidebarListItemId(item), index])); + let previous: Pick | undefined; + let transforms: ReturnType[] | null = []; + + function project({ rects, activeIndex, overIndex }: Layout) { + const active = items[activeIndex]; + const over = items[overIndex]; + if (active?.kind !== "thread" || !over || !rects[0]) return []; + const target = resolveSidebarDropTarget(items, active.key, sidebarListItemId(over)); + if (!target) return []; + if (target.section === active.section && over.kind === "thread") + return target.section === "settled" ? [] : null; + const groups: Record = { + pinned: [], + active: [], + snoozed: [], + settled: [], + }; + let cardHeight = input.cardHeight; + let slimHeight = input.slimHeight; + for (const [index, item] of items.entries()) { + if (item.kind === "marker") { + if (item.marker.endsWith("placeholder")) slimHeight ??= rects[index]?.height; + continue; + } + if (item.section === "pinned" || item.section === "active") + cardHeight ??= rects[index]?.height; + else slimHeight ??= rects[index]?.height; + if (item.key !== active.key) groups[item.section].push(item); + } + // Cards are 4.875rem + 0.25rem padding; slim rows/placeholders are h-9. + const scale = slimHeight !== undefined ? slimHeight / 36 : (cardHeight ?? 82) / 82; + cardHeight ??= 82 * scale; + slimHeight ??= 36 * scale; + const group = groups[target.section]; + const order = + target.section === "pinned" + ? target.pinnedOrder + : target.section === "settled" + ? input.settledOrder + : target.activeOrder; + const ranks = new Map(order.map((key, index) => [key, index])); + const rank = ranks.get(active.key) ?? Number.POSITIVE_INFINITY; + const index = group.findIndex( + (item) => (ranks.get(item.key) ?? Number.POSITIVE_INFINITY) > rank, + ); + group.splice(index < 0 ? group.length : index, 0, { ...active, section: target.section }); + const settledOrder = ( + input.settledOrder.length > 0 ? input.settledOrder : groups.settled.map((item) => item.key) + ).filter((key) => key !== active.key || target.section === "settled"); + const visible = input.settledExpanded + ? settledOrder.slice(0, input.settledVisibleCount ?? settledOrder.length) + : []; + const routeKey = input.routeThreadKey; + if (routeKey && settledOrder.includes(routeKey) && !visible.includes(routeKey)) { + visible.push(routeKey); + } + groups.settled = visible.map((key) => ({ kind: "thread", key, section: "settled" })); + const projected: SidebarListItem[] = []; + const marker = (name: SidebarListMarker) => projected.push({ kind: "marker", marker: name }); + const section = (name: "active" | "settled") => { + if (groups[name].length > 0) projected.push(...groups[name]); + else marker(`${name}-placeholder`); + }; + marker("pinned-header"); + projected.push(...groups.pinned); + marker("pinned-divider"); + section("active"); + if ( + groups.snoozed.length > 0 || + ((active.section !== "snoozed" || (input.snoozedThreadCount ?? 0) > 1) && + items.some((item) => item.kind === "marker" && item.marker === "snoozed-header")) + ) { + marker("snoozed-header"); + projected.push(...groups.snoozed); + } + marker("settled-header"); + section("settled"); + const result = items.map(() => hidden); + let top = rects[0].top; + for (const item of projected) { + const index = indices.get(sidebarListItemId(item)); + const rect = index === undefined ? undefined : rects[index]; + if (index !== undefined && rect) result[index] = { ...stationary, y: top - rect.top }; + const fallback = + item.kind === "thread" && (item.section === "pinned" || item.section === "active") + ? cardHeight + : slimHeight; + const moved = item.kind === "thread" && item.key === active.key; + top += (moved ? fallback : (rect?.height ?? fallback)) + 1; + } + result[activeIndex] = stationary; + return result; + } + + return (args) => { + if ( + previous?.rects !== args.rects || + previous.activeIndex !== args.activeIndex || + previous.overIndex !== args.overIndex + ) { + previous = args; + transforms = project(args); + } + return transforms === null + ? verticalListSortingStrategy(args) + : (transforms[args.index] ?? stationary); + }; +} diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index fee7ef181a59..edf51aa2d8f5 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -3,7 +3,8 @@ import { defaultAnimateLayoutChanges, type AnimateLayoutChanges } from "@dnd-kit import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; import { - animatePinnedLayoutChanges, + animateSidebarLayoutChanges, + applySidebarThreadDrop, archiveSelectedThreadEntries, buildBulkTitleRegenerationContextMenuItem, buildBulkUnpinContextMenuItem, @@ -32,14 +33,21 @@ import { shouldRecedeSidebarThread, sortLogicalProjectsForSidebar, sortSettledThreadsForSidebar, + resolveSidebarDropTarget, pinOrderKeyBetween, planPinnedReorder, + planSidebarThreadDrop, + sidebarMarkerId, + sidebarListItemId, sortPinnedThreadsForSidebar, sortThreadsForSidebar, sortProjectsForSidebar, sortScopedProjectsForSidebar, shouldCreateNewThreadInCurrentProject, THREAD_JUMP_HINT_SHOW_DELAY_MS, + type SidebarListItem, + type SidebarListMarker, + type SidebarSection, } from "./Sidebar.logic"; import { EnvironmentId, @@ -53,12 +61,13 @@ import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Project, + type SidebarThreadSummary, type Thread, } from "../types"; const localEnvironmentId = EnvironmentId.make("environment-local"); -describe("animatePinnedLayoutChanges", () => { +describe("animateSidebarLayoutChanges", () => { const baseArgs: Parameters[0] = { active: null, containerId: "pinned-threads", @@ -76,11 +85,11 @@ describe("animatePinnedLayoutChanges", () => { it("does not replay layout movement after the pointer is released", () => { expect(defaultAnimateLayoutChanges(baseArgs)).toBe(true); - expect(animatePinnedLayoutChanges(baseArgs)).toBe(false); + expect(animateSidebarLayoutChanges(baseArgs)).toBe(false); }); it("keeps layout movement while the user is sorting", () => { - expect(animatePinnedLayoutChanges({ ...baseArgs, isSorting: true })).toBe(true); + expect(animateSidebarLayoutChanges({ ...baseArgs, isSorting: true })).toBe(true); }); }); @@ -1025,6 +1034,715 @@ describe("planPinnedReorder", () => { }); }); +describe("resolveSidebarDropTarget", () => { + const thread = (key: string, section: SidebarSection): SidebarListItem => ({ + kind: "thread", + key, + section, + }); + const marker = (marker: SidebarListMarker): SidebarListItem => ({ kind: "marker", marker }); + // Pinned p1 p2 | Active a1 a2 | Snoozed z1 | Settled s1 + const items: readonly SidebarListItem[] = [ + marker("pinned-header"), + thread("p1", "pinned"), + thread("p2", "pinned"), + marker("pinned-divider"), + thread("a1", "active"), + thread("a2", "active"), + marker("snoozed-header"), + thread("z1", "snoozed"), + marker("settled-header"), + thread("s1", "settled"), + ]; + const resolve = (activeKey: string, overId: string) => + resolveSidebarDropTarget(items, activeKey, overId); + + it("keeps marker-like scoped thread keys draggable", () => { + const key = "marker:pinned-header"; + const list: SidebarListItem[] = [ + marker("pinned-header"), + thread(key, "pinned"), + thread("env:other", "pinned"), + marker("pinned-divider"), + ]; + expect(new Set(list.map(sidebarListItemId)).size).toBe(list.length); + expect(resolveSidebarDropTarget(list, key, "env:other")).toEqual({ + section: "pinned", + pinnedOrder: ["env:other", key], + activeOrder: [], + }); + }); + + it("reads the section off the markers above the gap", () => { + expect(resolve("p1", "a2")).toEqual({ + section: "active", + pinnedOrder: ["p2"], + activeOrder: ["a1", "a2", "p1"], + }); + expect(resolve("a1", "s1")).toEqual({ + section: "settled", + pinnedOrder: ["p1", "p2"], + activeOrder: ["a2"], + }); + expect(resolve("s1", "a1")).toEqual({ + section: "active", + pinnedOrder: ["p1", "p2"], + activeOrder: ["s1", "a1", "a2"], + }); + }); + + it("uses arrayMove placement, so a marker hovered from below lands above it", () => { + // Dragging a1 up onto the divider: the divider shifts down, a1 becomes + // the last pinned row. + expect(resolve("a1", sidebarMarkerId("pinned-divider"))).toEqual({ + section: "pinned", + pinnedOrder: ["p1", "p2", "a1"], + activeOrder: ["a2"], + }); + // Dragging p2 down onto the divider: the divider shifts up, p2 is the + // first inbox row — an unpin. + expect(resolve("p2", sidebarMarkerId("pinned-divider"))).toEqual({ + section: "active", + pinnedOrder: ["p1"], + activeOrder: ["p2", "a1", "a2"], + }); + // Same on the Settled header: from above it settles; from below the + // gap lands in whatever is above the header — here the snoozed shelf, + // which is never a target. + expect(resolve("a2", sidebarMarkerId("settled-header"))?.section).toBe("settled"); + expect(resolve("s1", sidebarMarkerId("settled-header"))).toBeNull(); + }); + + it("reorders inside the pinned block with the dragged row at the over slot", () => { + expect(resolve("p1", "p2")).toEqual({ + section: "pinned", + pinnedOrder: ["p2", "p1"], + activeOrder: ["a1", "a2"], + }); + expect(resolve("a2", "p1")).toEqual({ + section: "pinned", + pinnedOrder: ["a2", "p1", "p2"], + activeOrder: ["a1"], + }); + }); + + it("lands first in Pinned when hovering its permanent header", () => { + expect(resolve("a2", sidebarMarkerId("pinned-header"))).toEqual({ + section: "pinned", + pinnedOrder: ["a2", "p1", "p2"], + activeOrder: ["a1"], + }); + }); + + it("reorders active rows in either direction without changing sections", () => { + for (const [from, to] of [ + ["a1", "a2"], + ["a2", "a1"], + ] as const) { + expect(resolve(from, to)).toEqual({ + section: "active", + pinnedOrder: ["p1", "p2"], + activeOrder: ["a2", "a1"], + }); + } + }); + + it("never lands in the snoozed shelf", () => { + expect(resolve("a1", "z1")).toBeNull(); + expect(resolve("a1", sidebarMarkerId("snoozed-header"))).toBeNull(); + }); + + it("lands on a placeholder when the section is otherwise empty", () => { + const withPlaceholder: readonly SidebarListItem[] = [ + marker("pinned-header"), + marker("pinned-divider"), + thread("a1", "active"), + marker("settled-header"), + marker("settled-placeholder"), + ]; + expect( + resolveSidebarDropTarget(withPlaceholder, "a1", sidebarMarkerId("settled-placeholder")), + ).toEqual({ section: "settled", pinnedOrder: [], activeOrder: [] }); + }); + + it("lands in empty Pinned using its header without an extra placeholder", () => { + const emptyPinned: readonly SidebarListItem[] = [ + marker("pinned-header"), + marker("pinned-divider"), + thread("a1", "active"), + ]; + expect(resolveSidebarDropTarget(emptyPinned, "a1", sidebarMarkerId("pinned-header"))).toEqual({ + section: "pinned", + pinnedOrder: ["a1"], + activeOrder: [], + }); + expect(resolveSidebarDropTarget(emptyPinned, "a1", sidebarMarkerId("pinned-divider"))).toEqual({ + section: "pinned", + pinnedOrder: ["a1"], + activeOrder: [], + }); + }); + + it("rejects ids that are not in the list", () => { + expect(resolve("a1", "nope")).toBeNull(); + expect(resolve("nope", "a1")).toBeNull(); + expect(resolve(sidebarMarkerId("pinned-divider"), "a1")).toBeNull(); + }); +}); + +describe("planSidebarThreadDrop", () => { + const pinnedKeysById = new Map([ + ["p1", "f"], + ["p2", "m"], + ["p3", "t"], + ]); + const activeKeysById = new Map([ + ["a1", "f"], + ["a2", "m"], + ["a3", "t"], + ]); + const plan = ( + overrides: Partial[0], "target">> & { + activeKey: string; + activeSection: "pinned" | "active" | "snoozed" | "settled"; + target: Omit[0]["target"], "activeOrder"> & { + activeOrder?: readonly string[]; + }; + }, + ) => + planSidebarThreadDrop({ + pinnedOrder: ["p1", "p2", "p3"], + pinnedKeysById, + activeOrder: ["a1", "a2", "a3"], + activeKeysById, + ...overrides, + target: { activeOrder: [], ...overrides.target }, + }); + + it("allows old-server pinned reordering while rejecting settlement", () => { + expect( + plan({ + activeKey: "p1", + activeSection: "pinned", + supportsSettlement: false, + target: { section: "pinned", pinnedOrder: ["p2", "p1", "p3"] }, + }).kind, + ).toBe("reorder-pinned"); + expect( + plan({ + activeKey: "p1", + activeSection: "pinned", + supportsSettlement: false, + target: { section: "settled", pinnedOrder: ["p2", "p3"] }, + }), + ).toEqual({ kind: "none" }); + }); + + it.each(["pinned", "active"] as const)("reserves hidden %s slots during a drop", (section) => { + const order = section === "pinned" ? ["p2", "p1", "p3"] : ["a2", "a1", "a3"]; + const keys = new Map(section === "pinned" ? pinnedKeysById : activeKeysById); + const moved = section === "pinned" ? "p1" : "a1"; + const reserved = pinOrderKeyBetween(keys.get(order[0]!)!, keys.get(order[2]!)!)!; + keys.set("snoozed", reserved); + const result = plan({ + activeKey: moved, + activeSection: section, + pinnedKeysById: section === "pinned" ? keys : pinnedKeysById, + activeKeysById: section === "active" ? keys : activeKeysById, + target: { + section, + pinnedOrder: section === "pinned" ? order : [], + activeOrder: section === "active" ? order : [], + }, + }); + if (result.kind !== "reorder-pinned" && result.kind !== "move-active") + throw new Error("Expected reorder"); + expect(result.assignments).toHaveLength(1); + expect(result.assignments[0]!.orderKey).not.toBe(reserved); + }); + + it.each([ + { key: "p2", section: "pinned" as const, unpin: true, unsettle: false, unsnooze: false }, + { key: "s1", section: "settled" as const, unpin: false, unsettle: true, unsnooze: false }, + { key: "z1", section: "snoozed" as const, unpin: false, unsettle: false, unsnooze: true }, + ])("moves a $section thread to the chosen Active slot", (source) => { + const order = ["a1", source.key, "a2", "a3"]; + const result = plan({ + activeKey: source.key, + activeSection: source.section, + target: { section: "active", pinnedOrder: [], activeOrder: order }, + }); + expect(result).toEqual({ + kind: "move-active", + order, + assignments: [{ id: source.key, orderKey: expect.any(String) }], + unpin: source.unpin, + unsettle: source.unsettle, + unsnooze: source.unsnooze, + }); + if (result.kind !== "move-active") return; + const key = result.assignments[0]!.orderKey; + expect(key > "f" && key < "m").toBe(true); + }); + + it.each([ + { state: "pinned", activePinned: true, activeSettled: false }, + { state: "settled", activePinned: false, activeSettled: true }, + { state: "pinned and settled", activePinned: true, activeSettled: true }, + ])("clears a snoozed thread's $state state before waking it into Active", (hiddenState) => { + expect( + plan({ + activeKey: "z1", + activeSection: "snoozed", + activePinned: hiddenState.activePinned, + activeSettled: hiddenState.activeSettled, + target: { + section: "active", + pinnedOrder: ["p1", "p2", "p3"], + activeOrder: ["a1", "z1", "a2", "a3"], + }, + }), + ).toEqual({ + kind: "move-active", + order: ["a1", "z1", "a2", "a3"], + assignments: [{ id: "z1", orderKey: expect.any(String) }], + unpin: hiddenState.activePinned, + unsettle: hiddenState.activeSettled, + unsnooze: true, + }); + }); + + it("saves the first Active reorder, then moves only one key on subsequent drops", () => { + const rows = ["a1", "a2", "a3"].map((id, index) => ({ + id, + createdAt: new Date(Date.UTC(2026, 8, 4, 12 - index)).toISOString(), + activeOrderKey: null as string | null, + })); + const firstOrder = ["a2", "a3", "a1"]; + const first = plan({ + activeKey: "a1", + activeSection: "active", + target: { section: "active", pinnedOrder: [], activeOrder: firstOrder }, + activeKeysById: new Map(rows.map((row) => [row.id, row.activeOrderKey])), + }); + expect(first.kind).toBe("move-active"); + if (first.kind !== "move-active") return; + expect(first.unpin || first.unsettle || first.unsnooze).toBe(false); + const savedKeys = new Map(first.assignments.map(({ id, orderKey }) => [id, orderKey])); + const savedRows = rows.map((row) => ({ + ...row, + activeOrderKey: savedKeys.get(row.id) ?? null, + })); + expect(sortThreadsForSidebar(savedRows).map((row) => row.id)).toEqual(firstOrder); + + const secondOrder = ["a2", "a1", "a3"]; + const second = plan({ + activeKey: "a1", + activeSection: "active", + activeOrder: firstOrder, + activeKeysById: savedKeys, + target: { section: "active", pinnedOrder: [], activeOrder: secondOrder }, + }); + expect(second.kind).toBe("move-active"); + if (second.kind !== "move-active") return; + expect(second.assignments).toEqual([{ id: "a1", orderKey: expect.any(String) }]); + const finalRows = savedRows.map((row) => + row.id === "a1" ? { ...row, activeOrderKey: second.assignments[0]!.orderKey } : row, + ); + expect(sortThreadsForSidebar(finalRows).map((row) => row.id)).toEqual(secondOrder); + }); + + it("does not write when an Active thread is dropped in its existing slot", () => { + expect( + plan({ + activeKey: "a2", + activeSection: "active", + target: { section: "active", pinnedOrder: [], activeOrder: ["a1", "a2", "a3"] }, + }), + ).toEqual({ kind: "none" }); + }); + + it("requires Active ordering support only for the threads whose keys must change", () => { + const input = { + activeKey: "a3", + activeSection: "active" as const, + target: { section: "active" as const, pinnedOrder: [], activeOrder: ["a1", "a3", "a2"] }, + activeReorderableKeys: new Set(["a3"]), + }; + expect(plan(input).kind).toBe("move-active"); + expect( + plan({ + ...input, + activeKeysById: new Map([ + ["a1", null], + ["a2", "m"], + ["a3", "t"], + ]), + }), + ).toEqual({ kind: "none" }); + expect(plan({ ...input, activeReorderableKeys: new Set() })).toEqual({ kind: "none" }); + }); + + it("settles anything dropped on Settled except a settled thread", () => { + const target = { section: "settled", pinnedOrder: ["p1", "p2", "p3"] } as const; + expect(plan({ activeKey: "a1", activeSection: "active", target })).toEqual({ kind: "settle" }); + expect(plan({ activeKey: "p1", activeSection: "pinned", target })).toEqual({ kind: "settle" }); + expect(plan({ activeKey: "z1", activeSection: "snoozed", target })).toEqual({ kind: "settle" }); + expect(plan({ activeKey: "s1", activeSection: "settled", target })).toEqual({ kind: "none" }); + }); + + it("pins a foreign thread with a key between its new neighbors", () => { + const result = plan({ + activeKey: "a1", + activeSection: "active", + target: { section: "pinned", pinnedOrder: ["p1", "a1", "p2", "p3"] }, + }); + expect(result.kind).toBe("pin"); + if (result.kind !== "pin") return; + expect(result.order).toEqual(["p1", "a1", "p2", "p3"]); + expect(result.orderKey).toBeDefined(); + expect(result.orderKey! > "f" && result.orderKey! < "m").toBe(true); + expect(result.extraAssignments).toEqual([]); + + const empty = plan({ + activeKey: "a1", + activeSection: "active", + target: { section: "pinned", pinnedOrder: ["a1"] }, + pinnedOrder: [], + pinnedKeysById: new Map(), + }); + expect(empty.kind).toBe("pin"); + if (empty.kind !== "pin") return; + expect(empty.orderKey).toBeDefined(); + }); + + it("reorders an already-pinned snoozed thread after pinning wakes it", () => { + const result = plan({ + activeKey: "z1", + activeSection: "snoozed", + activePinned: true, + target: { section: "pinned", pinnedOrder: ["p1", "z1", "p2", "p3"] }, + pinnedKeysById: new Map([...pinnedKeysById, ["z1", "x"]]), + }); + expect(result.kind).toBe("pin"); + if (result.kind !== "pin") return; + expect(result.extraAssignments).toEqual([{ id: "z1", orderKey: result.orderKey }]); + expect(result.orderKey! > "f" && result.orderKey! < "m").toBe(true); + }); + + it("uses keyed disabled neighbors as anchors without writing to them", () => { + const insertion = plan({ + activeKey: "a1", + activeSection: "active", + target: { section: "pinned", pinnedOrder: ["p1", "a1", "p2", "p3"] }, + reorderableKeys: new Set(["a1"]), + }); + expect(insertion.kind).toBe("pin"); + if (insertion.kind !== "pin") return; + expect(insertion.order).toEqual(["p1", "a1", "p2", "p3"]); + expect(insertion.orderKey! > "f" && insertion.orderKey! < "m").toBe(true); + expect(insertion.extraAssignments).toEqual([]); + + const reorder = plan({ + activeKey: "p3", + activeSection: "pinned", + target: { section: "pinned", pinnedOrder: ["p1", "p3", "p2"] }, + reorderableKeys: new Set(["p3"]), + }); + expect(reorder.kind).toBe("reorder-pinned"); + if (reorder.kind !== "reorder-pinned") return; + expect(reorder.assignments).toEqual([{ id: "p3", orderKey: expect.any(String) }]); + expect(reorder.assignments[0]!.orderKey > "f").toBe(true); + expect(reorder.assignments[0]!.orderKey < "m").toBe(true); + }); + + it.each([ + { + activeKey: "a1", + activeSection: "active" as const, + order: ["p1", "p3", "a1", "p2"], + }, + { activeKey: "p1", activeSection: "pinned" as const, order: ["p3", "p1", "p2"] }, + ])("rejects $activeSection drops that require rewriting a disabled neighbor", (source) => { + expect( + plan({ + activeKey: source.activeKey, + activeSection: source.activeSection, + target: { section: "pinned", pinnedOrder: source.order }, + pinnedOrder: ["p1", "p3", "p2"], + pinnedKeysById: new Map([ + ["p1", "f"], + ["p2", null], + ["p3", "t"], + ]), + reorderableKeys: new Set(["p1", "p3", source.activeKey]), + }), + ).toEqual({ kind: "none" }); + }); + + it("rewrites the section when a foreign thread lands next to a keyless pin", () => { + const result = plan({ + activeKey: "a1", + activeSection: "active", + target: { section: "pinned", pinnedOrder: ["p1", "a1", "p2", "p3"] }, + pinnedKeysById: new Map([ + ["p1", null], + ["p2", "m"], + ["p3", "t"], + ]), + }); + expect(result.kind).toBe("pin"); + if (result.kind !== "pin") return; + expect(result.orderKey).toBeDefined(); + expect(result.extraAssignments.map((entry) => entry.id)).toEqual(["p1", "p2", "p3"]); + const byId = new Map([ + ["a1", result.orderKey!], + ...result.extraAssignments.map((e) => [e.id, e.orderKey] as const), + ]); + const ordered = result.order.map((id) => byId.get(id)!); + expect([...ordered].sort()).toEqual(ordered); + }); + + it("reorders within the pinned block, and is a no-op when the order is unchanged", () => { + const down = plan({ + activeKey: "p1", + activeSection: "pinned", + target: { section: "pinned", pinnedOrder: ["p2", "p3", "p1"] }, + }); + expect(down.kind).toBe("reorder-pinned"); + if (down.kind !== "reorder-pinned") return; + expect(down.assignments).toEqual([{ id: "p1", orderKey: expect.any(String) }]); + expect(down.assignments[0]!.orderKey > "t").toBe(true); + + expect( + plan({ + activeKey: "p1", + activeSection: "pinned", + target: { section: "pinned", pinnedOrder: ["p1", "p2", "p3"] }, + }), + ).toEqual({ kind: "none" }); + }); +}); + +describe("applySidebarThreadDrop", () => { + const createdAt = "2026-03-09T08:00:00.000Z"; + const earlier = "2026-03-09T09:00:00.000Z"; + const now = "2026-03-09T12:00:00.000Z"; + const serverNow = "2026-03-09T12:00:01.000Z"; + const wakeAt = "2026-03-10T08:00:00.000Z"; + const thread = (overrides: Partial = {}) => ({ + id: ThreadId.make("dragged"), + title: "Keep this title", + createdAt, + updatedAt: earlier, + latestUserMessageAt: null, + latestTurn: null, + pinnedAt: null, + pinOrderKey: null, + activeOrderKey: null, + snoozedAt: null, + snoozedUntil: null, + settledAt: null, + settledOverride: null, + unsettledAt: null, + ...overrides, + }); + const newer = thread({ id: ThreadId.make("newer"), createdAt: "2026-03-09T11:00:00.000Z" }); + + it("previews an un-settle at the same active position as the eventual server row", () => { + const source = thread({ settledOverride: "settled", settledAt: earlier }); + const preview = applySidebarThreadDrop(source, "active", now); + const final = { + ...source, + settledOverride: "active" as const, + settledAt: null, + unsettledAt: serverNow, + }; + expect(sortThreadsForSidebar([newer, preview]).map((row) => row.id)).toEqual([ + "dragged", + "newer", + ]); + expect(sortThreadsForSidebar([newer, preview]).map((row) => row.id)).toEqual( + sortThreadsForSidebar([newer, final]).map((row) => row.id), + ); + }); + + it.each([ + { state: "pin", pinnedAt: earlier, pinOrderKey: "m", snoozedAt: null, snoozedUntil: null }, + { + state: "snooze", + pinnedAt: null, + pinOrderKey: null, + snoozedAt: earlier, + snoozedUntil: wakeAt, + }, + { + state: "snoozed pin", + pinnedAt: earlier, + pinOrderKey: "m", + snoozedAt: earlier, + snoozedUntil: wakeAt, + }, + ])("preserves the active sort anchor when clearing a $state", ({ state: _state, ...parked }) => { + const source = thread({ ...parked, settledOverride: "active", unsettledAt: earlier }); + const preview = applySidebarThreadDrop(source, "active", now); + const final = { + ...source, + pinnedAt: null, + pinOrderKey: null, + snoozedAt: null, + snoozedUntil: null, + updatedAt: serverNow, + }; + expect(preview).toEqual({ ...final, updatedAt: source.updatedAt }); + expect(sortThreadsForSidebar([newer, preview]).map((row) => row.id)).toEqual([ + "newer", + "dragged", + ]); + expect(sortThreadsForSidebar([newer, preview]).map((row) => row.id)).toEqual( + sortThreadsForSidebar([newer, final]).map((row) => row.id), + ); + }); + + it("clears underlying pinning and settlement when waking into Active", () => { + const source = thread({ + pinnedAt: earlier, + pinOrderKey: "m", + snoozedAt: earlier, + snoozedUntil: wakeAt, + settledOverride: "settled", + settledAt: earlier, + }); + expect(applySidebarThreadDrop(source, "active", now)).toEqual({ + ...source, + pinnedAt: null, + pinOrderKey: null, + snoozedAt: null, + snoozedUntil: null, + settledOverride: "active", + settledAt: null, + unsettledAt: now, + }); + }); + + it("previews a new settlement at the same position as the eventual server row", () => { + const source = thread({ + pinnedAt: earlier, + pinOrderKey: "m", + snoozedAt: earlier, + snoozedUntil: wakeAt, + unsettledAt: earlier, + }); + const preview = applySidebarThreadDrop(source, "settled", now); + const final = { + ...source, + pinnedAt: null, + pinOrderKey: null, + snoozedAt: null, + snoozedUntil: null, + settledOverride: "settled" as const, + settledAt: serverNow, + unsettledAt: null, + }; + const existing = { ...newer, settledOverride: "settled" as const, settledAt: newer.createdAt }; + expect(preview).toEqual({ ...final, settledAt: now }); + expect(sortSettledThreadsForSidebar([existing, preview]).map((row) => row.id)).toEqual([ + "dragged", + "newer", + ]); + expect(sortSettledThreadsForSidebar([existing, preview]).map((row) => row.id)).toEqual( + sortSettledThreadsForSidebar([existing, final]).map((row) => row.id), + ); + }); + + it("retains a snoozed thread's earlier settlement and its position when settling again", () => { + const source = thread({ + snoozedAt: earlier, + snoozedUntil: wakeAt, + settledOverride: "settled", + settledAt: earlier, + }); + const preview = applySidebarThreadDrop(source, "settled", now); + const final = { ...source, snoozedAt: null, snoozedUntil: null }; + const existing = { ...newer, settledOverride: "settled" as const, settledAt: newer.createdAt }; + expect(preview).toEqual(final); + expect(sortSettledThreadsForSidebar([existing, preview]).map((row) => row.id)).toEqual([ + "newer", + "dragged", + ]); + }); + + it("pins a settled thread at its requested slot and projects the re-entry stamp", () => { + const source = thread({ + snoozedAt: earlier, + snoozedUntil: wakeAt, + settledOverride: "settled", + settledAt: earlier, + }); + const original = { ...source }; + const preview = applySidebarThreadDrop(source, "pinned", now, "m"); + expect(preview).toEqual({ + ...source, + pinnedAt: now, + pinOrderKey: "m", + snoozedAt: null, + snoozedUntil: null, + settledOverride: "active", + settledAt: null, + unsettledAt: now, + }); + expect( + sortPinnedThreadsForSidebar([ + thread({ id: ThreadId.make("after"), pinnedAt: earlier, pinOrderKey: "t" }), + preview, + thread({ id: ThreadId.make("before"), pinnedAt: earlier, pinOrderKey: "f" }), + ]).map((row) => row.id), + ).toEqual(["before", "dragged", "after"]); + expect(source).toEqual(original); + }); + + it("keeps an existing pin's timestamp and key unless the drop supplies a new key", () => { + const source = thread({ + pinnedAt: earlier, + pinOrderKey: "t", + snoozedAt: earlier, + snoozedUntil: wakeAt, + settledOverride: "active", + unsettledAt: earlier, + }); + const unchangedSlot = applySidebarThreadDrop(source, "pinned", now); + expect(unchangedSlot).toEqual({ ...source, snoozedAt: null, snoozedUntil: null }); + expect(applySidebarThreadDrop(source, "pinned", now, "m")).toEqual({ + ...unchangedSlot, + pinOrderKey: "m", + }); + }); + + it("keeps an Active drop at its chosen position after unpinning", () => { + const source = thread({ pinnedAt: earlier, pinOrderKey: "g", activeOrderKey: "z" }); + const preview = applySidebarThreadDrop(source, "active", now, "m"); + expect(preview).toMatchObject({ pinnedAt: null, pinOrderKey: null, activeOrderKey: "m" }); + expect( + sortThreadsForSidebar([ + thread({ id: ThreadId.make("after"), activeOrderKey: "t" }), + preview, + thread({ id: ThreadId.make("before"), activeOrderKey: "f" }), + ]).map((row) => row.id), + ).toEqual(["before", "dragged", "after"]); + }); + + it("clears the manual Active position when settling so reopening returns to the top", () => { + const source = thread({ activeOrderKey: "z" }); + const settled = applySidebarThreadDrop(source, "settled", now); + expect(settled.activeOrderKey).toBeNull(); + const reopened = applySidebarThreadDrop(settled, "active", serverNow); + expect(sortThreadsForSidebar([newer, reopened]).map((row) => row.id)).toEqual([ + "dragged", + "newer", + ]); + }); +}); + describe("sortPinnedThreadsForSidebar", () => { const pinnable = (input: { id: string; createdAt: string; pinOrderKey?: string | null }) => ({ id: input.id, diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index de06237ae41d..0b406c92c442 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -7,8 +7,8 @@ import { import type { ContextMenuItem } from "@t3tools/contracts"; import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; import type { AsyncResult } from "effect/unstable/reactivity"; +import { planPinnedReorder } from "@t3tools/client-runtime/state/thread-sort"; import { - activeThreadAnchorTimestampMs, getThreadSortTimestamp, resolveSettledThreadTimestamp, sortThreads, @@ -81,12 +81,257 @@ export function useRetainedValue(key: string | null, value: T | null): T | nu return key !== null && retained.current?.key === key ? retained.current.value : null; } -// 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) => +// Sidebar.motion handles ordinary section changes. Sortable transforms own +// dragging; replaying their committed DOM order would animate the drop twice. +export const animateSidebarLayoutChanges: AnimateLayoutChanges = (args) => args.isSorting ? defaultAnimateLayoutChanges(args) : false; +// Rows and section markers share one sortable list. The separators resolve +// the lifecycle action; Sidebar.drag previews the resulting layout. Pinned +// and active threads keep the dragged position; settled threads use time +// order. Snoozed rows can leave the shelf, but dropping into it is not +// supported because snoozing requires a wake time. + +export type SidebarSection = "pinned" | "active" | "snoozed" | "settled"; + +/** Sortable ids: thread rows use their scoped key; structural items use a + colon-free prefix: scoped thread keys always contain a colon. */ +const SIDEBAR_MARKER_PREFIX = "sidebar-marker-"; + +export type SidebarListMarker = + /** The top boundary is also a landing target when there are no pins. */ + | "pinned-header" + /** Stand-in rows so an empty section has somewhere for the gap to open. */ + | "active-placeholder" + | "settled-placeholder" + /** The boundary between pinned and active rows. */ + | "pinned-divider" + | "snoozed-header" + | "settled-header"; + +export function sidebarMarkerId(marker: SidebarListMarker): string { + return `${SIDEBAR_MARKER_PREFIX}${marker}`; +} + +export type SidebarListItem = + | { readonly kind: "thread"; readonly key: string; readonly section: SidebarSection } + | { readonly kind: "marker"; readonly marker: SidebarListMarker }; + +export function sidebarListItemId(item: SidebarListItem): string { + return item.kind === "thread" ? item.key : sidebarMarkerId(item.marker); +} + +/** The section a slot belongs to, read off the markers around it: from + the top down, everything before the pinned divider is pinned, then the + inbox until the snoozed header, the shelf until the settled header, + then settled. */ +function sectionAtSidebarSlot(items: readonly SidebarListItem[], index: number): SidebarSection { + let section: SidebarSection = "pinned"; + for (let i = 0; i < index && i < items.length; i += 1) { + const item = items[i]!; + if (item.kind !== "marker") continue; + if (item.marker === "pinned-divider") section = "active"; + else if (item.marker === "snoozed-header") section = "snoozed"; + else if (item.marker === "settled-header") section = "settled"; + } + return section; +} + +/** Resolve the destination section and manual order from an arrayMove across + * the separators. The snoozed shelf is never a destination. */ +export type SidebarDropTarget = { + readonly section: "pinned" | "active" | "settled"; + readonly pinnedOrder: readonly string[]; + readonly activeOrder: readonly string[]; +}; + +export function resolveSidebarDropTarget( + items: readonly SidebarListItem[], + activeKey: string, + overId: string, +): SidebarDropTarget | null { + const activeIndex = items.findIndex((item) => sidebarListItemId(item) === activeKey); + const overIndex = items.findIndex((item) => sidebarListItemId(item) === overId); + if (activeIndex === -1 || overIndex === -1 || items[activeIndex]?.kind !== "thread") return null; + const moved = items.filter((_, index) => index !== activeIndex); + moved.splice(overIndex, 0, items[activeIndex]!); + const section = sectionAtSidebarSlot(moved, overIndex); + if (section === "snoozed") return null; + const pinnedOrder: string[] = []; + const activeOrder: string[] = []; + let currentSection: SidebarSection = "pinned"; + for (const item of moved) { + if (item.kind === "marker") { + if (item.marker === "pinned-divider") currentSection = "active"; + else if (item.marker === "snoozed-header" || item.marker === "settled-header") break; + } else if (currentSection === "pinned") pinnedOrder.push(item.key); + else activeOrder.push(item.key); + } + return { section, pinnedOrder, activeOrder }; +} + +export type SidebarThreadDropPlan = + | { readonly kind: "none" } + /** Within the pinned block: the existing key writes. */ + | { + readonly kind: "reorder-pinned"; + readonly order: readonly string[]; + readonly assignments: ReadonlyArray<{ readonly id: string; readonly orderKey: string }>; + } + /** From another section into the pinned block. Fresh pins take `orderKey` + on the pin command. `extraAssignments` land afterward, including the + moved row when it was already pinned beneath a snooze. */ + | { + readonly kind: "pin"; + readonly order: readonly string[]; + readonly orderKey: string | undefined; + readonly extraAssignments: ReadonlyArray<{ readonly id: string; readonly orderKey: string }>; + } + | { + readonly kind: "move-active"; + readonly order: readonly string[]; + readonly assignments: ReadonlyArray<{ readonly id: string; readonly orderKey: string }>; + readonly unpin: boolean; + readonly unsettle: boolean; + readonly unsnooze: boolean; + } + | { readonly kind: "settle" }; + +export function planSidebarThreadDrop(input: { + readonly activeKey: string; + readonly activeSection: SidebarSection; + /** Snoozed threads can retain pinning and settlement beneath the shelf. */ + readonly activePinned?: boolean; + readonly activeSettled?: boolean; + readonly supportsSettlement?: boolean; + readonly target: SidebarDropTarget; + /** All pinned keys in displayed order before the drop. */ + readonly pinnedOrder: readonly string[]; + readonly pinnedKeysById: ReadonlyMap; + readonly reorderableKeys?: ReadonlySet; + readonly activeOrder: readonly string[]; + readonly activeKeysById: ReadonlyMap; + readonly activeReorderableKeys?: ReadonlySet; +}): SidebarThreadDropPlan { + const { + activeKey, + activeSection, + activePinned = activeSection === "pinned", + activeSettled = activeSection === "settled", + target, + pinnedOrder, + pinnedKeysById, + reorderableKeys, + activeOrder, + activeKeysById, + activeReorderableKeys, + } = input; + if (input.supportsSettlement === false && (target.section === "settled" || activeSettled)) { + return { kind: "none" }; + } + switch (target.section) { + case "active": { + const order = target.activeOrder; + if ( + activeSection === "active" && + order.length === activeOrder.length && + order.every((key, index) => key === activeOrder[index]) + ) { + return { kind: "none" }; + } + const assignments = planPinnedReorder({ + orderedIds: order, + keysById: activeKeysById, + movedId: activeKey, + }); + if (activeReorderableKeys && assignments.some(({ id }) => !activeReorderableKeys.has(id))) { + return { kind: "none" }; + } + return { + kind: "move-active", + order, + assignments, + unpin: activePinned, + unsettle: activeSettled, + unsnooze: activeSection === "snoozed", + }; + } + case "settled": + return activeSection === "settled" ? { kind: "none" } : { kind: "settle" }; + case "pinned": { + const order = target.pinnedOrder; + // Dropped back where it started: nothing to write. + if ( + activeSection === "pinned" && + order.length === pinnedOrder.length && + order.every((key, index) => key === pinnedOrder[index]) + ) { + return { kind: "none" }; + } + const assignments = planPinnedReorder({ + orderedIds: order, + keysById: pinnedKeysById, + movedId: activeKey, + }); + if (reorderableKeys && assignments.some(({ id }) => !reorderableKeys.has(id))) { + return { kind: "none" }; + } + if (activeSection === "pinned") { + return assignments.length === 0 + ? { kind: "none" } + : { kind: "reorder-pinned", order, assignments }; + } + return { + kind: "pin", + order, + orderKey: assignments.find((assignment) => assignment.id === activeKey)?.orderKey, + extraAssignments: activePinned + ? assignments + : assignments.filter((assignment) => assignment.id !== activeKey), + }; + } + } +} + +/** Project a drop's lifecycle fields before sorting its destination. Reusing + the server's re-entry rules keeps the preview in place when events arrive. */ +export function applySidebarThreadDrop< + T extends Pick< + SidebarThreadSummary, + | "pinnedAt" + | "pinOrderKey" + | "activeOrderKey" + | "snoozedAt" + | "snoozedUntil" + | "settledAt" + | "settledOverride" + | "unsettledAt" + >, +>(thread: T, section: "pinned" | "active" | "settled", now: string, orderKey?: string): T { + const wasSettled = thread.settledOverride === "settled"; + const awake = { ...thread, snoozedAt: null, snoozedUntil: null }; + if (section === "settled") { + return { + ...awake, + pinnedAt: null, + pinOrderKey: null, + activeOrderKey: null, + settledOverride: "settled", + settledAt: wasSettled ? (thread.settledAt ?? now) : now, + unsettledAt: null, + }; + } + const resumed = wasSettled + ? { ...awake, settledOverride: "active" as const, settledAt: null, unsettledAt: now } + : awake; + return { + ...resumed, + pinnedAt: section === "pinned" ? (thread.pinnedAt ?? now) : null, + pinOrderKey: section === "pinned" ? (orderKey ?? thread.pinOrderKey) : null, + ...(section === "active" && orderKey !== undefined ? { activeOrderKey: orderKey } : {}), + }; +} + type SidebarProject = { id: string; title: string; @@ -605,26 +850,7 @@ function firstValidTimestamp( return null; } -// Sidebar sort: static order, newest anchor on top. Activity NEVER reorders -// the list — a row holds its position between lifecycle transitions, so the -// screen only moves when a thread enters or leaves the active list. The -// anchor is creation time until an un-settle re-anchors it (see -// activeThreadAnchorTimestampMs), so an un-settled thread surfaces at the -// top instead of sinking back to its creation-order slot. Status (including -// pending approval) is carried by each card's edge strip, not by position. -export function sortThreadsForSidebar< - T extends { - readonly id: string; - readonly createdAt: string; - readonly unsettledAt?: string | null | undefined; - }, ->(threads: readonly T[]): T[] { - return [...threads].toSorted( - (left, right) => - activeThreadAnchorTimestampMs(right) - activeThreadAnchorTimestampMs(left) || - left.id.localeCompare(right.id), - ); -} +export { sortActiveThreadsByOrderKey as sortThreadsForSidebar } from "@t3tools/client-runtime/state/thread-sort"; // Pinned-reorder key math and the keyed sort live in client-runtime // (state/thread-sort) so web and mobile compute identical pinned orders. diff --git a/apps/web/src/components/Sidebar.motion.test.ts b/apps/web/src/components/Sidebar.motion.test.ts new file mode 100644 index 000000000000..f8570553058a --- /dev/null +++ b/apps/web/src/components/Sidebar.motion.test.ts @@ -0,0 +1,339 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { createSidebarListMotion } from "./Sidebar.motion"; + +class TestAnimation { + progress: number | null = 0; + playState: AnimationPlayState = "running"; + effect = { getComputedTiming: () => ({ progress: this.progress }) }; + cancel = vi.fn(() => { + this.playState = "idle"; + }); + private onFinish: (() => void) | undefined; + addEventListener(_type: string, listener: () => void) { + this.onFinish = listener; + } + finish() { + this.playState = "finished"; + this.onFinish?.(); + } +} + +class TestRow { + offsetTop = 0; + offsetLeft = 4; + offsetWidth = 260; + namespaceURI = "http://www.w3.org/1999/xhtml"; + dragTranslate = 0; + style: Record = {}; + inert = false; + attributes: { name: string; value: string }[] = []; + children: TestRow[] = []; + clones: TestRow[] = []; + remove = vi.fn(); + animations: TestAnimation[] = []; + constructor( + readonly name: string, + public offsetHeight = 82, + ) {} + getBoundingClientRect() { + return { top: this.offsetTop + this.dragTranslate, height: this.offsetHeight }; + } + setAttribute(name: string, value: string) { + this.removeAttribute(name); + this.attributes.push({ name, value }); + } + removeAttribute(name: string) { + this.attributes = this.attributes.filter((attribute) => attribute.name !== name); + } + querySelectorAll(_selector: string): TestRow[] { + return this.children.flatMap((child) => [child, ...child.querySelectorAll("*")]); + } + cloneNode(_deep: boolean): TestRow { + const clone = new TestRow(`${this.name} clone`, this.offsetHeight); + clone.namespaceURI = this.namespaceURI; + clone.style = { ...this.style }; + clone.attributes = this.attributes.map((attribute) => ({ ...attribute })); + clone.children = this.children.map((child) => child.cloneNode(true)); + this.clones.push(clone); + return clone; + } + animate = vi.fn((_frames: Keyframe[], _options: KeyframeAnimationOptions) => { + const animation = new TestAnimation(); + this.animations.push(animation); + return animation; + }); +} + +function fixture(rows: TestRow[]) { + const media = { matches: false }; + const parent = { + children: rows, + ownerDocument: { defaultView: { matchMedia: () => media } }, + append(node: TestRow) { + parent.children.push(node); + node.remove.mockImplementation(() => { + parent.children = parent.children.filter((child) => child !== node); + }); + }, + }; + function layout(next: TestRow[]) { + let top = 8; + for (const row of next) { + row.offsetTop = top; + top += row.offsetHeight + 1; + } + parent.children = [ + ...next, + ...parent.children.filter((row) => row.style.position === "absolute"), + ]; + } + layout(rows); + const motion = createSidebarListMotion(parent as unknown as HTMLUListElement); + return { motion, layout, media, parent }; +} + +function expectMove(row: TestRow, offset: number) { + expect(row.animate).toHaveBeenLastCalledWith( + [{ transform: `translateY(${offset}px)` }, { transform: "translateY(0px)" }], + { duration: 150, easing: "ease-out" }, + ); +} + +beforeEach(() => vi.stubGlobal("HTMLElement", TestRow)); +afterEach(() => vi.unstubAllGlobals()); + +describe("sidebar list motion", () => { + it("moves a retained Active row into Settled with its displaced peers", () => { + const pinnedHeader = new TestRow("Pinned", 0); + const pinned = new TestRow("pin"); + const divider = new TestRow("Active", 0); + const a = new TestRow("a"); + const b = new TestRow("b"); + const settledHeader = new TestRow("Settled", 32); + const settled = new TestRow("settled", 36); + const rows = [pinnedHeader, pinned, divider, a, b, settledHeader, settled]; + const { motion, layout } = fixture(rows); + motion.update(true); + expect(rows.every((row) => row.animations.length === 0)).toBe(true); + + a.offsetHeight = 36; + layout([pinnedHeader, pinned, divider, b, settledHeader, settled, a]); + motion.update(true); + expectMove(a, -153); + expectMove(b, 83); + expectMove(settledHeader, 83); + expectMove(settled, 83); + expect(pinned.animate).not.toHaveBeenCalled(); + expect(divider.animate).not.toHaveBeenCalled(); + }); + + it("refreshes the drop baseline without replay and animates the next ordinary move", () => { + const [a, b, c] = [new TestRow("a"), new TestRow("b"), new TestRow("c")]; + const { motion, layout } = fixture([a, b, c]); + motion.update(true); + motion.suspend(); + a.dragTranslate = 300; + b.dragTranslate = -83; + motion.update(false); + motion.suspend(); + layout([b, a, c]); + a.dragTranslate = b.dragTranslate = 0; + motion.update(true); + expect([a, b, c].every((row) => row.animations.length === 0)).toBe(true); + + layout([c, b, a]); + motion.update(true); + expectMove(c, 166); + expectMove(b, -83); + expectMove(a, -83); + }); + + it("does not carry a canceled drag's transformed position into the next move", () => { + const a = new TestRow("a"); + const b = new TestRow("b"); + const { motion, layout } = fixture([a, b]); + motion.update(true); + motion.suspend(); + a.dragTranslate = 500; + b.dragTranslate = -83; + motion.update(false); + motion.suspend(); + a.dragTranslate = b.dragTranslate = 0; + motion.update(true); + expect(a.animate).not.toHaveBeenCalled(); + expect(b.animate).not.toHaveBeenCalled(); + + layout([b, a]); + motion.update(true); + expectMove(a, -83); + expectMove(b, 83); + }); + + it("retargets rapid changes from the current visual position", () => { + const [a, b, c] = [new TestRow("a", 99), new TestRow("b", 99), new TestRow("c", 99)]; + const { motion, layout } = fixture([a, b, c]); + motion.update(true); + layout([b, c, a]); + motion.update(true); + expectMove(a, -200); + const first = a.animations[0]!; + first.progress = 0.25; + + layout([b, a, c]); + motion.update(true); + expect(first.cancel).toHaveBeenCalledOnce(); + expectMove(a, -50); + first.finish(); + motion.suspend(); + expect(a.animations[1]!.cancel).toHaveBeenCalledOnce(); + }); + + it("keeps an uninterrupted movement when the layout position does not change", () => { + const a = new TestRow("a"); + const b = new TestRow("b"); + const { motion, layout } = fixture([a, b]); + motion.update(true); + layout([b, a]); + motion.update(true); + a.animations[0]!.progress = 0.5; + motion.update(true); + expect(a.animate).toHaveBeenCalledOnce(); + expect(a.animations[0]!.cancel).not.toHaveBeenCalled(); + }); + + it("cancels owned motion on suspension and never animates a disposed list", () => { + const a = new TestRow("a"); + const b = new TestRow("b"); + const { motion, layout } = fixture([a, b]); + motion.update(true); + layout([b, a]); + motion.update(true); + motion.suspend(); + expect(a.animations[0]!.cancel).toHaveBeenCalledOnce(); + expect(b.animations[0]!.cancel).toHaveBeenCalledOnce(); + motion.update(false); + layout([a, b]); + motion.update(true); + motion.dispose(); + expect(a.animations[1]!.cancel).toHaveBeenCalledOnce(); + layout([b, a]); + motion.update(true); + expect(a.animate).toHaveBeenCalledTimes(2); + }); + + it("fades a collapsed-shelf exit at its current visual box and a new wake in", () => { + const a = new TestRow("a"); + const b = new TestRow("b"); + const fresh = new TestRow("new"); + a.setAttribute("data-thread-item", "a"); + a.children = [new TestRow("button")]; + a.children[0]!.setAttribute("id", "thread-control"); + a.children[0]!.setAttribute("data-testid", "thread-control"); + a.children[0]!.setAttribute("data-state", "open"); + const icon = new TestRow("provider icon"); + icon.namespaceURI = "http://www.w3.org/2000/svg"; + icon.setAttribute("id", "provider-mask"); + icon.setAttribute("mask", "url(#provider-mask)"); + a.children.push(icon); + const { motion, layout, parent } = fixture([a, b]); + motion.update(true); + layout([b, a]); + motion.update(true); + a.animations[0]!.progress = 0.5; + layout([b, fresh]); + motion.update(true); + expect(a.animations[0]!.cancel).toHaveBeenCalledOnce(); + expect(fresh.animate).toHaveBeenLastCalledWith([{ opacity: 0 }, { opacity: 1 }], { + duration: 150, + easing: "ease-out", + }); + const clone = a.clones[0]!; + expect(clone.style).toMatchObject({ + position: "absolute", + top: "49.5px", + left: "4px", + width: "260px", + height: "82px", + transform: "none", + pointerEvents: "none", + }); + expect(clone.inert).toBe(true); + expect(clone.attributes).toEqual([{ name: "aria-hidden", value: "true" }]); + expect(clone.children[0]!.attributes).toEqual([{ name: "data-state", value: "open" }]); + expect(clone.children[1]!.attributes).toEqual(icon.attributes); + expect(clone.animate).toHaveBeenCalledWith([{ opacity: 1 }, { opacity: 0 }], { + duration: 150, + easing: "ease-out", + }); + expect(parent.children.includes(clone)).toBe(true); + motion.update(true); + expect(clone.animations).toHaveLength(1); + expect(clone.clones).toHaveLength(0); + clone.animations[0]!.finish(); + expect(parent.children.includes(clone)).toBe(false); + }); + + it("clears exit clones on pickup and does not fade the release commit", () => { + const [a, b, c] = [new TestRow("a"), new TestRow("b"), new TestRow("c")]; + const { motion, layout, parent } = fixture([a, b]); + motion.update(false); + layout([b]); + motion.update(true); + const clone = a.clones[0]!; + motion.suspend(); + expect(clone.animations[0]!.cancel).toHaveBeenCalledOnce(); + expect(parent.children.includes(clone)).toBe(false); + motion.update(false); + motion.suspend(); + layout([c]); + motion.update(true); + expect(b.clones).toHaveLength(0); + expect(c.animations).toHaveLength(0); + layout([c, a]); + motion.update(true); + expect(a.animate).toHaveBeenCalledWith([{ opacity: 0 }, { opacity: 1 }], { + duration: 150, + easing: "ease-out", + }); + motion.dispose(); + expect(a.animations.at(-1)!.cancel).toHaveBeenCalledOnce(); + }); + + it("carries entry opacity into a quick exit and removes artifacts on a silent update", () => { + const a = new TestRow("a"); + const marker = new TestRow("boundary", 0); + const { motion, layout, parent } = fixture([marker]); + motion.update(false); + layout([marker, a]); + motion.update(true); + a.animations[0]!.progress = 0.4; + layout([]); + motion.update(true); + expect(marker.clones).toHaveLength(0); + const clone = a.clones[0]!; + expect(clone.animate).toHaveBeenCalledWith([{ opacity: 0.4 }, { opacity: 0 }], { + duration: 150, + easing: "ease-out", + }); + motion.update(false); + expect(parent.children).toEqual([]); + expect(clone.animations[0]!.cancel).toHaveBeenCalledOnce(); + }); + + it("respects reduced motion while keeping the next baseline fresh", () => { + const a = new TestRow("a"); + const b = new TestRow("b"); + const { motion, layout, media } = fixture([a, b]); + motion.update(true); + media.matches = true; + layout([b, a]); + motion.update(true); + expect(a.animate).not.toHaveBeenCalled(); + media.matches = false; + layout([a, b]); + motion.update(true); + expectMove(a, 83); + expectMove(b, -83); + }); +}); diff --git a/apps/web/src/components/Sidebar.motion.ts b/apps/web/src/components/Sidebar.motion.ts new file mode 100644 index 000000000000..065e22be1b30 --- /dev/null +++ b/apps/web/src/components/Sidebar.motion.ts @@ -0,0 +1,168 @@ +const motionTiming = { duration: 150, easing: "ease-out" }; + +type RowPosition = { top: number; left: number; width: number; height: number }; + +function progress(animation: Animation) { + return animation.playState === "finished" + ? 1 + : (animation.effect?.getComputedTiming().progress ?? 0); +} + +/** Animate rows between their layout positions. The list must be + * positioned so every direct child's offsetTop has the same origin. */ +export function createSidebarListMotion(parent: HTMLUListElement) { + let positions: Map | null = null; + let disposed = false; + const reducedMotion = parent.ownerDocument.defaultView?.matchMedia( + "(prefers-reduced-motion: reduce)", + ); + const running = new Map(); + const entering = new Map(); + const exiting = new Map(); + + const remainingOffset = (node: HTMLElement) => { + const current = running.get(node); + return current ? current.offset * (1 - progress(current.animation)) : 0; + }; + const clearFades = () => { + for (const animation of [...entering.values(), ...exiting.values()]) animation.cancel(); + for (const node of exiting.keys()) node.remove(); + entering.clear(); + exiting.clear(); + }; + const fadeOut = (node: HTMLElement, position: RowPosition) => { + if (position.height === 0) return; + // React owns the removed row; only a noninteractive copy stays for the fade. + const clone = node.cloneNode(true) as HTMLElement; + for (const element of [clone, ...clone.querySelectorAll("*")]) { + for (const attribute of Array.from(element.attributes)) { + if ( + (attribute.name === "id" && element.namespaceURI !== "http://www.w3.org/2000/svg") || + attribute.name === "data-thread-item" || + attribute.name === "data-thread-selection-safe" || + attribute.name === "data-testid" + ) { + element.removeAttribute(attribute.name); + } + } + } + clone.setAttribute("aria-hidden", "true"); + clone.inert = true; + Object.assign(clone.style, { + position: "absolute", + top: `${position.top + remainingOffset(node)}px`, + left: `${position.left}px`, + width: `${position.width}px`, + height: `${position.height}px`, + margin: "0", + boxSizing: "border-box", + contentVisibility: "visible", + transform: "none", + transition: "none", + pointerEvents: "none", + }); + parent.append(clone); + const entry = entering.get(node); + const animation = clone.animate( + [{ opacity: entry ? progress(entry) : 1 }, { opacity: 0 }], + motionTiming, + ); + exiting.set(clone, animation); + animation.addEventListener( + "finish", + () => { + clone.remove(); + exiting.delete(clone); + }, + { once: true }, + ); + }; + + const cancel = (node: HTMLElement) => { + running.get(node)?.animation.cancel(); + running.delete(node); + }; + const suspend = () => { + for (const node of running.keys()) cancel(node); + clearFades(); + positions = null; + }; + + return { + update(animate: boolean) { + if (disposed) return; + const next = new Map( + Array.from(parent.children) + .filter((node): node is HTMLElement => node instanceof HTMLElement && !exiting.has(node)) + .map((node) => [ + node, + { + top: node.offsetTop, + left: node.offsetLeft, + width: node.offsetWidth, + height: node.offsetHeight, + }, + ]), + ); + const shouldAnimate = animate && positions !== null && !reducedMotion?.matches; + if (!shouldAnimate) clearFades(); + else { + for (const [node, position] of positions!) { + if (!next.has(node)) fadeOut(node, position); + } + } + for (const [node, animation] of entering) { + if (!next.has(node)) { + animation.cancel(); + entering.delete(node); + } + } + for (const node of running.keys()) { + if (!shouldAnimate || !next.has(node)) cancel(node); + } + if (shouldAnimate) { + for (const [node, position] of next) { + const previousTop = positions?.get(node)?.top; + if (previousTop === undefined) { + if (position.height > 0) { + const animation = node.animate([{ opacity: 0 }, { opacity: 1 }], motionTiming); + entering.set(node, animation); + animation.addEventListener( + "finish", + () => { + if (entering.get(node) === animation) entering.delete(node); + }, + { once: true }, + ); + } + continue; + } + if (previousTop === position.top) continue; + // Computed progress includes the effect's easing. Only our own + // translate is carried forward; dnd-kit's transforms are never read. + const offset = previousTop + remainingOffset(node) - position.top; + cancel(node); + if (offset === 0) continue; + const animation = node.animate( + [{ transform: `translateY(${offset}px)` }, { transform: "translateY(0px)" }], + motionTiming, + ); + running.set(node, { animation, offset }); + animation.addEventListener( + "finish", + () => { + if (running.get(node)?.animation === animation) running.delete(node); + }, + { once: true }, + ); + } + } + positions = next; + }, + suspend, + dispose() { + suspend(); + disposed = true; + }, + }; +} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index a0d983976379..1a66dbdd9b5f 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,20 +1,15 @@ -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, + type DragOverEvent, + type DragStartEvent, } from "@dnd-kit/core"; -import { - SortableContext, - arrayMove, - useSortable, - verticalListSortingStrategy, -} from "@dnd-kit/sortable"; +import { SortableContext, useSortable } from "@dnd-kit/sortable"; import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers"; import { CSS } from "@dnd-kit/utilities"; import { @@ -63,6 +58,7 @@ import { memo, useCallback, useEffect, + useLayoutEffect, useMemo, useReducer, useRef, @@ -77,6 +73,7 @@ import { isAtomCommandInterrupted, settlePromise, squashAtomCommandFailure, + type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; import { isElectron } from "../env"; import { @@ -137,7 +134,8 @@ import { cn } from "~/lib/utils"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { buildThreadActionMenuItems } from "./threadActionMenu.logic"; import { - animatePinnedLayoutChanges, + animateSidebarLayoutChanges, + applySidebarThreadDrop, buildBulkTitleRegenerationContextMenuItem, buildBulkUnpinContextMenuItem, deleteSelectedThreadEntries, @@ -148,14 +146,17 @@ import { isSidebarNestedLinkClick, isTrailingDoubleClick, orderItemsByPreferredIds, - planPinnedReorder, + planSidebarThreadDrop, reduceSidebarProjectScopeMenuState, resolveAdjacentThreadId, + resolveSidebarDropTarget, resolveSidebarThreadStatus, searchSidebarThreadsByTitle, shouldCreateNewThreadInCurrentProject, shouldRecedeSidebarThread, resolveWorkingStartedAt, + sidebarListItemId, + sidebarMarkerId, sortLogicalProjectsForSidebar, sortPinnedThreadsForSidebar, sortSettledThreadsForSidebar, @@ -163,8 +164,13 @@ import { useRetainedValue, useSidebarRowSubscriptionLease, useThreadJumpHintVisibility, + type SidebarListItem, + type SidebarListMarker, + type SidebarSection, } from "./Sidebar.logic"; import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; +import { createSidebarCollisionDetection, createSidebarSortingStrategy } from "./Sidebar.drag"; +import { createSidebarListMotion } from "./Sidebar.motion"; import { ThreadWorktreeIndicator, prStatusIndicator, @@ -473,23 +479,25 @@ 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 +// Subset of useSortable applied to a thread row's root
  • . Listeners go +// on the whole row (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 +// attributes since there is no keyboard sensor and the row body already // carries its own button semantics. -type SortablePinnedRowBag = Pick< +type SortableThreadRowBag = Pick< ReturnType, "listeners" | "setNodeRef" | "transform" | "transition" | "isDragging" >; -function SortablePinnedThreadRow(props: { +function SortableThreadRow(props: { id: string; - children: (bag: SortablePinnedRowBag) => ReactNode; + disabled: boolean; + children: (bag: SortableThreadRowBag) => ReactNode; }) { const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: props.id, - animateLayoutChanges: animatePinnedLayoutChanges, + disabled: { draggable: props.disabled }, + animateLayoutChanges: animateSidebarLayoutChanges, }); return props.children({ listeners, setNodeRef, transform, transition, isDragging }); } @@ -499,6 +507,152 @@ function SortablePinnedThreadRow(props: { const draftSurfaceClassName = "bg-amber-400/[0.04] hover:bg-amber-400/[0.08]"; const draftPenClassName = "size-3 shrink-0 text-amber-600 dark:text-amber-300/80"; +// Structural list items — the section headers and the +// empty-section placeholders — take part in the sortable list so they shift +// with the rows and the gap can open on either side of them. They can't be +// picked up, and a marker is the sortable `over` when the pointer is on it, +// which resolveSidebarDropTarget turns into the section the gap sits in. +function SortableSidebarMarker(props: { + marker: SidebarListMarker; + className?: string; + children?: ReactNode; + "data-testid"?: string; +}) { + const { setNodeRef, transform, transition } = useSortable({ + id: sidebarMarkerId(props.marker), + disabled: { draggable: true }, + animateLayoutChanges: animateSidebarLayoutChanges, + }); + return ( +
  • + {props.children} +
  • + ); +} + +// Empty targets stay mounted before pickup so starting a drag never changes +// the list's measured positions. +function SidebarSectionPlaceholder(props: { + marker: "active-placeholder" | "settled-placeholder"; + label: string; + showHint: boolean; + isDropTarget: boolean; +}) { + return ( + + {props.showHint ? props.label : null} + + ); +} + +// Boundary labels overlay the cards' padding during a drag. The measured +// marker stays empty, so showing a label never pushes a row out of the way. +function SidebarDragBoundary(props: { + marker: "pinned-header" | "pinned-divider"; + label: string; + hint: string | null; + visible: boolean; + isDropTarget: boolean; +}) { + return ( + + {props.visible ? ( +
    + + {props.label} + {props.hint ? {props.hint} : null} + + +
    + ) : null} +
    + ); +} + +// Shelf headers stay visible and keep their measured height while dragging. +function SidebarSectionHeader(props: { + marker: "snoozed-header" | "settled-header"; + label: string; + hint?: string | null; + isDropTarget?: boolean; + toggle: { expanded: boolean; onToggle: () => void }; +}) { + const snoozed = props.marker === "snoozed-header"; + const className = cn( + "flex h-full w-full items-center gap-2 rounded-md border border-dashed border-transparent px-2 text-left text-xs font-medium", + snoozed ? "text-blue-600 dark:text-blue-400" : "text-sidebar-muted-foreground/60", + props.isDropTarget && "border-primary/40 bg-primary/5 text-primary", + ); + const content = ( + <> + {props.label} + + {props.hint ? {props.hint} : null} + + + ); + return ( + + + + ); +} + // One unsent draft session the user has invested content in. Two lines, // nothing else: project name, then the typed prompt. All the draft's // settings (model, env mode, branch, worktree) still travel with it — @@ -752,10 +906,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 + // Present on rows whose server supports every drop outcome: dnd-kit + // sortable bag applied to the row root so the whole row drags (the // pointer sensor's distance constraint keeps plain clicks working). - sortable?: SortablePinnedRowBag | undefined; + sortable?: SortableThreadRowBag | undefined; + dropSection: SidebarSection | null; // Compact wake countdown ("2h") for rows in the snoozed shelf. snoozeWakeLabelText: string | null; // When a snooze ended (timer or early wake); drives the Woke pill until @@ -1151,6 +1306,41 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { !isSelected && "opacity-70 transition-opacity hover:opacity-100", ); + // dnd-kit props for the row root. Same bag on both variants: every row in + // the list translates around the gap as the drag passes it. + const sortable = props.sortable; + const sortableRootProps = sortable + ? { + ref: sortable.setNodeRef, + style: { + transform: CSS.Translate.toString(sortable.transform), + transition: sortable.transition, + // A zero-height boundary also makes dnd-kit scale the source to + // zero. Only projected peers use scaleY as a visibility sentinel. + visibility: + !sortable.isDragging && sortable.transform?.scaleY === 0 + ? ("hidden" as const) + : undefined, + }, + ...sortable.listeners, + } + : {}; + const dragDestination = + sortable?.isDragging && props.dropSection !== null ? ( + + + {props.dropSection === "pinned" + ? "Pinned" + : props.dropSection === "active" + ? "Active" + : props.dropSection === "settled" + ? "Settled" + : "Snoozed"} + + ) : null; const title = isRenaming ? ( - + - - {variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? ( - // Snoozed rows show when they come BACK, not when they were - // last touched — the return ticket is the row's whole story. - - {props.snoozeWakeLabelText} - - ) : isWoke ? ( - // A wake can land straight in the settled tail (e.g. PR - // merged while snoozed); the signal must survive the trip. + {dragDestination ?? ( + + + {variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? ( + // Snoozed rows show when they come BACK, not when they were + // last touched — the return ticket is the row's whole story. + + {props.snoozeWakeLabelText} + + ) : isWoke ? ( + // A wake can land straight in the settled tail (e.g. PR + // merged while snoozed); the signal must survive the trip. + + + + Woke + + } + /> + Dismiss Woke notification + + ) : ( + + {variantAction === "unsettle" + ? settledTimeLabel(thread) + : threadTimeLabel(thread)} + + )} + + {variantAction === "unsnooze" ? ( + !props.snoozeSupported ? null : ( + + ) + ) : !props.settlementSupported ? null : variantAction === "unsettle" ? ( - - Woke - + aria-label="Un-settle thread" + onClick={handleUnsettleClick} + className={cn( + "pointer-events-none absolute inset-y-0 right-0 -mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/sidebar-row:pointer-events-auto group-hover/sidebar-row:opacity-100", + isWoke && "group-hover/sidebar-row:static", + )} + /> } - /> - Dismiss Woke notification + > + + + Un-settle thread ) : ( - - {variantAction === "unsettle" - ? settledTimeLabel(thread) - : threadTimeLabel(thread)} - - )} - - {variantAction === "unsnooze" ? ( - !props.snoozeSupported ? null : ( - ) - ) : !props.settlementSupported ? null : variantAction === "unsettle" ? ( - - - } - > - - - Un-settle thread - - ) : ( - - )} - + )} + + )} {props.jumpLabel ? : null} {detailsTooltip} @@ -1429,26 +1625,16 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { const diff = latestTurnDiff(thread); - const sortable = props.sortable; return (
  • - +
  • {title} @@ -1834,8 +2022,10 @@ export default function Sidebar() { snoozeThread, unsnoozeThread, pinThread, + unpinThread, confirmAndUnpinThread, reorderPinnedThread, + reorderActiveThread, archiveThread, deleteThread, } = useThreadActions(); @@ -2196,13 +2386,27 @@ export default function Sidebar() { [openProjectSettings], ); - // Settled threads stay in the live shell stream (settled ≠ archived), so - // the partition works directly off live shells: no archived-snapshot - // merging, no optimistic holds. Archived threads remain hidden here — - // archive keeps its original "remove from sidebar" meaning. + // Keep a dropped row at its destination while its server applies the + // lifecycle command and any order-key writes. The next pickup waits for + // this hold so a second drop cannot replace an unconfirmed placement. + const [optimisticDrop, setOptimisticDrop] = useState<{ + readonly key: string; + readonly sourceSection: SidebarSection; + readonly section: "pinned" | "active" | "settled"; + readonly occurredAt: string; + readonly clearsSnooze: boolean; + /** Full destination order for pinned and active drops. */ + readonly order: readonly string[] | null; + /** Destination order keys before the drop, to recognize concurrent writes. */ + 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 { pinnedThreads, - reorderablePinnedKeys, + draggableThreadKeys, + activeReorderableThreadKeys, activeThreads, snoozedThreads, settledThreads, @@ -2224,17 +2428,42 @@ export default function Sidebar() { const active: EnvironmentThreadShell[] = []; const snoozed: EnvironmentThreadShell[] = []; const settled: EnvironmentThreadShell[] = []; + const draggable = new Set(); + const activeReorderable = new Set(); for (const thread of visible) { + const capabilities = serverConfigs.get(thread.environmentId)?.environment.capabilities; // Threads on servers without the settlement capability (old server, // or descriptor not loaded yet) never classify as settled: the user // could neither un-settle nor pin them, so auto-settling them would // strand rows in a tail with no working affordances. - const supportsSettlement = - serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === true; - const supportsSnooze = - serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; - // Snooze outranks settlement and pinning until the thread wakes. - if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) { + const supportsSettlement = capabilities?.threadSettlement === true; + const supportsSnooze = capabilities?.threadSnooze === true; + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + if (capabilities?.threadActiveReorder === true) activeReorderable.add(threadKey); + // Older servers retain their existing drag actions. Active placement + // additionally requires its own ordering capability at the drop target. + if (capabilities?.threadPinning === true && capabilities.threadPinReorder === true) { + draggable.add(threadKey); + } + if (optimisticDrop?.key === threadKey) { + const projected = applySidebarThreadDrop( + thread, + optimisticDrop.section, + optimisticDrop.occurredAt, + optimisticDrop.assignedKeys.get(threadKey), + ); + (optimisticDrop.section === "pinned" + ? pinned + : optimisticDrop.section === "settled" + ? settled + : active + ).push( + optimisticDrop.clearsSnooze + ? projected + : { ...projected, snoozedAt: thread.snoozedAt, snoozedUntil: thread.snoozedUntil }, + ); + } else if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) { + // Snooze outranks settlement and pinning until the thread wakes. snoozed.push(thread); } else if (supportsSettlement && thread.settledOverride === "settled") { settled.push(thread); @@ -2249,18 +2478,27 @@ export default function Sidebar() { // Server capability only gates DRAGGING — it must not influence the // sort, or mixed-version fleets would render different pinned orders on // web and mobile from the same data. + const sortedPinned = sortPinnedThreadsForSidebar(pinned); + const sortedActive = sortThreadsForSidebar(active); return { - pinnedThreads: sortPinnedThreadsForSidebar(pinned), - reorderablePinnedKeys: new Set( - pinned - .filter( - (thread) => - serverConfigs.get(thread.environmentId)?.environment.capabilities.threadPinReorder === - true, - ) - .map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), - ), - activeThreads: sortThreadsForSidebar(active), + pinnedThreads: + optimisticDrop?.section !== "pinned" || optimisticDrop.order === null + ? sortedPinned + : orderItemsByPreferredIds({ + items: sortedPinned, + preferredIds: optimisticDrop.order, + getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + }), + draggableThreadKeys: draggable, + activeReorderableThreadKeys: activeReorderable, + activeThreads: + optimisticDrop?.section !== "active" || optimisticDrop.order === null + ? sortedActive + : orderItemsByPreferredIds({ + items: sortedActive, + preferredIds: optimisticDrop.order, + getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + }), // Soonest wake first: "what comes back next" is the shelf's question. snoozedThreads: snoozed.toSorted( (left, right) => @@ -2270,7 +2508,7 @@ export default function Sidebar() { settledThreads: sortSettledThreadsForSidebar(settled), snoozeNow: preciseNow, }; - }, [nowMinute, scopedProjectKeys, serverConfigs, snoozeWakeTick, threads]); + }, [nowMinute, optimisticDrop, scopedProjectKeys, serverConfigs, snoozeWakeTick, threads]); const threadSearchInputRef = useRef(null); const [threadSearchQuery, setThreadSearchQuery] = useState(""); @@ -2715,77 +2953,122 @@ 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( + const listMotionRef = useRef | null>(null); + const attachListMotionRef = useCallback((node: HTMLUListElement | null) => { + listMotionRef.current?.dispose(); + listMotionRef.current = node === null ? null : createSidebarListMotion(node); + listMotionRef.current?.update(false); + }, []); + + // Hold the chosen section and order until every key write arrives. This + // also covers first-time ordering, which assigns keys to keyless neighbors. + // A failed write, concurrent reorder, or membership change releases the hold. + const dndSensors = 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; + const [dragState, setDragState] = useState<{ + readonly activeKey: string; + readonly activeSection: SidebarSection; + readonly occurredAt: string; + readonly activationY: number | null; } | null>(null); - const orderedPinnedThreads = useMemo(() => { - if (optimisticPinnedOrder === null) return pinnedThreads; - return orderItemsByPreferredIds({ - items: pinnedThreads, - preferredIds: optimisticPinnedOrder.order, - getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - }); - }, [optimisticPinnedOrder, pinnedThreads]); + const [dragTargetSection, setDragTargetSection] = useState(null); + const sectionByThreadKey = useMemo(() => { + const map = new Map(); + const add = (list: readonly EnvironmentThreadShell[], section: SidebarSection) => { + for (const thread of list) { + map.set(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), section); + } + }; + add(pinnedThreads, "pinned"); + add(activeThreads, "active"); + add(snoozedThreads, "snoozed"); + add(settledThreads, "settled"); + return map; + }, [activeThreads, pinnedThreads, settledThreads, snoozedThreads]); + const pinnedKeys = useMemo( + () => + pinnedThreads.map((thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ), + [pinnedThreads], + ); + const activeKeys = useMemo( + () => + activeThreads.map((thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ), + [activeThreads], + ); useEffect(() => { - if (optimisticPinnedOrder === null) return; - const canonical = pinnedThreads.filter((thread) => - reorderablePinnedKeys.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), + if (optimisticDrop === null) return; + const canonicalByKey = new Map( + threads.map((thread) => [ + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + thread, + ]), ); - const canonicalKeys = canonical.map((thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + const thread = canonicalByKey.get(optimisticDrop.key); + if (thread === undefined || thread.archivedAt !== null) { + setOptimisticDrop(null); + return; + } + const canonicalSection = effectiveSnoozed(thread, { now: new Date().toISOString() }) + ? "snoozed" + : thread.settledOverride === "settled" + ? "settled" + : thread.pinnedAt != null + ? "pinned" + : "active"; + if ( + canonicalSection !== optimisticDrop.sourceSection && + canonicalSection !== optimisticDrop.section + ) { + setOptimisticDrop(null); + return; + } + if (optimisticDrop.order === null) { + // Settle also emits unpin/unsnooze events. Wait for the entire move + // before releasing the projected fields and sort timestamps. + if ( + canonicalSection === optimisticDrop.section && + thread.pinnedAt == null && + (!optimisticDrop.clearsSnooze || thread.snoozedUntil == null) + ) { + setOptimisticDrop(null); + } + return; + } + if (canonicalSection !== optimisticDrop.section) return; + if (optimisticDrop.clearsSnooze && thread.snoozedUntil != null) return; + const destinationKeys = optimisticDrop.section === "pinned" ? pinnedKeys : activeKeys; + const canonicalDestination = destinationKeys.flatMap((key) => { + const canonical = canonicalByKey.get(key); + return canonical === undefined ? [] : [canonical]; + }); + const keyByThread = new Map( + canonicalDestination.map((thread) => [ + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + (optimisticDrop.section === "pinned" ? thread.pinOrderKey : thread.activeOrderKey) ?? null, + ]), ); - // 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 heldOrder = optimisticDrop.order; + const heldKeys = new Set(heldOrder); 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); + destinationKeys.length !== heldOrder.length || + destinationKeys.some((key) => !heldKeys.has(key)); + const foreignKeyLanded = destinationKeys.some((threadKey) => { + const currentKey = keyByThread.get(threadKey) ?? null; + if (currentKey === (optimisticDrop.keysAtDrop.get(threadKey) ?? null)) return false; + return currentKey !== optimisticDrop.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 allAssignmentsLanded = [...optimisticDrop.assignedKeys].every( + ([threadKey, orderKey]) => keyByThread.get(threadKey) === orderKey, ); - const orderConfirmed = - !membershipChanged && - canonicalKeys.every((key, index) => key === optimisticPinnedOrder.order[index]); - if (membershipChanged || foreignKeyLanded || allAssignmentsLanded || orderConfirmed) { - setOptimisticPinnedOrder(null); + if (membershipChanged || foreignKeyLanded || allAssignmentsLanded) { + setOptimisticDrop(null); } - }, [optimisticPinnedOrder, pinnedThreads, reorderablePinnedKeys]); + }, [activeKeys, optimisticDrop, pinnedKeys, threads]); const attemptPin = useCallback( (threadRef: ScopedThreadRef) => { void (async () => { @@ -2827,71 +3110,338 @@ export default function Sidebar() { [confirmAndUnpinThread], ); - const handlePinnedDragEnd = useCallback( - (event: DragEndEvent) => { + const handleThreadDragStart = useCallback( + (event: DragStartEvent) => { 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, + const activeSection = sectionByThreadKey.get(activeKey); + if (activeSection === undefined) return; + // Stop normal section motion before dnd-kit measures the picked-up row. + listMotionRef.current?.suspend(); + setDragState({ + activeKey, + activeSection, + occurredAt: new Date().toISOString(), + activationY: + event.activatorEvent instanceof PointerEvent ? event.activatorEvent.clientY : null, }); - if (assignments.length === 0) return; - setOptimisticPinnedOrder({ - order: newOrder, - keysAtDrop, - assignedKeys: new Map( - assignments.map((assignment) => [assignment.id, assignment.orderKey]), - ), + setDragTargetSection(activeSection); + }, + [sectionByThreadKey], + ); + const handleThreadDragCancel = useCallback(() => { + listMotionRef.current?.suspend(); + setDragState(null); + setDragTargetSection(null); + }, []); + // Include every visible row in the measured order. Older servers disable + // pickup on their rows without changing where those rows render. + const sidebarListItems = useMemo((): readonly SidebarListItem[] => { + const rowsOf = ( + list: readonly EnvironmentThreadShell[], + section: SidebarSection, + ): SidebarListItem[] => + list.map((thread) => { + const key = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + return { kind: "thread", key, section }; + }); + if ( + pinnedThreads.length + + activeThreads.length + + snoozedThreads.length + + settledThreads.length === + 0 + ) { + return []; + } + const items: SidebarListItem[] = [{ kind: "marker", marker: "pinned-header" }]; + const pinnedRows = rowsOf(pinnedThreads, "pinned"); + items.push(...pinnedRows); + items.push({ kind: "marker", marker: "pinned-divider" }); + const activeRows = rowsOf(activeThreads, "active"); + if (activeRows.length === 0) { + items.push({ kind: "marker", marker: "active-placeholder" }); + } + items.push(...activeRows); + if (snoozedThreads.length > 0) { + items.push({ kind: "marker", marker: "snoozed-header" }); + items.push(...rowsOf(visibleSnoozedThreads, "snoozed")); + } + items.push({ kind: "marker", marker: "settled-header" }); + const settledRows = rowsOf(renderedSettledThreads, "settled"); + if (settledRows.length === 0) { + items.push({ kind: "marker", marker: "settled-placeholder" }); + } + items.push(...settledRows); + return items; + }, [ + activeThreads, + pinnedThreads, + renderedSettledThreads, + settledThreads.length, + snoozedThreads.length, + visibleSnoozedThreads, + ]); + const listMotionPaused = dragState !== null; + useLayoutEffect(() => { + // Drag release clears the baseline, so its commit cannot replay the + // sortable preview. Later thread actions can animate while writes settle. + // Draft navigation can reveal a frozen row without changing the draft count. + listMotionRef.current?.update( + !listMotionPaused && sidebarListItems.length + visibleDraftSessionCount > 0, + ); + }, [listMotionPaused, routeDraftIdForRows, sidebarListItems, visibleDraftSessionCount]); + const handleThreadDragOver = useCallback( + (event: DragOverEvent) => { + const target = event.over + ? resolveSidebarDropTarget(sidebarListItems, String(event.active.id), String(event.over.id)) + : null; + setDragTargetSection(target?.section ?? null); + }, + [sidebarListItems], + ); + const sortableIds = useMemo(() => sidebarListItems.map(sidebarListItemId), [sidebarListItems]); + const draggedSettledOrder = useMemo(() => { + const thread = dragState === null ? undefined : threadByKey.get(dragState.activeKey); + if (dragState === null || thread === undefined) return []; + const key = (candidate: EnvironmentThreadShell) => + scopedThreadKey(scopeThreadRef(candidate.environmentId, candidate.id)); + return sortSettledThreadsForSidebar([ + ...settledThreads.filter((candidate) => key(candidate) !== dragState.activeKey), + applySidebarThreadDrop(thread, "settled", dragState.occurredAt), + ]).map(key); + }, [dragState, settledThreads, threadByKey]); + const sidebarSortingStrategy = useMemo( + () => + createSidebarSortingStrategy({ + items: sidebarListItems, + settledOrder: draggedSettledOrder, + settledExpanded: settledShelfExpanded, + settledVisibleCount, + routeThreadKey, + snoozedThreadCount: snoozedThreads.length, + }), + [ + draggedSettledOrder, + routeThreadKey, + settledShelfExpanded, + settledVisibleCount, + sidebarListItems, + snoozedThreads.length, + ], + ); + // Hidden and filtered threads keep their keys. Reserve those slots without + // including the rows in the visible drop order or writing to them. + const { pinnedKeysById, activeKeysById } = useMemo( + () => ({ + pinnedKeysById: new Map( + threads.map((thread) => [ + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + thread.pinOrderKey ?? null, + ]), + ), + activeKeysById: new Map( + threads.map((thread) => [ + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + thread.activeOrderKey ?? null, + ]), + ), + }), + [threads], + ); + const dndCollisionDetection = useMemo(() => { + if (dragState === null) return createSidebarCollisionDetection(() => true); + const source = threadByKey.get(dragState.activeKey); + if (source === undefined) return createSidebarCollisionDetection(() => false); + return createSidebarCollisionDetection( + (id) => { + const target = resolveSidebarDropTarget(sidebarListItems, dragState.activeKey, id); + if (target === null) return false; + return ( + planSidebarThreadDrop({ + activeKey: dragState.activeKey, + activeSection: dragState.activeSection, + activePinned: source.pinnedAt != null, + activeSettled: source.settledOverride === "settled", + supportsSettlement: + serverConfigs.get(source.environmentId)?.environment.capabilities.threadSettlement === + true, + target, + pinnedOrder: pinnedKeys, + pinnedKeysById, + reorderableKeys: draggableThreadKeys, + activeOrder: activeKeys, + activeKeysById, + activeReorderableKeys: activeReorderableThreadKeys, + }).kind !== "none" + ); + }, + { emptyPins: pinnedKeys.length === 0, activationY: dragState.activationY }, + ); + }, [ + activeKeysById, + pinnedKeysById, + serverConfigs, + activeKeys, + activeReorderableThreadKeys, + dragState, + draggableThreadKeys, + pinnedKeys, + sidebarListItems, + threadByKey, + ]); + const handleThreadDragEnd = useCallback( + (event: DragEndEvent) => { + listMotionRef.current?.suspend(); + setDragState(null); + setDragTargetSection(null); + const activeKey = String(event.active.id); + const activeSection = sectionByThreadKey.get(activeKey); + const target = + event.over === null + ? null + : resolveSidebarDropTarget(sidebarListItems, activeKey, String(event.over.id)); + const activeThread = threadByKey.get(activeKey); + if (activeSection === undefined || target === null || activeThread === undefined) return; + const threadRef = scopeThreadRef(activeThread.environmentId, activeThread.id); + const plan = planSidebarThreadDrop({ + activeKey, + activeSection, + activePinned: activeThread.pinnedAt != null, + activeSettled: activeThread.settledOverride === "settled", + supportsSettlement: + serverConfigs.get(activeThread.environmentId)?.environment.capabilities + .threadSettlement === true, + target, + pinnedOrder: pinnedKeys, + pinnedKeysById, + reorderableKeys: draggableThreadKeys, + activeOrder: activeKeys, + activeKeysById, + activeReorderableKeys: activeReorderableThreadKeys, }); + if (plan.kind === "none") return; + if (plan.kind === "settle" && settlingThreadKeysRef.current.has(activeKey)) return; + const assignments = + plan.kind === "pin" + ? [ + ...(plan.orderKey === undefined ? [] : [{ id: activeKey, orderKey: plan.orderKey }]), + ...plan.extraAssignments, + ] + : plan.kind === "reorder-pinned" || plan.kind === "move-active" + ? plan.assignments + : []; + const drop = { + key: activeKey, + sourceSection: activeSection, + section: target.section, + occurredAt: new Date().toISOString(), + clearsSnooze: + plan.kind === "pin" || + plan.kind === "settle" || + (plan.kind === "move-active" && plan.unsnooze), + order: plan.kind === "settle" ? null : plan.order, + keysAtDrop: target.section === "active" ? activeKeysById : pinnedKeysById, + assignedKeys: new Map(assignments.map(({ id, orderKey }) => [id, orderKey])), + }; + setOptimisticDrop(drop); 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 run = async ( + operation: Promise>, + title: string, + ) => { + const result = await operation; + if (result._tag === "Success") return true; + // A late failure must not cancel a newer drag's preview. + setOptimisticDrop((current) => (current === drop ? null : current)); + if (!isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); toastManager.add( stackedThreadToast({ type: "error", - title: "Failed to reorder pinned threads", + title, description: error instanceof Error ? error.message : "An error occurred.", }), ); + } + return false; + }; + switch (plan.kind) { + case "settle": { + settlingThreadKeysRef.current.add(activeKey); + const navigateAfterSettle = planForwardNavigation(activeKey); + const settled = await run(settleThread(threadRef), "Failed to settle thread").finally( + () => settlingThreadKeysRef.current.delete(activeKey), + ); + if (settled && routeThreadKeyRef.current === activeKey) navigateAfterSettle?.(); return; } + case "move-active": + // The drag expresses unpin intent; button/menu confirmation is unchanged. + if (plan.unpin && !(await run(unpinThread(threadRef), "Failed to unpin thread"))) + return; + if ( + plan.unsettle && + !(await run(unsettleThread(threadRef), "Failed to un-settle thread")) + ) + return; + if (plan.unsnooze && !(await run(unsnoozeThread(threadRef), "Failed to wake thread"))) + return; + break; + case "pin": + if ( + !(await run( + pinThread( + threadRef, + plan.orderKey === undefined ? {} : { orderKey: plan.orderKey }, + ), + "Failed to pin thread", + )) + ) + return; + break; + case "reorder-pinned": + break; + } + // Stop on failure; each successful key write remains a valid placement. + const keyWrites = plan.kind === "pin" ? plan.extraAssignments : plan.assignments; + for (const assignment of keyWrites) { + const thread = threadByKey.get(assignment.id); + if (thread === undefined) continue; + if ( + !(await run( + (plan.kind === "move-active" ? reorderActiveThread : reorderPinnedThread)( + scopeThreadRef(thread.environmentId, thread.id), + assignment.orderKey, + ), + plan.kind === "move-active" + ? "Failed to reorder active threads" + : "Failed to reorder pinned threads", + )) + ) + return; } })(); }, - [orderedPinnedThreads, reorderPinnedThread, reorderablePinnedKeys], + [ + activeKeysById, + pinnedKeysById, + serverConfigs, + activeKeys, + activeReorderableThreadKeys, + draggableThreadKeys, + pinThread, + pinnedKeys, + planForwardNavigation, + reorderPinnedThread, + reorderActiveThread, + sectionByThreadKey, + settleThread, + sidebarListItems, + threadByKey, + unpinThread, + unsettleThread, + unsnoozeThread, + ], ); // One snooze per thread at a time — same double-dispatch guard as settle. const snoozingThreadKeysRef = useRef(new Set()); @@ -3535,11 +4085,6 @@ export default function Sidebar() { updateThreadJumpHintsVisibility(shouldShowJumpHintsNow); }, [shouldShowJumpHintsNow, updateThreadJumpHintsVisibility]); - const attachListAutoAnimateRef = useCallback((node: HTMLUListElement | null) => { - if (!node) return; - autoAnimate(node, { duration: 150, easing: "ease-out" }); - }, []); - // New thread defaults to the project you're in (active thread's project, // falling back to the top project) — same resolution the command palette // uses. The command palette already offers a "New thread in..." submenu @@ -3929,281 +4474,298 @@ export default function Sidebar() { closeDelay={0} timeout={400} > -
      - {(() => { - 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} + + +
        + {(() => { + const renderThreadRowInner = ( + thread: EnvironmentThreadShell, + section: SidebarSection, + sortable?: SortableThreadRowBag, + ) => { + 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 ( + + ); + }; + const renderThreadRow = ( + thread: EnvironmentThreadShell, + section: SidebarSection, + ) => { + const threadKey = scopedThreadKey( + scopeThreadRef(thread.environmentId, thread.id), + ); + return ( + -
          - {orderedPinnedThreads.map((thread) => { - const threadKey = scopedThreadKey( - scopeThreadRef(thread.environmentId, thread.id), - ); - if (!reorderablePinnedKeys.has(threadKey)) { - return renderThreadRow(thread, "pinned"); + {(bag) => renderThreadRowInner(thread, section, bag)} + + ); + }; + const from = dragState?.activeSection ?? null; + const previewPinnedCount = + pinnedThreads.length + + (from !== "pinned" && dragTargetSection === "pinned" ? 1 : 0) - + (from === "pinned" && + dragTargetSection !== null && + dragTargetSection !== "pinned" + ? 1 + : 0); + const activeHint = + from === "pinned" + ? "Drop to unpin" + : from === "settled" + ? "Drop to un-settle" + : from === "snoozed" + ? "Drop to wake" + : null; + const items: ReactNode[] = [ + , + ]; + for (const item of sidebarListItems) { + if (item.kind === "thread") { + items.push(renderThreadRow(threadByKey.get(item.key)!, item.section)); + continue; + } + switch (item.marker) { + case "pinned-header": + items.push( + , + ); + break; + case "pinned-divider": + items.push( + 0} + />, + ); + break; + case "active-placeholder": + items.push( + , + ); + break; + case "snoozed-header": + items.push( + - {(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( -
      • + toggle={{ + expanded: snoozedShelfExpanded, + onToggle: toggleSnoozedShelf, + }} + />, + ); + break; + case "settled-header": + items.push( + , + ); + break; + case "settled-placeholder": + items.push( + , + ); + break; + } + } + return items; + })()} + {settledShelfExpanded && hiddenSettledCount > 0 ? ( +
      • -
      • , - ); - } - for (const thread of renderedSettledThreads) { - items.push(renderThreadRow(thread, "settled")); - } - return items; - })()} - {settledShelfExpanded && hiddenSettledCount > 0 ? ( -
      • - -
      • - ) : null} -
      +
    • + ) : null} +
    + + ) : null} {!isSearchingThreads && diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 9b162bc8cce3..cefe81a7fa09 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -25,6 +25,7 @@ import { readLocalApi } from "../localApi"; import { readEnvironmentSupportsPinning, readEnvironmentSupportsPinReorder, + readEnvironmentSupportsActiveReorder, readEnvironmentSupportsSettlement, readEnvironmentSupportsSnooze, readEnvironmentThreadRefs, @@ -124,6 +125,18 @@ export class ThreadPinReorderUnsupportedError extends Schema.TaggedErrorClass()( + "ThreadActiveReorderUnsupportedError", + { + environmentId: EnvironmentId, + threadId: ThreadId, + }, +) { + override get message(): string { + return "Update this environment's server to reorder active threads."; + } +} + export async function requestThreadUnpinConfirmation(input: { enabled: boolean; title: string; @@ -185,6 +198,9 @@ export function useThreadActions() { const reorderPinnedThreadMutation = useAtomCommand(threadEnvironment.reorderPin, { reportFailure: false, }); + const reorderActiveThreadMutation = useAtomCommand(threadEnvironment.reorderActive, { + reportFailure: false, + }); const snoozeThreadMutation = useAtomCommand(threadEnvironment.snooze, { reportFailure: false, }); @@ -631,6 +647,26 @@ export function useThreadActions() { [reorderPinnedThreadMutation], ); + const reorderActiveThread = useCallback( + async (target: ScopedThreadRef, orderKey: string) => { + if (!readEnvironmentSupportsActiveReorder(target.environmentId)) { + return AsyncResult.failure( + Cause.fail( + new ThreadActiveReorderUnsupportedError({ + environmentId: target.environmentId, + threadId: target.threadId, + }), + ), + ); + } + return reorderActiveThreadMutation({ + environmentId: target.environmentId, + input: { threadId: target.threadId, orderKey }, + }); + }, + [reorderActiveThreadMutation], + ); + const snoozeThread = useCallback( async (target: ScopedThreadRef, snoozedUntil: string) => { // Version skew: never send the command to a server that predates it. @@ -729,6 +765,7 @@ export function useThreadActions() { unpinThread, confirmAndUnpinThread, reorderPinnedThread, + reorderActiveThread, }), [ archiveThread, @@ -737,6 +774,7 @@ export function useThreadActions() { deleteThread, pinThread, reorderPinnedThread, + reorderActiveThread, settleThread, snoozeThread, unarchiveThread, diff --git a/apps/web/src/lib/threadSort.ts b/apps/web/src/lib/threadSort.ts index 2644ea67adec..7785bceaac73 100644 --- a/apps/web/src/lib/threadSort.ts +++ b/apps/web/src/lib/threadSort.ts @@ -1,5 +1,4 @@ export { - activeThreadAnchorTimestampMs, getLatestThreadForProject, getThreadSortTimestamp, resolveSettledThreadTimestamp, diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index deb4948a0f5a..d9610e20717f 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -229,6 +229,13 @@ export function readEnvironmentSupportsPinReorder(environmentId: EnvironmentId): ); } +export function readEnvironmentSupportsActiveReorder(environmentId: EnvironmentId): boolean { + return ( + appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities + .threadActiveReorder === true + ); +} + export function readEnvironmentThreadRefs( environmentId: EnvironmentId, ): ReadonlyArray { diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 0a2e7aebad76..5168041471d3 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -22,12 +22,40 @@ worktree**, each background submission creates its own worktree. ## Pin and reorder threads -Pin a thread from its menu to keep it above your active work. Drag pinned threads -to reorder them on web and desktop, or use **Move up** and **Move down** on mobile. -The order syncs across devices. +Pin a thread from its menu to keep it above your active work. Pinning does not prevent automatic settlement. Settling a thread removes its pin. +On web and desktop, drag a thread between sections to change its state. Drag a thread up into +the pinned section to pin it at the spot you drop it; drag a pinned thread down into the active +list to unpin it. Dragging a thread onto the **Settled** header settles it, and dragging a settled +thread into the active list un-settles it. A snoozed thread can be dragged out of the snoozed +shelf, which wakes it, but threads cannot be dragged into the shelf because snoozing needs a wake +time. Dragging a pinned thread out of the pinned section does not ask for unpin confirmation. +Pinned and active boundary labels appear only while dragging, without moving the rows. The +destination boundary highlights and the thread shows which section it will land in. When there +are no pins, drag to the top edge to pin a thread. Drop instructions also appear for empty sections +and a collapsed settled shelf. + +Drag within the pinned or active section to change its order. Other rows slide aside to show the +spot where the thread will land. Drops into either section keep the position you choose. On +mobile, open a pinned or active thread's menu and choose **Move up** or **Move down**. The server +saves the order, so it survives a refresh and appears on your other connected devices. + +On web and desktop, the list also animates section changes made with thread actions such as +**Pin**, **Settle**, and **Snooze**. These transitions respect your system's reduced-motion +preference. While dragging, rows follow the insertion gap without replaying a second transition +after the drop. + +New threads appear above the active threads you have arranged. Settling clears a thread's active +position, so using **Un-settle** returns it to the top. Pinning and snoozing preserve its active +position until you move it again. Thread activity does not change the order. The settled shelf +continues to use settlement time. + +If dragging is unavailable for one environment, update the T3 Code server running in that +environment. Pinned and active reordering require server support. Threads from older servers keep +their default order until the server is updated. + ## Settle finished work Choose **Settle thread** from its menu to move finished work out of the active list diff --git a/packages/client-runtime/src/state/threadSort.ts b/packages/client-runtime/src/state/threadSort.ts index f06c95919554..3a4a9d284a18 100644 --- a/packages/client-runtime/src/state/threadSort.ts +++ b/packages/client-runtime/src/state/threadSort.ts @@ -107,7 +107,7 @@ export function getThreadSortTimestamp( * top instead of sinking back to its creation-order slot. Shared by web and * mobile so both render the same order. Malformed timestamps sink to 0. */ -export function activeThreadAnchorTimestampMs(thread: { +function activeThreadAnchorTimestampMs(thread: { readonly createdAt: string; readonly unsettledAt?: string | null | undefined; }): number { From 9a47c7bd40522977c7df022a5b64759aae7b58e0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 6 Sep 2026 11:10:14 -0700 Subject: [PATCH 06/71] feat(web): simplify sidebar drag destination cues (#9750) --- apps/web/src/components/Sidebar.tsx | 95 +++++++++++++---------------- docs/user/thread-sidebar.md | 8 ++- 2 files changed, 47 insertions(+), 56 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 1a66dbdd9b5f..63c7deca4c5e 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -568,7 +568,6 @@ function SidebarSectionPlaceholder(props: { function SidebarDragBoundary(props: { marker: "pinned-header" | "pinned-divider"; label: string; - hint: string | null; visible: boolean; isDropTarget: boolean; }) { @@ -587,7 +586,6 @@ function SidebarDragBoundary(props: { )} > {props.label} - {props.hint ? {props.hint} : null} void }; }) { @@ -624,7 +621,6 @@ function SidebarSectionHeader(props: { props.isDropTarget && "bg-primary/30", )} /> - {props.hint ? {props.hint} : null} | null; // Compact wake countdown ("2h") for rows in the snoozed shelf. snoozeWakeLabelText: string | null; // When a snooze ended (timer or early wake); drives the Woke pill until @@ -1331,14 +1327,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { role="status" className="pointer-events-none ml-auto inline-flex h-5 shrink-0 items-center gap-1 rounded-sm border border-primary/30 bg-sidebar px-1.5 text-[11px] font-medium text-primary" > + Move to {props.dropSection === "pinned" ? "Pinned" : props.dropSection === "active" ? "Active" - : props.dropSection === "settled" - ? "Settled" - : "Snoozed"} + : "Settled"} ) : null; @@ -1442,31 +1437,32 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { Unsent draft ) : null; - const pinIndicator = props.isPinned ? ( - props.pinningSupported ? ( - - - } - > - - - Unpin thread - - ) : ( - - ) - ) : null; + const pinIndicator = + props.isPinned && !sortable?.isDragging ? ( + props.pinningSupported ? ( + + + } + > + + + Unpin thread + + ) : ( + + ) + ) : null; if (variant === "slim") { return ( @@ -1526,7 +1522,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { remain visible AND clickable while the row is hovered. Only the time/jump label yields to the settle affordance. */} {prBadge} - {dragDestination ?? ( + {sortable?.isDragging ? ( + dragDestination + ) : (