From 7502dbd0187f2a3c9dfd508feeedccea81d2d992 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 7 Aug 2026 00:37:52 -0700 Subject: [PATCH 1/5] feat(web): drag-to-reorder pinned threads in v2 sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pinned threads in the v2 sidebar can now be dragged into a custom order. Each pinned thread carries an optional fractional-index pinOrderKey stored on its own server, so a drag writes one key to one thread — the merged pinned list stays consistent across clients and across multiple connected servers without any server seeing the full list. - contracts: thread.pin.reorder command, thread.pin-reordered event, optional orderKey on thread.pin, pinOrderKey on thread shells, and a threadPinReorder capability flag for version skew - server: decider case (rejects unpinned, idempotent re-emission), projection column + migration 038, key cleared on unpin/settle - web: dnd-kit sortable on the pinned block (same pattern as v1 project reordering), optimistic order until the event round-trips, keyed threads sort first with keyless/legacy-server pins in creation order below - mobile: read-side sort by the same keys so both platforms render the order arranged on web Co-Authored-By: Claude Fable 5 --- .../src/features/threads/threadListV2.ts | 33 +- .../src/environment/ServerEnvironment.ts | 1 + .../Layers/ProjectionPipeline.ts | 20 + .../Layers/ProjectionSnapshotQuery.test.ts | 2 + .../Layers/ProjectionSnapshotQuery.ts | 10 + apps/server/src/orchestration/Schemas.ts | 2 + .../src/orchestration/decider.pinned.test.ts | 98 +++++ apps/server/src/orchestration/decider.ts | 43 ++ apps/server/src/orchestration/projector.ts | 16 + .../persistence/Layers/ProjectionThreads.ts | 5 + apps/server/src/persistence/Migrations.ts | 2 + .../038_ProjectionThreadsPinOrderKey.ts | 16 + .../persistence/Services/ProjectionThreads.ts | 1 + apps/web/src/components/Sidebar.logic.test.ts | 133 +++++++ apps/web/src/components/Sidebar.logic.ts | 136 +++++++ apps/web/src/components/SidebarV2.tsx | 376 ++++++++++++++---- apps/web/src/hooks/useThreadActions.ts | 42 +- apps/web/src/state/entities.ts | 9 + .../client-runtime/src/operations/commands.ts | 11 + .../src/state/threadCommands.ts | 9 + .../client-runtime/src/state/threadDetail.ts | 1 + .../client-runtime/src/state/threadReducer.ts | 14 + packages/contracts/src/environment.ts | 3 + packages/contracts/src/orchestration.ts | 38 ++ 24 files changed, 936 insertions(+), 85 deletions(-) create mode 100644 apps/server/src/persistence/Migrations/038_ProjectionThreadsPinOrderKey.ts diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index fa5f58d5d0e..fb80c1a26c3 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -175,6 +175,33 @@ export function sortThreadsForListV2(threads: readonly T[]): T[] { + const keyed: T[] = []; + const keyless: T[] = []; + for (const thread of threads) { + (thread.pinOrderKey != null ? keyed : keyless).push(thread); + } + keyed.sort((left, right) => { + const leftKey = left.pinOrderKey!; + const rightKey = right.pinOrderKey!; + return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : left.id.localeCompare(right.id); + }); + return [...keyed, ...sortThreadsForListV2(keyless)]; +} + export interface ThreadListV2Item { readonly thread: EnvironmentThreadShell; readonly variant: "card" | "slim"; @@ -383,8 +410,8 @@ export function buildThreadListV2Items(input: { input.changeRequestStateByKey?.get(`${thread.environmentId}:${thread.id}`) ?? null; // Visibility parity with web: snooze outranks everything, including a // pin — a snoozed thread leaves the list until it wakes (or raises its - // hand). The pin survives underneath, so a woken thread reappears at - // its original spot in the creation-ordered pinned block. + // hand). The pin (and its pinOrderKey) survives underneath, so a woken + // thread reappears at its exact spot in the pinned block. if (supportsSnooze && effectiveSnoozed(thread, { now: snoozeNow })) { snoozed.push(thread); if ( @@ -444,7 +471,7 @@ export function buildThreadListV2Items(input: { ); const items: ThreadListV2Item[] = []; - for (const thread of sortThreadsForListV2(pinned)) { + for (const thread of sortPinnedThreadsForListV2(pinned)) { items.push({ thread, variant: "card", diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index b6eedb87e66..c697b4bd98f 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -146,6 +146,7 @@ export const make = Effect.gen(function* () { threadSettlement: true, threadSnooze: true, threadPinning: true, + threadPinReorder: true, threadTitleRegeneration: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), ...(serverSelfUpdate === "boot-service" ? { serverSelfUpdateProgress: true } : {}), diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 7776e374ee2..38a70240d97 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -612,6 +612,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti snoozedUntil: null, snoozedAt: null, pinnedAt: null, + pinOrderKey: null, titleRegenerationRequestId: null, titleRegenerationStartedAt: null, latestUserMessageAt: null, @@ -728,6 +729,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti yield* projectionThreadRepository.upsert({ ...existingRow.value, pinnedAt: event.payload.pinnedAt, + ...(event.payload.pinOrderKey !== undefined + ? { pinOrderKey: event.payload.pinOrderKey } + : {}), updatedAt: event.payload.updatedAt, }); return; @@ -743,6 +747,22 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti yield* projectionThreadRepository.upsert({ ...existingRow.value, pinnedAt: null, + pinOrderKey: null, + updatedAt: event.payload.updatedAt, + }); + return; + } + + case "thread.pin-reordered": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + pinOrderKey: event.payload.orderKey, updatedAt: event.payload.updatedAt, }); return; diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index d5dda7aa86b..0e328cd955a 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -318,6 +318,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { snoozedUntil: null, snoozedAt: null, pinnedAt: null, + pinOrderKey: null, titleRegeneration: null, deletedAt: null, messages: [ @@ -434,6 +435,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { snoozedUntil: null, snoozedAt: null, pinnedAt: null, + pinOrderKey: null, titleRegeneration: null, session: { threadId: ThreadId.make("thread-1"), diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 9633f162d2b..e744574a73c 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -423,6 +423,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", + pin_order_key AS "pinOrderKey", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", @@ -458,6 +459,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", + pin_order_key AS "pinOrderKey", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", @@ -495,6 +497,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", + pin_order_key AS "pinOrderKey", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", @@ -932,6 +935,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", + pin_order_key AS "pinOrderKey", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", @@ -1562,6 +1566,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, pinnedAt: row.pinnedAt, + pinOrderKey: row.pinOrderKey ?? null, titleRegeneration: mapTitleRegeneration(row), deletedAt: row.deletedAt, messages: messagesByThread.get(row.threadId) ?? [], @@ -1766,6 +1771,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, pinnedAt: row.pinnedAt, + pinOrderKey: row.pinOrderKey ?? null, titleRegeneration: mapTitleRegeneration(row), deletedAt: row.deletedAt, messages: [], @@ -1901,6 +1907,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, pinnedAt: row.pinnedAt, + pinOrderKey: row.pinOrderKey ?? null, titleRegeneration: mapTitleRegeneration(row), session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, @@ -2045,6 +2052,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, pinnedAt: row.pinnedAt, + pinOrderKey: row.pinOrderKey ?? null, titleRegeneration: mapTitleRegeneration(row), session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, @@ -2321,6 +2329,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozedUntil: threadRow.value.snoozedUntil, snoozedAt: threadRow.value.snoozedAt, pinnedAt: threadRow.value.pinnedAt, + pinOrderKey: threadRow.value.pinOrderKey ?? null, titleRegeneration: mapTitleRegeneration(threadRow.value), session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null, latestUserMessageAt: threadRow.value.latestUserMessageAt, @@ -2441,6 +2450,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozedUntil: threadRow.value.snoozedUntil, snoozedAt: threadRow.value.snoozedAt, pinnedAt: threadRow.value.pinnedAt, + pinOrderKey: threadRow.value.pinOrderKey ?? null, titleRegeneration: mapTitleRegeneration(threadRow.value), deletedAt: null, messages: messageRows.map((row) => { diff --git a/apps/server/src/orchestration/Schemas.ts b/apps/server/src/orchestration/Schemas.ts index ee96e422945..7e866cf8959 100644 --- a/apps/server/src/orchestration/Schemas.ts +++ b/apps/server/src/orchestration/Schemas.ts @@ -15,6 +15,7 @@ import { ThreadUnsnoozedPayload as ContractsThreadUnsnoozedPayloadSchema, ThreadPinnedPayload as ContractsThreadPinnedPayloadSchema, ThreadUnpinnedPayload as ContractsThreadUnpinnedPayloadSchema, + ThreadPinReorderedPayload as ContractsThreadPinReorderedPayloadSchema, ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema, ThreadProposedPlanUpsertedPayload as ContractsThreadProposedPlanUpsertedPayloadSchema, ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema, @@ -46,6 +47,7 @@ export const ThreadSnoozedPayload = ContractsThreadSnoozedPayloadSchema; export const ThreadUnsnoozedPayload = ContractsThreadUnsnoozedPayloadSchema; export const ThreadPinnedPayload = ContractsThreadPinnedPayloadSchema; export const ThreadUnpinnedPayload = ContractsThreadUnpinnedPayloadSchema; +export const ThreadPinReorderedPayload = ContractsThreadPinReorderedPayloadSchema; export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema; export const ThreadProposedPlanUpsertedPayload = ContractsThreadProposedPlanUpsertedPayloadSchema; diff --git a/apps/server/src/orchestration/decider.pinned.test.ts b/apps/server/src/orchestration/decider.pinned.test.ts index bed41e13a17..4ad00ba994b 100644 --- a/apps/server/src/orchestration/decider.pinned.test.ts +++ b/apps/server/src/orchestration/decider.pinned.test.ts @@ -16,6 +16,7 @@ const PINNED_AT = "1969-12-30T00:00:00.000Z"; function makeReadModel(input: { readonly pinnedAt?: string | null; + readonly pinOrderKey?: string | null; readonly archivedAt?: string | null; readonly settledOverride?: "settled" | "active" | null; readonly settledAt?: string | null; @@ -44,6 +45,7 @@ function makeReadModel(input: { snoozedUntil: input.snoozedUntil ?? null, snoozedAt: input.snoozedAt ?? (input.snoozedUntil != null ? PINNED_AT : null), pinnedAt: input.pinnedAt ?? null, + pinOrderKey: input.pinOrderKey ?? null, deletedAt: null, messages: [], proposedPlans: [], @@ -223,4 +225,100 @@ it.layer(NodeServices.layer)("pinned thread decider", (it) => { expect(error._tag).toBe("OrchestrationCommandInvariantError"); }), ); + + it.effect("a fresh pin carries the client's order key", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.pin", + commandId: CommandId.make("cmd-pin-keyed"), + threadId: ThreadId.make("thread-1"), + orderKey: "g", + }, + readModel: makeReadModel({}), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events[0]?.type).toBe("thread.pinned"); + if (events[0]?.type === "thread.pinned") { + expect(events[0].payload.pinOrderKey).toBe("g"); + } + }), + ); + + it.effect( + "re-pinning ignores the incoming order key so raced pins cannot move a placed thread", + () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.pin", + commandId: CommandId.make("cmd-pin-keyed-again"), + threadId: ThreadId.make("thread-1"), + orderKey: "t", + }, + readModel: makeReadModel({ pinnedAt: PINNED_AT, pinOrderKey: "g" }), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events[0]?.type).toBe("thread.pinned"); + if (events[0]?.type === "thread.pinned") { + expect(events[0].payload.pinOrderKey).toBeUndefined(); + } + }), + ); + + it.effect("reorders a pinned thread, stamping the new key", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.pin.reorder", + commandId: CommandId.make("cmd-reorder"), + threadId: ThreadId.make("thread-1"), + orderKey: "m", + }, + readModel: makeReadModel({ pinnedAt: PINNED_AT, pinOrderKey: "g" }), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events[0]?.type).toBe("thread.pin-reordered"); + if (events[0]?.type === "thread.pin-reordered") { + expect(events[0].payload.orderKey).toBe("m"); + // A real move stamps the command time (the test clock), not the + // thread's previous updatedAt. + expect(events[0].payload.updatedAt).not.toBe(NOW); + } + }), + ); + + it.effect("reordering onto the same key preserves updatedAt", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.pin.reorder", + commandId: CommandId.make("cmd-reorder-noop"), + threadId: ThreadId.make("thread-1"), + orderKey: "g", + }, + readModel: makeReadModel({ pinnedAt: PINNED_AT, pinOrderKey: "g" }), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events[0]?.type).toBe("thread.pin-reordered"); + if (events[0]?.type === "thread.pin-reordered") { + expect(events[0].payload.updatedAt).toBe(NOW); + } + }), + ); + + it.effect("rejects reordering an unpinned thread", () => + Effect.gen(function* () { + const error = yield* decideOrchestrationCommand({ + command: { + type: "thread.pin.reorder", + commandId: CommandId.make("cmd-reorder-unpinned"), + threadId: ThreadId.make("thread-1"), + orderKey: "m", + }, + readModel: makeReadModel({}), + }).pipe(Effect.flip); + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); }); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 5e5579ae93d..3de2592c884 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -676,6 +676,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" payload: { threadId: command.threadId, pinnedAt: existingPinnedAt ?? occurredAt, + // A fresh pin takes the client's slot in the arranged order; on a + // re-pin the existing key wins so raced duplicates cannot move a + // thread the user already placed. + ...(existingPinnedAt === null && command.orderKey !== undefined + ? { pinOrderKey: command.orderKey } + : {}), updatedAt: existingPinnedAt !== null ? thread.updatedAt : occurredAt, }, }; @@ -745,6 +751,43 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.pin.reorder": { + const thread = yield* requireThreadNotArchived({ + readModel, + command, + threadId: command.threadId, + }); + // Only pinned threads have a slot in the arranged order. Rejecting + // (rather than silently pinning) keeps a raced reorder-after-unpin + // from resurrecting a pin the user just cleared. + if (thread.pinnedAt == null) { + return yield* Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} is not pinned and cannot be reordered`, + }), + ); + } + // Idempotent by re-emission (see thread.settle): a duplicate drop on + // the same slot keeps the existing updatedAt so it projects as a no-op. + const keyUnchanged = thread.pinOrderKey === command.orderKey; + const occurredAt = yield* nowIso; + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.pin-reordered", + payload: { + threadId: command.threadId, + orderKey: command.orderKey, + updatedAt: keyUnchanged ? thread.updatedAt : occurredAt, + }, + }; + } + case "thread.meta.update": { const thread = yield* requireThread({ readModel, diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index ed4b084e4f9..5acf3ee6968 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -24,6 +24,7 @@ import { ThreadRuntimeModeSetPayload, ThreadSettledPayload, ThreadPinnedPayload, + ThreadPinReorderedPayload, ThreadSnoozedPayload, ThreadUnpinnedPayload, ThreadUnarchivedPayload, @@ -402,6 +403,7 @@ export function projectEvent( ...nextBase, threads: updateThread(nextBase.threads, payload.threadId, { pinnedAt: payload.pinnedAt, + ...(payload.pinOrderKey !== undefined ? { pinOrderKey: payload.pinOrderKey } : {}), updatedAt: payload.updatedAt, }), })), @@ -413,6 +415,20 @@ export function projectEvent( ...nextBase, threads: updateThread(nextBase.threads, payload.threadId, { pinnedAt: null, + // Unpin clears the slot: re-pinning is "pin again", not "restore + // an ancient position". + pinOrderKey: null, + updatedAt: payload.updatedAt, + }), + })), + ); + + case "thread.pin-reordered": + return decodeForEvent(ThreadPinReorderedPayload, event.payload, event.type, "payload").pipe( + Effect.map((payload) => ({ + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + pinOrderKey: payload.orderKey, updatedAt: payload.updatedAt, }), })), diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 0e2adeecf3b..b7d8ae13747 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -48,6 +48,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { snoozed_until, snoozed_at, pinned_at, + pin_order_key, title_regeneration_request_id, title_regeneration_started_at, latest_user_message_at, @@ -74,6 +75,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.snoozedUntil}, ${row.snoozedAt}, ${row.pinnedAt}, + ${row.pinOrderKey ?? null}, ${row.titleRegenerationRequestId ?? null}, ${row.titleRegenerationStartedAt ?? null}, ${row.latestUserMessageAt}, @@ -100,6 +102,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { snoozed_until = excluded.snoozed_until, snoozed_at = excluded.snoozed_at, pinned_at = excluded.pinned_at, + pin_order_key = excluded.pin_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, @@ -133,6 +136,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", + pin_order_key AS "pinOrderKey", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", @@ -168,6 +172,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", + pin_order_key AS "pinOrderKey", 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 1f335bdfda7..733c52fab3e 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -50,6 +50,7 @@ import Migration0034 from "./Migrations/034_ProjectionThreadsSnoozed.ts"; import Migration0035 from "./Migrations/035_ProjectionThreadTitleRegeneration.ts"; import Migration0036 from "./Migrations/036_ProjectionThreadsPinned.ts"; import Migration0037 from "./Migrations/037_ProjectionTurnsKeysetIndex.ts"; +import Migration0038 from "./Migrations/038_ProjectionThreadsPinOrderKey.ts"; /** * Migration loader with all migrations defined inline. @@ -99,6 +100,7 @@ export const migrationEntries = [ [35, "ProjectionThreadTitleRegeneration", Migration0035], [36, "ProjectionThreadsPinned", Migration0036], [37, "ProjectionTurnsKeysetIndex", Migration0037], + [38, "ProjectionThreadsPinOrderKey", Migration0038], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/038_ProjectionThreadsPinOrderKey.ts b/apps/server/src/persistence/Migrations/038_ProjectionThreadsPinOrderKey.ts new file mode 100644 index 00000000000..d6735ebdbfb --- /dev/null +++ b/apps/server/src/persistence/Migrations/038_ProjectionThreadsPinOrderKey.ts @@ -0,0 +1,16 @@ +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 === "pin_order_key")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN pin_order_key TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index a0cee8e3298..c572e1d11cc 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -42,6 +42,7 @@ export const ProjectionThread = Schema.Struct({ snoozedUntil: Schema.NullOr(IsoDateTime), snoozedAt: Schema.NullOr(IsoDateTime), pinnedAt: Schema.NullOr(IsoDateTime), + pinOrderKey: 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/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index ac2716a196e..d15433e56b9 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -26,6 +26,9 @@ import { shouldClearThreadSelectionOnMouseDown, sortLogicalProjectsForSidebar, sortSettledThreadsForSidebarV2, + pinOrderKeyBetween, + planPinnedReorder, + sortPinnedThreadsForSidebarV2, sortThreadsForSidebarV2, sortProjectsForSidebar, sortScopedProjectsForSidebar, @@ -738,6 +741,136 @@ describe("sortThreadsForSidebarV2", () => { }); }); +describe("pinOrderKeyBetween", () => { + it("produces keys that sort between their bounds", () => { + const middle = pinOrderKeyBetween(null, null)!; + const top = pinOrderKeyBetween(null, middle)!; + const bottom = pinOrderKeyBetween(middle, null)!; + expect(top < middle).toBe(true); + expect(middle < bottom).toBe(true); + + const between = pinOrderKeyBetween(top, middle)!; + expect(top < between && between < middle).toBe(true); + }); + + it("extends into new digits when bounds are adjacent", () => { + const key = pinOrderKeyBetween("g", "h")!; + expect("g" < key && key < "h").toBe(true); + }); + + it("stays strictly ordered under repeated top insertion", () => { + // Every new pin lands at the head of the arranged run; keys must keep + // sorting before the previous head without ever bottoming out. + let head: string | null = null; + const keys: string[] = []; + for (let i = 0; i < 100; i += 1) { + const key: string = pinOrderKeyBetween(null, head)!; + expect(key).not.toBeNull(); + if (head !== null) expect(key < head).toBe(true); + keys.push(key); + head = key; + } + expect(new Set(keys).size).toBe(100); + }); + + it("stays strictly ordered under repeated middle insertion", () => { + let low = pinOrderKeyBetween(null, null)!; + let high = pinOrderKeyBetween(low, null)!; + for (let i = 0; i < 100; i += 1) { + const key: string = pinOrderKeyBetween(low, high)!; + expect(low < key && key < high).toBe(true); + if (i % 2 === 0) low = key; + else high = key; + } + }); + + it("returns null for corrupt or out-of-order bounds instead of throwing", () => { + expect(pinOrderKeyBetween("z", "a")).toBeNull(); + expect(pinOrderKeyBetween("A!", null)).toBeNull(); + expect(pinOrderKeyBetween(null, "ma")).toBeNull(); + expect(pinOrderKeyBetween("m", "m")).toBeNull(); + }); +}); + +describe("planPinnedReorder", () => { + it("writes only the moved thread when neighbors are keyed", () => { + const assignments = planPinnedReorder({ + orderedIds: ["a", "c", "b"], + keysById: new Map([ + ["a", "f"], + ["b", "m"], + ["c", "t"], + ]), + movedId: "c", + }); + expect(assignments).toHaveLength(1); + expect(assignments[0]!.id).toBe("c"); + expect(assignments[0]!.orderKey > "f" && assignments[0]!.orderKey < "m").toBe(true); + }); + + it("treats list edges as open bounds", () => { + const assignments = planPinnedReorder({ + orderedIds: ["b", "a"], + keysById: new Map([ + ["a", "m"], + ["b", null], + ]), + movedId: "b", + }); + expect(assignments).toHaveLength(1); + expect(assignments[0]!.orderKey < "m").toBe(true); + }); + + it("materializes keys for the whole section when a neighbor is keyless", () => { + const assignments = planPinnedReorder({ + orderedIds: ["b", "a", "c"], + keysById: new Map([ + ["a", null], + ["b", "m"], + ["c", null], + ]), + movedId: "b", + }); + expect(assignments.map((entry) => entry.id)).toEqual(["b", "a", "c"]); + const keys = assignments.map((entry) => entry.orderKey); + expect([...keys].sort()).toEqual(keys); + expect(new Set(keys).size).toBe(keys.length); + }); +}); + +describe("sortPinnedThreadsForSidebarV2", () => { + const pinnable = (input: { id: string; createdAt: string; pinOrderKey?: string | null }) => ({ + id: input.id, + createdAt: input.createdAt, + pinOrderKey: input.pinOrderKey ?? null, + }); + + it("sorts keyed threads by key ahead of keyless threads in creation order", () => { + const sorted = sortPinnedThreadsForSidebarV2([ + pinnable({ id: "keyless-old", createdAt: "2026-03-09T08:00:00.000Z" }), + pinnable({ id: "second", createdAt: "2026-03-09T09:00:00.000Z", pinOrderKey: "t" }), + pinnable({ id: "keyless-new", createdAt: "2026-03-09T12:00:00.000Z" }), + pinnable({ id: "first", createdAt: "2026-03-09T07:00:00.000Z", pinOrderKey: "g" }), + ]); + + expect(sorted.map((thread) => thread.id)).toEqual([ + "first", + "second", + "keyless-new", + "keyless-old", + ]); + }); + + it("breaks equal keys by id so raced writes render identically everywhere", () => { + const sorted = sortPinnedThreadsForSidebarV2([ + pinnable({ id: "b", createdAt: "2026-03-09T10:00:00.000Z", pinOrderKey: "m" }), + pinnable({ id: "a", createdAt: "2026-03-09T11:00:00.000Z", pinOrderKey: "m" }), + ]); + + expect(sorted.map((thread) => thread.id)).toEqual(["a", "b"]); + }); +}); + describe("sortSettledThreadsForSidebarV2", () => { const settled = (input: { id: string; diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 677e85c3746..c6b38186730 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -510,6 +510,142 @@ export function sortThreadsForSidebarV2< ); } +// ── Pinned reorder: fractional index keys ────────────────────────────── +// Pinned threads carry an optional pinOrderKey (a base-26 string). The +// pinned block sorts keyed threads by plain string comparison, so a drag +// writes ONE key to ONE thread on that thread's own server — neighbors, +// possibly living on other servers, are never touched, and every client +// connected to the same servers converges on the same order. +const PIN_ORDER_DIGITS = "abcdefghijklmnopqrstuvwxyz"; + +function isValidPinOrderKey(key: string): boolean { + if (key.length === 0) return false; + for (const char of key) { + if (!PIN_ORDER_DIGITS.includes(char)) return false; + } + // A trailing minimum digit would leave no room to sort a key immediately + // before this one; generators never produce it, so treat it as corrupt. + return key.at(-1) !== PIN_ORDER_DIGITS[0]; +} + +/** Midpoint of two digit strings interpreted as fractions in (0, 1). + "" stands for the open bound on either side. Requires a < b. */ +function pinOrderMidpoint(a: string, b: string): string { + if (b !== "" && a >= b) throw new Error("pinOrderMidpoint: bounds out of order"); + if (b !== "") { + // Recurse past the longest common prefix ("a" pads the shorter side). + let n = 0; + while ((a.charAt(n) || PIN_ORDER_DIGITS[0]) === b.charAt(n)) n += 1; + if (n > 0) return b.slice(0, n) + pinOrderMidpoint(a.slice(n), b.slice(n)); + } + const digitA = a === "" ? 0 : PIN_ORDER_DIGITS.indexOf(a.charAt(0)); + const digitB = b === "" ? PIN_ORDER_DIGITS.length : PIN_ORDER_DIGITS.indexOf(b.charAt(0)); + if (digitB - digitA > 1) { + return PIN_ORDER_DIGITS.charAt(Math.round((digitA + digitB) / 2)); + } + // Consecutive leading digits: either b has spare digits to shorten into, + // or we extend a (never producing a trailing minimum digit — the base + // case midpoint("", "") is the middle of the alphabet). + if (b.length > 1) return b.charAt(0); + return PIN_ORDER_DIGITS.charAt(digitA) + pinOrderMidpoint(a.slice(1), ""); +} + +/** Key that sorts strictly between two neighbors; null bounds mean "top of + the pinned block" / "bottom of the keyed run". Returns null instead of + throwing when existing keys are corrupt or out of order — callers fall + back to rewriting the section. */ +export function pinOrderKeyBetween(before: string | null, after: string | null): string | null { + const a = before ?? ""; + const b = after ?? ""; + if (a !== "" && !isValidPinOrderKey(a)) return null; + if (b !== "" && !isValidPinOrderKey(b)) return null; + if (b !== "" && a >= b) return 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. */ +export function generateSpreadPinOrderKeys(count: number): string[] { + const space = PIN_ORDER_DIGITS.length * 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); + // 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), + ); + } + return keys; +} + +/** + * Assignments needed to realize a new pinned order. When the moved thread + * 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 drag after that is single-write. + */ +export function planPinnedReorder(input: { + /** Thread ids in the desired visual order (after the move). */ + readonly orderedIds: readonly string[]; + readonly keysById: ReadonlyMap; + readonly movedId: string; +}): ReadonlyArray<{ readonly id: string; readonly orderKey: string }> { + const { orderedIds, keysById, movedId } = input; + const movedIndex = orderedIds.indexOf(movedId); + if (movedIndex === -1) return []; + const beforeId = movedIndex > 0 ? orderedIds[movedIndex - 1] : null; + const afterId = movedIndex < orderedIds.length - 1 ? orderedIds[movedIndex + 1] : null; + const beforeKey = beforeId != null ? (keysById.get(beforeId) ?? null) : null; + const afterKey = afterId != null ? (keysById.get(afterId) ?? null) : null; + const beforeUsable = beforeId === null || beforeKey != null; + const afterUsable = afterId === null || afterKey != null; + if (beforeUsable && afterUsable) { + const key = pinOrderKeyBetween(beforeKey, 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); + return orderedIds.flatMap((id, index) => { + const key = keys[index]!; + return keysById.get(id) === key ? [] : [{ id, orderKey: key }]; + }); +} + +/** + * Pinned block order: user-arranged keys first (string comparison, id + * tiebreak), then keyless threads in the standard v2 creation order — so + * threads on servers that predate reordering keep today's behavior at the + * bottom of the block instead of breaking the section. + */ +export function sortPinnedThreadsForSidebarV2< + T extends { + readonly id: string; + readonly createdAt: string; + readonly pinOrderKey?: string | null | undefined; + }, +>(threads: readonly T[]): T[] { + const keyed: T[] = []; + const keyless: T[] = []; + for (const thread of threads) { + (thread.pinOrderKey != null ? keyed : keyless).push(thread); + } + keyed.sort((left, right) => { + const leftKey = left.pinOrderKey!; + const rightKey = right.pinOrderKey!; + return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : left.id.localeCompare(right.id); + }); + return [...keyed, ...sortThreadsForSidebarV2(keyless)]; +} + /** * Search the already-ordered sidebar thread collection by title only. * Keeping the input order means lifecycle ordering (active, snoozed, settled) diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 81d88350808..874e1e66632 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -1,5 +1,21 @@ import { autoAnimate } from "@formkit/auto-animate"; import { useAtomValue } from "@effect/atom-react"; +import { + DndContext, + PointerSensor, + closestCenter, + useSensor, + useSensors, + type DragEndEvent, +} from "@dnd-kit/core"; +import { + SortableContext, + arrayMove, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers"; +import { CSS } from "@dnd-kit/utilities"; import { canSnooze, effectiveSettled, @@ -116,6 +132,8 @@ import { hasUnseenCompletion, isTrailingDoubleClick, orderItemsByPreferredIds, + pinOrderKeyBetween, + planPinnedReorder, resolveAdjacentThreadId, resolveSettledTimestamp, resolveSidebarV2Status, @@ -123,6 +141,7 @@ import { resolveWorkingStartedAt, shouldNavigateAfterProjectRemoval, sortLogicalProjectsForSidebar, + sortPinnedThreadsForSidebarV2, sortSettledThreadsForSidebarV2, sortThreadsForSidebarV2, } from "./Sidebar.logic"; @@ -390,6 +409,26 @@ function SnoozePopoverButton(props: { ); } +// Subset of useSortable applied to a pinned card's root
  • . Listeners go +// on the whole card (no dedicated handle): the pointer sensor's distance +// constraint keeps plain clicks working, and we skip dnd-kit's aria +// attributes since there is no keyboard sensor and the card body already +// carries its own button semantics. +type SortablePinnedRowBag = Pick< + ReturnType, + "listeners" | "setNodeRef" | "transform" | "transition" | "isDragging" +>; + +function SortablePinnedThreadRow(props: { + id: string; + children: (bag: SortablePinnedRowBag) => ReactNode; +}) { + const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: props.id, + }); + return props.children({ listeners, setNodeRef, transform, transition, isDragging }); +} + const SidebarV2Row = memo(function SidebarV2Row(props: { thread: SidebarThreadSummary; variant: "card" | "slim"; @@ -410,6 +449,10 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { // the descriptor is not loaded. Pinning itself lives in the context menu. pinningSupported: boolean; isPinned: boolean; + // Present only on pinned cards whose server supports reordering: dnd-kit + // sortable bag applied to the card root so the whole card drags (the + // pointer sensor's distance constraint keeps plain clicks working). + sortable?: SortablePinnedRowBag | undefined; // 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 @@ -974,10 +1017,24 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { const diff = latestTurnDiff(thread); + const sortable = props.sortable; return (
  • { + async (target: ScopedThreadRef, opts: { orderKey?: string } = {}) => { // Version skew: never send the command to a server that predates it. if (!readEnvironmentSupportsPinning(target.environmentId)) { return AsyncResult.failure( @@ -518,9 +522,18 @@ export function useThreadActions() { ), ); } + // orderKey rides only to servers that decode it; pre-reorder servers + // get the bare pin they understand and the thread stays keyless. + const orderKey = + opts.orderKey !== undefined && readEnvironmentSupportsPinReorder(target.environmentId) + ? opts.orderKey + : undefined; return pinThreadMutation({ environmentId: target.environmentId, - input: { threadId: target.threadId }, + input: { + threadId: target.threadId, + ...(orderKey !== undefined ? { orderKey } : {}), + }, }); }, [pinThreadMutation], @@ -546,6 +559,29 @@ export function useThreadActions() { [unpinThreadMutation], ); + const reorderPinnedThread = useCallback( + async (target: ScopedThreadRef, orderKey: string) => { + // Callers (the sidebar drag handler) only enable dragging on + // reorder-capable environments; this guard covers races around + // capability changes mid-drag. + if (!readEnvironmentSupportsPinReorder(target.environmentId)) { + return AsyncResult.failure( + Cause.fail( + new ThreadPinningUnsupportedError({ + environmentId: target.environmentId, + threadId: target.threadId, + }), + ), + ); + } + return reorderPinnedThreadMutation({ + environmentId: target.environmentId, + input: { threadId: target.threadId, orderKey }, + }); + }, + [reorderPinnedThreadMutation], + ); + const snoozeThread = useCallback( async (target: ScopedThreadRef, snoozedUntil: string) => { // Version skew: never send the command to a server that predates it. @@ -641,12 +677,14 @@ export function useThreadActions() { unsnoozeThread, pinThread, unpinThread, + reorderPinnedThread, }), [ archiveThread, confirmAndDeleteThread, deleteThread, pinThread, + reorderPinnedThread, settleThread, snoozeThread, unarchiveThread, diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index c0018b24935..455e5e646f6 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -259,6 +259,15 @@ export function readEnvironmentSupportsTitleRegeneration(environmentId: Environm ); } +/** Whether the environment's server understands thread.pin.reorder (and + orderKey on thread.pin). Same version-skew contract as settlement. */ +export function readEnvironmentSupportsPinReorder(environmentId: EnvironmentId): boolean { + return ( + appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities + .threadPinReorder === true + ); +} + export function readThreadDetail(ref: ScopedThreadRef): EnvironmentThread | null { return appAtomRegistry.get(environmentThreadDetails.detailAtom(ref)); } diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index ee200d3a22d..cb74f117b77 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -41,6 +41,7 @@ export type SnoozeThreadInput = CommandInput<"thread.snooze">; 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 UpdateThreadMetadataInput = CommandInput<"thread.meta.update">; export type SetThreadRuntimeModeInput = CommandInput<"thread.runtime-mode.set">; export type SetThreadInteractionModeInput = CommandInput<"thread.interaction-mode.set">; @@ -219,6 +220,16 @@ export const unpinThread: (input: UnpinThreadInput) => CommandEffect = Effect.fn }); }); +export const reorderPinnedThread: (input: ReorderPinnedThreadInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.reorderPinnedThread", +)(function* (input) { + return yield* dispatch({ + ...input, + type: "thread.pin.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/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index 2eabc5aec16..ed3537e4f83 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -13,6 +13,7 @@ import { type SetThreadInteractionModeInput, type SetThreadRuntimeModeInput, type PinThreadInput, + type ReorderPinnedThreadInput, type SettleThreadInput, type SnoozeThreadInput, type StartThreadTurnInput, @@ -32,6 +33,7 @@ import { setThreadInteractionMode, setThreadRuntimeMode, pinThread, + reorderPinnedThread, settleThread, snoozeThread, startThreadTurn, @@ -55,6 +57,7 @@ export type { SetThreadInteractionModeInput, SetThreadRuntimeModeInput, PinThreadInput, + ReorderPinnedThreadInput, SettleThreadInput, SnoozeThreadInput, StartThreadTurnInput, @@ -136,6 +139,12 @@ export function createThreadEnvironmentAtoms( scheduler, concurrency, }), + reorderPin: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:reorder-pin", + execute: (input: ReorderPinnedThreadInput) => reorderPinnedThread(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 30e8ef58248..5a2ffa442e0 100644 --- a/packages/client-runtime/src/state/threadDetail.ts +++ b/packages/client-runtime/src/state/threadDetail.ts @@ -62,6 +62,7 @@ export function mergeEnvironmentThread( snoozedUntil: shell.snoozedUntil, snoozedAt: shell.snoozedAt, pinnedAt: shell.pinnedAt, + pinOrderKey: shell.pinOrderKey, session: shell.session, }; } diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index 0c6649f3868..970fd94b1a1 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -173,6 +173,9 @@ export function applyThreadDetailEvent( thread: { ...thread, pinnedAt: event.payload.pinnedAt, + ...(event.payload.pinOrderKey !== undefined + ? { pinOrderKey: event.payload.pinOrderKey } + : {}), updatedAt: event.payload.updatedAt, }, }; @@ -183,6 +186,17 @@ export function applyThreadDetailEvent( thread: { ...thread, pinnedAt: null, + pinOrderKey: null, + updatedAt: event.payload.updatedAt, + }, + }; + + case "thread.pin-reordered": + return { + kind: "updated", + thread: { + ...thread, + pinOrderKey: event.payload.orderKey, updatedAt: event.payload.updatedAt, }, }; diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 4c44a959655..329ff911503 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -50,6 +50,9 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ /** Server understands thread.pin / thread.unpin commands. Same version-skew contract as threadSettlement. */ threadPinning: Schema.optionalKey(Schema.Boolean), + /** Server understands thread.pin.reorder (and orderKey on thread.pin). + Same version-skew contract as threadSettlement. */ + threadPinReorder: 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.ts b/packages/contracts/src/orchestration.ts index 26204961923..87270d98c1f 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -379,6 +379,11 @@ export const OrchestrationThread = Schema.Struct({ // thread renders in the pinned block and never classifies into a shelf. // Optional so payloads from pre-pinning servers still decode. pinnedAt: Schema.optional(Schema.NullOr(IsoDateTime)), + // Fractional index for user-arranged pinned order. Keyed threads sort by + // string comparison ahead of keyless ones (which keep creation order), so + // 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)), // Pending-only state. Optional so older servers remain compatible. titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)), deletedAt: Schema.NullOr(IsoDateTime), @@ -434,6 +439,7 @@ export const OrchestrationThreadShell = Schema.Struct({ snoozedUntil: Schema.optional(Schema.NullOr(IsoDateTime)), snoozedAt: Schema.optional(Schema.NullOr(IsoDateTime)), pinnedAt: Schema.optional(Schema.NullOr(IsoDateTime)), + pinOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)), session: Schema.NullOr(OrchestrationSession), latestUserMessageAt: Schema.NullOr(IsoDateTime), @@ -702,6 +708,10 @@ const ThreadPinCommand = Schema.Struct({ type: Schema.Literal("thread.pin"), commandId: CommandId, threadId: ThreadId, + // Initial slot in the user-arranged pinned order (see ThreadPinReorderCommand). + // Optional: clients on pre-reorder servers omit it, and the pinned block + // falls back to creation order for keyless threads. + orderKey: Schema.optional(TrimmedNonEmptyString), }); const ThreadUnpinCommand = Schema.Struct({ @@ -710,6 +720,17 @@ const ThreadUnpinCommand = Schema.Struct({ threadId: ThreadId, }); +const ThreadPinReorderCommand = Schema.Struct({ + type: Schema.Literal("thread.pin.reorder"), + commandId: CommandId, + threadId: ThreadId, + // Fractional index key: pinned threads sort by plain string comparison of + // these keys, so a drag writes one key to one thread — neighbors (possibly + // on other servers) are never touched. Clients compute a key that sorts + // between the dropped position's neighbors. + orderKey: TrimmedNonEmptyString, +}); + const ThreadMetaUpdateCommand = Schema.Struct({ type: Schema.Literal("thread.meta.update"), commandId: CommandId, @@ -865,6 +886,7 @@ const DispatchableClientOrchestrationCommand = Schema.Union([ ThreadUnsnoozeCommand, ThreadPinCommand, ThreadUnpinCommand, + ThreadPinReorderCommand, ThreadMetaUpdateCommand, ThreadRuntimeModeSetCommand, ThreadInteractionModeSetCommand, @@ -892,6 +914,7 @@ export const ClientOrchestrationCommand = Schema.Union([ ThreadUnsnoozeCommand, ThreadPinCommand, ThreadUnpinCommand, + ThreadPinReorderCommand, ThreadMetaUpdateCommand, ThreadRuntimeModeSetCommand, ThreadInteractionModeSetCommand, @@ -1009,6 +1032,7 @@ export const OrchestrationEventType = Schema.Literals([ "thread.unsnoozed", "thread.pinned", "thread.unpinned", + "thread.pin-reordered", "thread.meta-updated", "thread.runtime-mode-set", "thread.interaction-mode-set", @@ -1120,6 +1144,9 @@ export const ThreadUnsnoozedPayload = Schema.Struct({ export const ThreadPinnedPayload = Schema.Struct({ threadId: ThreadId, pinnedAt: IsoDateTime, + // Absent on re-pins of an already-pinned thread (the existing key wins) + // and on pins from clients that predate reordering. + pinOrderKey: Schema.optional(TrimmedNonEmptyString), updatedAt: IsoDateTime, }); @@ -1128,6 +1155,12 @@ export const ThreadUnpinnedPayload = Schema.Struct({ updatedAt: IsoDateTime, }); +export const ThreadPinReorderedPayload = Schema.Struct({ + threadId: ThreadId, + orderKey: TrimmedNonEmptyString, + updatedAt: IsoDateTime, +}); + export const ThreadMetaUpdatedPayload = Schema.Struct({ threadId: ThreadId, title: Schema.optional(TrimmedNonEmptyString), @@ -1332,6 +1365,11 @@ export const OrchestrationEvent = Schema.Union([ type: Schema.Literal("thread.unpinned"), payload: ThreadUnpinnedPayload, }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.pin-reordered"), + payload: ThreadPinReorderedPayload, + }), Schema.Struct({ ...EventBaseFields, type: Schema.Literal("thread.meta-updated"), From 246bf3fb54bd0aa737c1ae02c46de17b3584ccc0 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 7 Aug 2026 05:34:44 -0700 Subject: [PATCH 2/5] fix(web): release optimistic pin order on membership change; add mobile Move up/down Review fixes and cherry-picks from the parallel implementation (#5528): - The optimistic drag override now releases on ANY pinned-membership change (new pin, unpin, snooze/wake) instead of only exact sequence match, so a pin landing mid-drag can no longer freeze the override and launder a stale order into later drags. - attemptPin anchors the new pin's key against the DISPLAYED order, and every pin path (chat header, context menus, mobile) now sends a top-of-run orderKey, so the same action never places differently. - Pin-order key math moved to client-runtime (state/thread-sort) and is shared by web and mobile instead of mirrored. - Mobile: Move up / Move down actions in the pinned row menu, computed against the canonical arranged order so search or project scoping never disables or misdirects a move. - Docs: user page on organizing threads. Co-Authored-By: Claude Fable 5 --- .../src/features/home/HomeRouteScreen.tsx | 2 + apps/mobile/src/features/home/HomeScreen.tsx | 44 +++++ .../src/features/home/useThreadListActions.ts | 102 ++++++++++- .../threads/ThreadNavigationSidebar.tsx | 33 ++++ .../features/threads/thread-list-v2-items.tsx | 43 ++++- .../src/features/threads/threadListV2.ts | 30 +--- apps/web/src/components/Sidebar.logic.ts | 143 +-------------- apps/web/src/components/SidebarV2.tsx | 83 +++++---- apps/web/src/hooks/useThreadActions.ts | 26 ++- apps/web/src/state/entities.ts | 4 + docs/README.md | 1 + docs/user/thread-sidebar.md | 13 ++ .../src/state/threadSort.test.ts | 48 ++++- .../client-runtime/src/state/threadSort.ts | 169 ++++++++++++++++++ 14 files changed, 530 insertions(+), 211 deletions(-) create mode 100644 docs/user/thread-sidebar.md diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 16a3efde6dd..aa82ee045c5 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -44,6 +44,7 @@ export function HomeRouteScreen() { unsnoozeThread, pinThread, unpinThread, + movePinnedThread, unsettleThread, } = useThreadListActions(); const pendingTasks = usePendingNewTasks(); @@ -159,6 +160,7 @@ export function HomeRouteScreen() { onUnsettleThread={unsettleThread} onPinThread={pinThread} onUnpinThread={unpinThread} + onMovePinnedThread={movePinnedThread} onEnvironmentChange={setSelectedEnvironmentId} onProjectChange={setSelectedProjectKey} onOpenEnvironments={() => diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index ba32dc6b609..2c8412cad7e 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -11,6 +11,7 @@ import { threadSearchMatchKey, type EnvironmentThreadSearchMatch, } from "@t3tools/client-runtime/state/thread-search"; +import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort"; import type { EnvironmentId, SidebarProjectGroupingMode, @@ -113,6 +114,10 @@ interface HomeScreenProps { readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void; readonly onPinThread: (thread: EnvironmentThreadShell) => Promise; readonly onUnpinThread: (thread: EnvironmentThreadShell) => Promise; + readonly onMovePinnedThread: ( + thread: EnvironmentThreadShell, + direction: "up" | "down", + ) => Promise; readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void; readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; readonly onNewThreadInProject: (project: EnvironmentProject) => void; @@ -524,6 +529,12 @@ export function HomeScreen(props: HomeScreenProps) { }, [props.onPinThread], ); + const handleMovePinnedThread = useCallback( + (thread: EnvironmentThreadShell, direction: "up" | "down") => { + void props.onMovePinnedThread(thread, direction); + }, + [props.onMovePinnedThread], + ); const handleUnpinThread = useCallback( (thread: EnvironmentThreadShell) => { void props.onUnpinThread(thread); @@ -598,6 +609,29 @@ export function HomeScreen(props: HomeScreenProps) { } return supported; }, [serverConfigs]); + const pinReorderEnvironmentIds = useMemo(() => { + const supported = new Set(); + for (const [environmentId, config] of serverConfigs) { + if (config.environment.capabilities.threadPinReorder === true) { + supported.add(environmentId); + } + } + return supported; + }, [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 threadListV2Layout = useMemo(() => { if (!threadListV2Enabled) return { @@ -784,11 +818,18 @@ export function HomeScreen(props: HomeScreenProps) { onSettleThread={handleSettleThread} snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)} pinningSupported={pinningEnvironmentIds.has(thread.environmentId)} + pinReorderSupported={pinReorderEnvironmentIds.has(thread.environmentId)} + canMovePinnedUp={arrangedPinnedKeys.indexOf(`${thread.environmentId}:${thread.id}`) > 0} + canMovePinnedDown={(() => { + const index = arrangedPinnedKeys.indexOf(`${thread.environmentId}:${thread.id}`); + return index !== -1 && index < arrangedPinnedKeys.length - 1; + })()} onSnoozeThread={handleSnoozeThread} onUnsnoozeThread={handleUnsnoozeThread} onUnsettleThread={handleUnsettleThread} onPinThread={handlePinThread} onUnpinThread={handleUnpinThread} + onMovePinnedThread={handleMovePinnedThread} onChangeRequestState={handleChangeRequestState} projectCwd={ projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null @@ -801,6 +842,8 @@ export function HomeScreen(props: HomeScreenProps) { [ handleChangeRequestState, handleDeleteThread, + arrangedPinnedKeys, + handleMovePinnedThread, handlePinThread, handleSettleThread, handleSnoozeThread, @@ -810,6 +853,7 @@ export function HomeScreen(props: HomeScreenProps) { handleSwipeableWillOpen, handleUnsettleThread, pinningEnvironmentIds, + pinReorderEnvironmentIds, projectByKey, projectCwdByKey, props.onArchiveThread, diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index dcea2b6791b..1b735cd1e46 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -8,9 +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 { appAtomRegistry } from "../../state/atom-registry"; import { environmentServerConfigsAtom } from "../../state/server"; -import { threadEnvironment } from "../../state/threads"; +import { environmentThreadShells, threadEnvironment } from "../../state/threads"; import { useAtomCommand } from "../../state/use-atom-command"; /** Version skew: never send settle/unsettle to a server that predates them @@ -36,6 +41,13 @@ function environmentSupportsPinning(environmentId: EnvironmentThreadShell["envir ); } +function environmentSupportsPinReorder(environmentId: EnvironmentThreadShell["environmentId"]) { + return ( + appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities + .threadPinReorder === true + ); +} + type ThreadListAction = "archive" | "unarchive" | "delete" | "settle" | "unsettle"; const ACTION_VERBS: Record = { @@ -211,6 +223,10 @@ export function useThreadListActions(): { readonly unsettleThread: (thread: EnvironmentThreadShell) => Promise; readonly pinThread: (thread: EnvironmentThreadShell) => Promise; readonly unpinThread: (thread: EnvironmentThreadShell) => Promise; + readonly movePinnedThread: ( + thread: EnvironmentThreadShell, + direction: "up" | "down", + ) => Promise; } { const executeAction = useThreadActionExecutor(); const snoozeMutation = useAtomCommand(threadEnvironment.snooze, { reportFailure: false }); @@ -331,9 +347,21 @@ export function useThreadListActions(): { return false; } selectionHaptic(); + // Same placement as web: a fresh pin takes the top of the arranged + // run. Servers that predate reordering get the bare pin (keyless). + let orderKey: string | undefined; + if (environmentSupportsPinReorder(thread.environmentId)) { + const shells = appAtomRegistry.get(environmentThreadShells.threadShellsAtom); + let firstKey: string | null = null; + for (const shell of shells) { + if (shell.pinnedAt == null || shell.pinOrderKey == null) continue; + if (firstKey === null || shell.pinOrderKey < firstKey) firstKey = shell.pinOrderKey; + } + orderKey = pinOrderKeyBetween(null, firstKey) ?? undefined; + } const result = await pinMutation({ environmentId: thread.environmentId, - input: { threadId: thread.id }, + input: { threadId: thread.id, ...(orderKey !== undefined ? { orderKey } : {}) }, }); if (result._tag === "Failure") { const error = Cause.squash(result.cause); @@ -378,6 +406,75 @@ export function useThreadListActions(): { [unpinMutation], ); + // 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). + const reorderPinnedMutation = useAtomCommand(threadEnvironment.reorderPin, { + reportFailure: false, + }); + const movePinnedThread = useCallback( + async (thread: EnvironmentThreadShell, direction: "up" | "down") => { + if (!environmentSupportsPinReorder(thread.environmentId)) { + Alert.alert( + "Could not move thread", + "This environment's server does not support pinned reordering yet. Update the server to reorder pins.", + ); + return false; + } + const shells = appAtomRegistry.get(environmentThreadShells.threadShellsAtom); + const pinned = sortPinnedThreadsByOrderKey( + shells.filter( + (shell) => + shell.pinnedAt != null && + shell.archivedAt === null && + environmentSupportsPinReorder(shell.environmentId), + ), + ); + 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, + ]), + ), + movedId: scopedThreadKey(thread.environmentId, thread.id), + direction, + }); + if (assignments === null || assignments.length === 0) return false; + const shellByKey = new Map( + pinned.map((shell) => [scopedThreadKey(shell.environmentId, shell.id), shell]), + ); + selectionHaptic(); + for (const assignment of assignments) { + const target = shellByKey.get(assignment.id); + if (target === undefined) continue; + const result = await reorderPinnedMutation({ + environmentId: target.environmentId, + input: { threadId: target.id, orderKey: assignment.orderKey }, + }); + if (result._tag === "Failure") { + const error = Cause.squash(result.cause); + Alert.alert( + "Could not move thread", + error instanceof Error && error.message.trim().length > 0 + ? error.message + : "The pinned 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. + return false; + } + } + return true; + }, + [reorderPinnedMutation], + ); + const confirmDeleteThread = useConfirmDeleteThread(executeAction); return { @@ -389,6 +486,7 @@ export function useThreadListActions(): { unsettleThread, pinThread, unpinThread, + movePinnedThread, }; } diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 322ac60759d..b7b1c2ff052 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -11,6 +11,7 @@ import { LegendList } from "@legendapp/list/react-native"; import type { MenuAction } from "@react-native-menu/menu"; import { useAtomValue } from "@effect/atom-react"; import type { EnvironmentId } from "@t3tools/contracts"; +import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent } from "react-native"; import { Platform, Pressable, StyleSheet, TextInput, View, useColorScheme } from "react-native"; @@ -207,6 +208,7 @@ function ThreadNavigationSidebarPane( unsettleThread, pinThread, unpinThread, + movePinnedThread, } = useThreadListActions(); const threadListV2Enabled = useThreadListV2Enabled(); const pendingTasks = usePendingNewTasks(); @@ -492,6 +494,28 @@ function ThreadNavigationSidebarPane( } return supported; }, [serverConfigs]); + const pinReorderEnvironmentIds = useMemo(() => { + const supported = new Set(); + for (const [environmentId, config] of serverConfigs) { + if (config.environment.capabilities.threadPinReorder === true) { + supported.add(environmentId); + } + } + return supported; + }, [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 threadListV2Layout = useMemo(() => { if (!threadListV2Enabled) return { @@ -929,11 +953,20 @@ function ThreadNavigationSidebarPane( onSettleThread={settleThread} snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)} pinningSupported={pinningEnvironmentIds.has(thread.environmentId)} + pinReorderSupported={pinReorderEnvironmentIds.has(thread.environmentId)} + canMovePinnedUp={ + arrangedPinnedKeys.indexOf(`${thread.environmentId}:${thread.id}`) > 0 + } + canMovePinnedDown={(() => { + const index = arrangedPinnedKeys.indexOf(`${thread.environmentId}:${thread.id}`); + return index !== -1 && index < arrangedPinnedKeys.length - 1; + })()} onSnoozeThread={snoozeThread} onUnsnoozeThread={unsnoozeThread} onUnsettleThread={unsettleThread} onPinThread={pinThread} onUnpinThread={unpinThread} + onMovePinnedThread={movePinnedThread} onChangeRequestState={handleChangeRequestState} projectCwd={projectCwdByKey.get(scopeKey) ?? null} onSwipeableClose={handleSwipeableClose} 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 24f7166916b..829652f61e2 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -354,6 +354,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly snoozeSupported: boolean; /** False on servers that predate thread.pin/unpin. */ readonly pinningSupported: 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 + would fall off the end of the list. */ + readonly canMovePinnedUp?: boolean; + readonly canMovePinnedDown?: boolean; readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; readonly onSwipeableClose: (methods: SwipeableMethods) => void; /** Reports this row's live PR state up so the partition can auto-settle @@ -382,6 +390,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { onArchiveThread, onPinThread, onUnpinThread, + onMovePinnedThread, onChangeRequestState, } = props; const snoozedRow = props.snoozed === true; @@ -416,6 +425,14 @@ 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 handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]); // Swipe: the v2 primary action is the lifecycle transition. Every settled @@ -459,12 +476,34 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { () => 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, + ] + : []), pinnedRow ? { id: "unpin", title: "Unpin", image: "pin.slash" } : { id: "pin", title: "Pin", image: "pin" }, ] : [], - [pinnedRow, props.pinningSupported], + [ + pinnedRow, + props.canMovePinnedDown, + props.canMovePinnedUp, + props.pinReorderSupported, + props.pinningSupported, + ], ); const snoozableCardMenuActions = useMemo( () => [ @@ -491,6 +530,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 === "archive") handleArchive(); if (nativeEvent.event === "delete") handleDelete(); const snoozeSelection = resolveThreadListV2SnoozeMenuSelection({ diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index fb80c1a26c3..ef9216ad96f 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -9,6 +9,7 @@ import { 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 { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort"; import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; @@ -175,33 +176,6 @@ export function sortThreadsForListV2(threads: readonly T[]): T[] { - const keyed: T[] = []; - const keyless: T[] = []; - for (const thread of threads) { - (thread.pinOrderKey != null ? keyed : keyless).push(thread); - } - keyed.sort((left, right) => { - const leftKey = left.pinOrderKey!; - const rightKey = right.pinOrderKey!; - return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : left.id.localeCompare(right.id); - }); - return [...keyed, ...sortThreadsForListV2(keyless)]; -} - export interface ThreadListV2Item { readonly thread: EnvironmentThreadShell; readonly variant: "card" | "slim"; @@ -471,7 +445,7 @@ export function buildThreadListV2Items(input: { ); const items: ThreadListV2Item[] = []; - for (const thread of sortPinnedThreadsForListV2(pinned)) { + for (const thread of sortPinnedThreadsByOrderKey(pinned)) { items.push({ thread, variant: "card", diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index c6b38186730..e516822fd56 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -510,141 +510,14 @@ export function sortThreadsForSidebarV2< ); } -// ── Pinned reorder: fractional index keys ────────────────────────────── -// Pinned threads carry an optional pinOrderKey (a base-26 string). The -// pinned block sorts keyed threads by plain string comparison, so a drag -// writes ONE key to ONE thread on that thread's own server — neighbors, -// possibly living on other servers, are never touched, and every client -// connected to the same servers converges on the same order. -const PIN_ORDER_DIGITS = "abcdefghijklmnopqrstuvwxyz"; - -function isValidPinOrderKey(key: string): boolean { - if (key.length === 0) return false; - for (const char of key) { - if (!PIN_ORDER_DIGITS.includes(char)) return false; - } - // A trailing minimum digit would leave no room to sort a key immediately - // before this one; generators never produce it, so treat it as corrupt. - return key.at(-1) !== PIN_ORDER_DIGITS[0]; -} - -/** Midpoint of two digit strings interpreted as fractions in (0, 1). - "" stands for the open bound on either side. Requires a < b. */ -function pinOrderMidpoint(a: string, b: string): string { - if (b !== "" && a >= b) throw new Error("pinOrderMidpoint: bounds out of order"); - if (b !== "") { - // Recurse past the longest common prefix ("a" pads the shorter side). - let n = 0; - while ((a.charAt(n) || PIN_ORDER_DIGITS[0]) === b.charAt(n)) n += 1; - if (n > 0) return b.slice(0, n) + pinOrderMidpoint(a.slice(n), b.slice(n)); - } - const digitA = a === "" ? 0 : PIN_ORDER_DIGITS.indexOf(a.charAt(0)); - const digitB = b === "" ? PIN_ORDER_DIGITS.length : PIN_ORDER_DIGITS.indexOf(b.charAt(0)); - if (digitB - digitA > 1) { - return PIN_ORDER_DIGITS.charAt(Math.round((digitA + digitB) / 2)); - } - // Consecutive leading digits: either b has spare digits to shorten into, - // or we extend a (never producing a trailing minimum digit — the base - // case midpoint("", "") is the middle of the alphabet). - if (b.length > 1) return b.charAt(0); - return PIN_ORDER_DIGITS.charAt(digitA) + pinOrderMidpoint(a.slice(1), ""); -} - -/** Key that sorts strictly between two neighbors; null bounds mean "top of - the pinned block" / "bottom of the keyed run". Returns null instead of - throwing when existing keys are corrupt or out of order — callers fall - back to rewriting the section. */ -export function pinOrderKeyBetween(before: string | null, after: string | null): string | null { - const a = before ?? ""; - const b = after ?? ""; - if (a !== "" && !isValidPinOrderKey(a)) return null; - if (b !== "" && !isValidPinOrderKey(b)) return null; - if (b !== "" && a >= b) return 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. */ -export function generateSpreadPinOrderKeys(count: number): string[] { - const space = PIN_ORDER_DIGITS.length * 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); - // 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), - ); - } - return keys; -} - -/** - * Assignments needed to realize a new pinned order. When the moved thread - * 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 drag after that is single-write. - */ -export function planPinnedReorder(input: { - /** Thread ids in the desired visual order (after the move). */ - readonly orderedIds: readonly string[]; - readonly keysById: ReadonlyMap; - readonly movedId: string; -}): ReadonlyArray<{ readonly id: string; readonly orderKey: string }> { - const { orderedIds, keysById, movedId } = input; - const movedIndex = orderedIds.indexOf(movedId); - if (movedIndex === -1) return []; - const beforeId = movedIndex > 0 ? orderedIds[movedIndex - 1] : null; - const afterId = movedIndex < orderedIds.length - 1 ? orderedIds[movedIndex + 1] : null; - const beforeKey = beforeId != null ? (keysById.get(beforeId) ?? null) : null; - const afterKey = afterId != null ? (keysById.get(afterId) ?? null) : null; - const beforeUsable = beforeId === null || beforeKey != null; - const afterUsable = afterId === null || afterKey != null; - if (beforeUsable && afterUsable) { - const key = pinOrderKeyBetween(beforeKey, 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); - return orderedIds.flatMap((id, index) => { - const key = keys[index]!; - return keysById.get(id) === key ? [] : [{ id, orderKey: key }]; - }); -} - -/** - * Pinned block order: user-arranged keys first (string comparison, id - * tiebreak), then keyless threads in the standard v2 creation order — so - * threads on servers that predate reordering keep today's behavior at the - * bottom of the block instead of breaking the section. - */ -export function sortPinnedThreadsForSidebarV2< - T extends { - readonly id: string; - readonly createdAt: string; - readonly pinOrderKey?: string | null | undefined; - }, ->(threads: readonly T[]): T[] { - const keyed: T[] = []; - const keyless: T[] = []; - for (const thread of threads) { - (thread.pinOrderKey != null ? keyed : keyless).push(thread); - } - keyed.sort((left, right) => { - const leftKey = left.pinOrderKey!; - const rightKey = right.pinOrderKey!; - return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : left.id.localeCompare(right.id); - }); - return [...keyed, ...sortThreadsForSidebarV2(keyless)]; -} +// Pinned-reorder key math and the keyed sort live in client-runtime +// (state/thread-sort) so web and mobile compute identical pinned orders. +export { + generateSpreadPinOrderKeys, + pinOrderKeyBetween, + planPinnedReorder, +} from "@t3tools/client-runtime/state/thread-sort"; +export { sortPinnedThreadsByOrderKey as sortPinnedThreadsForSidebarV2 } from "@t3tools/client-runtime/state/thread-sort"; /** * Search the already-ordered sidebar thread collection by title only. diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 874e1e66632..d5283bab269 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -2272,15 +2272,54 @@ export default function SidebarV2() { }, [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). The + // optimistic order keeps the card where it was dropped until the + // confirming event round-trips; canonical order matching it releases the + // override, and a failed write clears it (the card snaps back) with a toast. + // ANY membership change (new pin, unpin, snooze/wake) also releases it: + // the override can't say where members it never saw belong, and holding it + // would misplace them and launder the stale order into later drags. + const pinnedDndSensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), + ); + const [optimisticPinnedOrder, setOptimisticPinnedOrder] = useState( + null, + ); + const orderedPinnedThreads = useMemo(() => { + if (optimisticPinnedOrder === null) return pinnedThreads; + return orderItemsByPreferredIds({ + items: pinnedThreads, + preferredIds: optimisticPinnedOrder, + getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + }); + }, [optimisticPinnedOrder, pinnedThreads]); + useEffect(() => { + if (optimisticPinnedOrder === null) return; + const canonical = pinnedThreads + .map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))) + .filter((key) => reorderablePinnedKeys.has(key)); + const membershipChanged = + canonical.length !== optimisticPinnedOrder.length || + canonical.some((key) => !optimisticPinnedOrder.includes(key)); + const orderConfirmed = + !membershipChanged && canonical.every((key, index) => key === optimisticPinnedOrder[index]); + if (membershipChanged || orderConfirmed) { + setOptimisticPinnedOrder(null); + } + }, [optimisticPinnedOrder, pinnedThreads, reorderablePinnedKeys]); const attemptPin = useCallback( (threadRef: ScopedThreadRef) => { void (async () => { // Fresh pins take the top of the arranged run (newest pin most - // prominent, matching the keyless creation-order feel). Existing - // keys stay put; a null fallback just means "keyless", which sorts - // with the legacy block, so pinning never fails on key math. + // prominent, matching the keyless creation-order feel). Anchored to + // the DISPLAYED order so a pin during an in-flight drag lands above + // what the user is looking at. Existing keys stay put; a null + // fallback just means "keyless", which sorts with the legacy block, + // so pinning never fails on key math. const firstKey = - pinnedThreads.find((thread) => thread.pinOrderKey != null)?.pinOrderKey ?? null; + orderedPinnedThreads.find((thread) => thread.pinOrderKey != null)?.pinOrderKey ?? null; const orderKey = pinOrderKeyBetween(null, firstKey); const result = await pinThread(threadRef, orderKey === null ? {} : { orderKey }); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { @@ -2295,7 +2334,7 @@ export default function SidebarV2() { } })(); }, - [pinThread, pinnedThreads], + [orderedPinnedThreads, pinThread], ); const attemptUnpin = useCallback( (threadRef: ScopedThreadRef) => { @@ -2316,40 +2355,6 @@ export default function SidebarV2() { [unpinThread], ); - // 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). The - // optimistic order keeps the card where it was dropped until the - // confirming event round-trips; canonical order matching it releases the - // override, and a failed write clears it (the card snaps back) with a toast. - const pinnedDndSensors = useSensors( - useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), - ); - const [optimisticPinnedOrder, setOptimisticPinnedOrder] = useState( - null, - ); - const orderedPinnedThreads = useMemo(() => { - if (optimisticPinnedOrder === null) return pinnedThreads; - return orderItemsByPreferredIds({ - items: pinnedThreads, - preferredIds: optimisticPinnedOrder, - getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - }); - }, [optimisticPinnedOrder, pinnedThreads]); - useEffect(() => { - if (optimisticPinnedOrder === null) return; - const canonical = pinnedThreads - .map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))) - .filter((key) => reorderablePinnedKeys.has(key)); - const canonicalSet = new Set(canonical); - const optimistic = optimisticPinnedOrder.filter((key) => canonicalSet.has(key)); - if ( - canonical.length === optimistic.length && - canonical.every((key, index) => key === optimistic[index]) - ) { - setOptimisticPinnedOrder(null); - } - }, [optimisticPinnedOrder, pinnedThreads, reorderablePinnedKeys]); const handlePinnedDragEnd = useCallback( (event: DragEndEvent) => { const activeKey = String(event.active.id); diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index fba63eca416..07f461a2201 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -13,7 +13,7 @@ import { AsyncResult } from "effect/unstable/reactivity"; import { useRouter } from "@tanstack/react-router"; import { useCallback, useMemo, useRef } from "react"; -import { getFallbackThreadIdAfterDelete } from "../components/Sidebar.logic"; +import { getFallbackThreadIdAfterDelete, pinOrderKeyBetween } from "../components/Sidebar.logic"; import { useComposerDraftStore } from "../composerDraftStore"; import { terminalEnvironment } from "../state/terminal"; import { threadEnvironment } from "../state/threads"; @@ -29,6 +29,7 @@ import { readEnvironmentThreadRefs, readProject, readThreadShell, + readThreadShells, } from "../state/entities"; import { useTerminalUiStateStore } from "../terminalUiStateStore"; import { useUiStateStore } from "../uiStateStore"; @@ -98,6 +99,18 @@ export class ThreadSnoozeBlockedError extends Schema.TaggedErrorClass()( "ThreadPinningUnsupportedError", { @@ -522,12 +535,15 @@ export function useThreadActions() { ), ); } + // Every pin path places the thread at the top of the arranged run: + // callers with a better anchor (the sidebar, which knows the displayed + // order) pass their own key; everyone else (chat header, context menus) + // gets the default so the same action never places differently. // orderKey rides only to servers that decode it; pre-reorder servers // get the bare pin they understand and the thread stays keyless. - const orderKey = - opts.orderKey !== undefined && readEnvironmentSupportsPinReorder(target.environmentId) - ? opts.orderKey - : undefined; + const orderKey = readEnvironmentSupportsPinReorder(target.environmentId) + ? (opts.orderKey ?? topOfPinnedRunOrderKey()) + : undefined; return pinThreadMutation({ environmentId: target.environmentId, input: { diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index 455e5e646f6..7bca3118237 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -282,6 +282,10 @@ export function readThreadRefs(): ReadonlyArray { return appAtomRegistry.get(environmentThreadShells.threadRefsAtom); } +export function readThreadShells(): ReadonlyArray { + return appAtomRegistry.get(environmentThreadShells.threadShellsAtom); +} + export function findThreadRef(threadId: ThreadId): ScopedThreadRef | null { return ( appAtomRegistry diff --git a/docs/README.md b/docs/README.md index bc359826a04..b0006e954f6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,6 +5,7 @@ - [Install and first run](./user/install.md) - [Permission modes](./user/permission-modes.md) - [Keyboard shortcuts](./user/keybindings.md) +- [Organizing threads](./user/thread-sidebar.md) - [Remote access](./user/remote-access.md) - [Keeping app and server in sync](./user/updating.md) - [Source control integrations](./user/source-control.md) diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md new file mode 100644 index 00000000000..99c180bafdd --- /dev/null +++ b/docs/user/thread-sidebar.md @@ -0,0 +1,13 @@ +# Organizing threads + +Pin a thread from its context menu to keep it in the pinned section above your active work. +Pinned threads are shown independently of their project, including when you connect to more than +one environment. + +On web and desktop, drag a pinned thread to change its position. On mobile, open the thread's menu +and choose **Move up** or **Move down**. The order is stored by the server and appears on your +other connected devices. + +If reordering is unavailable for one environment, update the T3 Code server running in that +environment. Older servers can still pin and unpin threads, but do not understand synced ordering; +their pinned threads keep the default newest-first order below the ones you have arranged. diff --git a/packages/client-runtime/src/state/threadSort.test.ts b/packages/client-runtime/src/state/threadSort.test.ts index dd6f8c3a295..00e1c5b2b1a 100644 --- a/packages/client-runtime/src/state/threadSort.test.ts +++ b/packages/client-runtime/src/state/threadSort.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { sortThreads, type ThreadSortInput } from "./threadSort.ts"; +import { planPinnedMove, sortThreads, type ThreadSortInput } from "./threadSort.ts"; type TestThread = { readonly id: string } & ThreadSortInput; @@ -69,3 +69,49 @@ describe("sortThreads", () => { expect(sorted.map((thread) => thread.id)).toEqual(["thread-1", "thread-2"]); }); }); + +describe("planPinnedMove", () => { + it("moves a thread up with a single key write", () => { + const assignments = planPinnedMove({ + orderedIds: ["a", "b", "c"], + keysById: new Map([ + ["a", "f"], + ["b", "m"], + ["c", "t"], + ]), + movedId: "c", + direction: "up", + }); + expect(assignments).toHaveLength(1); + expect(assignments![0]!.id).toBe("c"); + expect(assignments![0]!.orderKey > "f" && assignments![0]!.orderKey < "m").toBe(true); + }); + + it("returns null when the move falls off the end of the list", () => { + const input = { + orderedIds: ["a", "b"], + keysById: new Map([ + ["a", "f"], + ["b", "m"], + ]), + }; + expect(planPinnedMove({ ...input, movedId: "a", direction: "up" })).toBeNull(); + expect(planPinnedMove({ ...input, movedId: "b", direction: "down" })).toBeNull(); + }); + + it("materializes keys for the whole section when a neighbor is keyless", () => { + const assignments = planPinnedMove({ + orderedIds: ["a", "b", "c"], + keysById: new Map([ + ["a", null], + ["b", "m"], + ["c", null], + ]), + movedId: "b", + direction: "up", + }); + expect(assignments).not.toBeNull(); + const keys = assignments!.map((entry) => entry.orderKey); + expect([...keys].sort()).toEqual(keys); + }); +}); diff --git a/packages/client-runtime/src/state/threadSort.ts b/packages/client-runtime/src/state/threadSort.ts index aed63cd442d..236b7cc1e24 100644 --- a/packages/client-runtime/src/state/threadSort.ts +++ b/packages/client-runtime/src/state/threadSort.ts @@ -102,3 +102,172 @@ export function getLatestThreadForProject< )[0] ?? null ); } + +// ── Pinned reorder: fractional index keys ────────────────────────────── +// Pinned threads carry an optional pinOrderKey (a base-26 string). The +// pinned block sorts keyed threads by plain string comparison, so a drag +// (web) or Move up/down (mobile) writes ONE key to ONE thread on that +// thread's own server — neighbors, possibly living on other servers, are +// never touched, and every client connected to the same servers converges +// on the same order. +const PIN_ORDER_DIGITS = "abcdefghijklmnopqrstuvwxyz"; + +function isValidPinOrderKey(key: string): boolean { + if (key.length === 0) return false; + for (const char of key) { + if (!PIN_ORDER_DIGITS.includes(char)) return false; + } + // A trailing minimum digit would leave no room to sort a key immediately + // before this one; generators never produce it, so treat it as corrupt. + return key.at(-1) !== PIN_ORDER_DIGITS[0]; +} + +/** Midpoint of two digit strings interpreted as fractions in (0, 1). + "" stands for the open bound on either side. Requires a < b. */ +function pinOrderMidpoint(a: string, b: string): string { + if (b !== "" && a >= b) throw new Error("pinOrderMidpoint: bounds out of order"); + if (b !== "") { + // Recurse past the longest common prefix ("a" pads the shorter side). + let n = 0; + while ((a.charAt(n) || PIN_ORDER_DIGITS[0]) === b.charAt(n)) n += 1; + if (n > 0) return b.slice(0, n) + pinOrderMidpoint(a.slice(n), b.slice(n)); + } + const digitA = a === "" ? 0 : PIN_ORDER_DIGITS.indexOf(a.charAt(0)); + const digitB = b === "" ? PIN_ORDER_DIGITS.length : PIN_ORDER_DIGITS.indexOf(b.charAt(0)); + if (digitB - digitA > 1) { + return PIN_ORDER_DIGITS.charAt(Math.round((digitA + digitB) / 2)); + } + // Consecutive leading digits: either b has spare digits to shorten into, + // or we extend a (never producing a trailing minimum digit — the base + // case midpoint("", "") is the middle of the alphabet). + if (b.length > 1) return b.charAt(0); + return PIN_ORDER_DIGITS.charAt(digitA) + pinOrderMidpoint(a.slice(1), ""); +} + +/** Key that sorts strictly between two neighbors; null bounds mean "top of + the pinned block" / "bottom of the keyed run". Returns null instead of + throwing when existing keys are corrupt or out of order — callers fall + back to rewriting the section. */ +export function pinOrderKeyBetween(before: string | null, after: string | null): string | null { + const a = before ?? ""; + const b = after ?? ""; + if (a !== "" && !isValidPinOrderKey(a)) return null; + if (b !== "" && !isValidPinOrderKey(b)) return null; + if (b !== "" && a >= b) return 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. */ +export function generateSpreadPinOrderKeys(count: number): string[] { + const space = PIN_ORDER_DIGITS.length * 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); + // 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), + ); + } + return keys; +} + +/** + * Assignments needed to realize a new pinned order. When the moved thread + * 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. + */ +export function planPinnedReorder(input: { + /** Thread ids in the desired visual order (after the move). */ + readonly orderedIds: readonly string[]; + readonly keysById: ReadonlyMap; + readonly movedId: string; +}): ReadonlyArray<{ readonly id: string; readonly orderKey: string }> { + const { orderedIds, keysById, movedId } = input; + const movedIndex = orderedIds.indexOf(movedId); + if (movedIndex === -1) return []; + const beforeId = movedIndex > 0 ? orderedIds[movedIndex - 1] : null; + const afterId = movedIndex < orderedIds.length - 1 ? orderedIds[movedIndex + 1] : null; + const beforeKey = beforeId != null ? (keysById.get(beforeId) ?? null) : null; + const afterKey = afterId != null ? (keysById.get(afterId) ?? null) : null; + const beforeUsable = beforeId === null || beforeKey != null; + const afterUsable = afterId === null || afterKey != null; + if (beforeUsable && afterUsable) { + const key = pinOrderKeyBetween(beforeKey, 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); + return orderedIds.flatMap((id, index) => { + const key = keys[index]!; + return keysById.get(id) === key ? [] : [{ id, orderKey: key }]; + }); +} + +/** + * Pinned block order: user-arranged keys first (string comparison, id + * tiebreak), then keyless threads newest-created first — so threads on + * servers that predate reordering keep the static creation order at the + * bottom of the block instead of breaking the section. + */ +export function sortPinnedThreadsByOrderKey< + T extends { + readonly id: string; + readonly createdAt: string; + readonly pinOrderKey?: string | null | undefined; + }, +>(threads: readonly T[]): T[] { + const keyed: T[] = []; + const keyless: T[] = []; + for (const thread of threads) { + (thread.pinOrderKey != null ? keyed : keyless).push(thread); + } + keyed.sort((left, right) => { + const leftKey = left.pinOrderKey!; + const rightKey = right.pinOrderKey!; + return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : left.id.localeCompare(right.id); + }); + keyless.sort((left, right) => { + const leftMs = Date.parse(left.createdAt); + const rightMs = Date.parse(right.createdAt); + return ( + (Number.isNaN(rightMs) ? 0 : rightMs) - (Number.isNaN(leftMs) ? 0 : leftMs) || + left.id.localeCompare(right.id) + ); + }); + return [...keyed, ...keyless]; +} + +/** + * planPinnedReorder specialized for mobile's Move up / Move down menu + * actions: swap the moved thread with its displayed neighbor. Null when the + * move falls off either end of the list. Same single-write-per-move + * semantics as a web drag. + */ +export function planPinnedMove(input: { + /** Reorder-capable pinned thread ids in displayed order. */ + readonly orderedIds: readonly string[]; + readonly keysById: ReadonlyMap; + readonly movedId: string; + readonly direction: "up" | "down"; +}): ReadonlyArray<{ readonly id: string; readonly orderKey: string }> | null { + const { orderedIds, keysById, movedId, direction } = input; + const from = orderedIds.indexOf(movedId); + if (from === -1) return null; + const to = direction === "up" ? from - 1 : from + 1; + if (to < 0 || to >= orderedIds.length) return null; + const newOrder = [...orderedIds]; + newOrder.splice(from, 1); + newOrder.splice(to, 0, movedId); + return planPinnedReorder({ orderedIds: newOrder, keysById, movedId }); +} From 477988d2aeec46eb6f8f7736b109281c1980bd4e Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 7 Aug 2026 05:59:39 -0700 Subject: [PATCH 3/5] fix(web): never hold a stale pinned-order override; unify pinned sort across platforms Review-bot follow-ups: - Distinct ThreadPinReorderUnsupportedError so a reorder-capability gap no longer reports pinning guidance (Effect service conventions) - The optimistic drag override also releases when any pinOrderKey changes (our write confirming, or a concurrent client's reorder), closing the stuck-override window for same-membership remote reorders - Web now sorts ALL pinned threads with the shared keyed rule; server capability only gates dragging, so mixed-version fleets render one order on web and mobile - attemptPin defers to pinThread's all-shells head so a snoozed pin holding the smallest key can't strand a fresh pin mid-run - Materialization writes go out sequentially, stopping at the first failure (no rollback by design: each key write is a complete placement) Co-Authored-By: Claude Fable 5 --- apps/web/src/components/SidebarV2.tsx | 175 +++++++++++++------------ apps/web/src/hooks/useThreadActions.ts | 14 +- 2 files changed, 103 insertions(+), 86 deletions(-) diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index d5283bab269..2a34b65cf2a 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -132,7 +132,6 @@ import { hasUnseenCompletion, isTrailingDoubleClick, orderItemsByPreferredIds, - pinOrderKeyBetween, planPinnedReorder, resolveAdjacentThreadId, resolveSettledTimestamp, @@ -1819,25 +1818,21 @@ export default function SidebarV2() { active.push(thread); } } - // Reorder-capable servers' threads follow the user-arranged key order - // (drag and drop); threads on servers that predate reordering keep the - // static creation order below the arranged run, where they land today. - const reorderablePinned: EnvironmentThreadShell[] = []; - const legacyPinned: EnvironmentThreadShell[] = []; - for (const thread of pinned) { - const supportsReorder = - serverConfigs.get(thread.environmentId)?.environment.capabilities.threadPinReorder === true; - (supportsReorder ? reorderablePinned : legacyPinned).push(thread); - } + // One shared rule on every platform (see sortPinnedThreadsByOrderKey): + // user-arranged keys first, keyless threads in creation order below. + // 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. return { - pinnedThreads: [ - ...sortPinnedThreadsForSidebarV2(reorderablePinned), - ...sortThreadsForSidebarV2(legacyPinned), - ], + pinnedThreads: sortPinnedThreadsForSidebarV2(pinned), reorderablePinnedKeys: new Set( - reorderablePinned.map((thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ), + pinned + .filter( + (thread) => + serverConfigs.get(thread.environmentId)?.environment.capabilities.threadPinReorder === + true, + ) + .map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), ), activeThreads: sortThreadsForSidebarV2(active), // Soonest wake first: "what comes back next" is the shelf's question. @@ -2284,44 +2279,59 @@ export default function SidebarV2() { const pinnedDndSensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), ); - const [optimisticPinnedOrder, setOptimisticPinnedOrder] = useState( - null, - ); + const [optimisticPinnedOrder, setOptimisticPinnedOrder] = useState<{ + readonly order: readonly string[]; + /** pinOrderKey per thread as of the drop, so ANY landed write (ours + confirming, or a concurrent one from another client) releases the + override rather than fighting canonical state. */ + readonly keysAtDrop: ReadonlyMap; + } | null>(null); const orderedPinnedThreads = useMemo(() => { if (optimisticPinnedOrder === null) return pinnedThreads; return orderItemsByPreferredIds({ items: pinnedThreads, - preferredIds: optimisticPinnedOrder, + preferredIds: optimisticPinnedOrder.order, getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), }); }, [optimisticPinnedOrder, pinnedThreads]); useEffect(() => { if (optimisticPinnedOrder === null) return; - const canonical = pinnedThreads - .map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))) - .filter((key) => reorderablePinnedKeys.has(key)); + const canonical = pinnedThreads.filter((thread) => + reorderablePinnedKeys.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), + ); + const canonicalKeys = canonical.map((thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ); + // The override represents one drop against one snapshot of the world. + // Release it as soon as the world moves on in any way: membership + // changed (pin/unpin/snooze/wake — the override can't say where members + // it never saw belong), a key changed (our write confirming, or a + // concurrent client's reorder that must win), or canonical already + // matches. Holding it longer would misplace newcomers and launder the + // stale order into later drags. const membershipChanged = - canonical.length !== optimisticPinnedOrder.length || - canonical.some((key) => !optimisticPinnedOrder.includes(key)); + canonicalKeys.length !== optimisticPinnedOrder.order.length || + canonicalKeys.some((key) => !optimisticPinnedOrder.order.includes(key)); + const anyKeyLanded = canonical.some( + (thread, index) => + optimisticPinnedOrder.keysAtDrop.get(canonicalKeys[index]!) !== + (thread.pinOrderKey ?? null), + ); const orderConfirmed = - !membershipChanged && canonical.every((key, index) => key === optimisticPinnedOrder[index]); - if (membershipChanged || orderConfirmed) { + !membershipChanged && + canonicalKeys.every((key, index) => key === optimisticPinnedOrder.order[index]); + if (membershipChanged || anyKeyLanded || orderConfirmed) { setOptimisticPinnedOrder(null); } }, [optimisticPinnedOrder, pinnedThreads, reorderablePinnedKeys]); const attemptPin = useCallback( (threadRef: ScopedThreadRef) => { void (async () => { - // Fresh pins take the top of the arranged run (newest pin most - // prominent, matching the keyless creation-order feel). Anchored to - // the DISPLAYED order so a pin during an in-flight drag lands above - // what the user is looking at. Existing keys stay put; a null - // fallback just means "keyless", which sorts with the legacy block, - // so pinning never fails on key math. - const firstKey = - orderedPinnedThreads.find((thread) => thread.pinOrderKey != null)?.pinOrderKey ?? null; - const orderKey = pinOrderKeyBetween(null, firstKey); - const result = await pinThread(threadRef, orderKey === null ? {} : { orderKey }); + // Fresh pins take the top of the arranged run: pinThread computes a + // key before the smallest key across ALL pinned shells — including + // snoozed pins hidden from this list, whose keys are still part of + // the run — so the new pin can't land beneath a hidden head. + const result = await pinThread(threadRef); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); toastManager.add( @@ -2334,7 +2344,7 @@ export default function SidebarV2() { } })(); }, - [orderedPinnedThreads, pinThread], + [pinThread], ); const attemptUnpin = useCallback( (threadRef: ScopedThreadRef) => { @@ -2371,40 +2381,41 @@ export default function SidebarV2() { 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: new Map( - reorderable.map((thread, index) => [keys[index]!, thread.pinOrderKey ?? null]), - ), + keysById: keysAtDrop, movedId: activeKey, }); if (assignments.length === 0) return; - setOptimisticPinnedOrder(newOrder); + setOptimisticPinnedOrder({ order: newOrder, keysAtDrop }); void (async () => { - const results = await Promise.all( - assignments.map((assignment) => { - const thread = threadByKey.get(assignment.id); - if (thread === undefined) return null; - return reorderPinnedThread( - scopeThreadRef(thread.environmentId, thread.id), - assignment.orderKey, - ); - }), - ); - const failure = results.find( - (result): result is Extract, { _tag: "Failure" }> => - result?._tag === "Failure" && !isAtomCommandInterrupted(result), - ); - if (failure !== undefined) { - setOptimisticPinnedOrder(null); - const error = squashAtomCommandFailure(failure); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to reorder pinned threads", - description: error instanceof Error ? error.message : "An error occurred.", - }), + // 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" && !isAtomCommandInterrupted(result)) { + setOptimisticPinnedOrder(null); + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to reorder pinned threads", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + return; + } } })(); }, @@ -3344,19 +3355,9 @@ export default function SidebarV2() { // Pinned block: full cards above the inbox, closed by a // thin divider (the pin glyphs carry the meaning, so no // header text). Vanishes entirely at count 0. - // Only reorder-capable rows join the sortable context; - // legacy-server pins render as plain rows below them. - const sortablePinned = orderedPinnedThreads.filter((thread) => - reorderablePinnedKeys.has( - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ), - ); - const legacyPinned = orderedPinnedThreads.filter( - (thread) => - !reorderablePinnedKeys.has( - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ), - ); + // 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[] = [ - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - )} + items={orderedPinnedThreads + .map((thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ) + .filter((threadKey) => reorderablePinnedKeys.has(threadKey))} strategy={verticalListSortingStrategy} > - {sortablePinned.map((thread) => { + {orderedPinnedThreads.map((thread) => { const threadKey = scopedThreadKey( scopeThreadRef(thread.environmentId, thread.id), ); + if (!reorderablePinnedKeys.has(threadKey)) { + return renderThreadRow(thread, "pinned"); + } return ( {(bag) => renderThreadRow(thread, "pinned", bag)} @@ -3383,7 +3389,6 @@ export default function SidebarV2() { })} , - ...legacyPinned.map((thread) => renderThreadRow(thread, "pinned")), ]; if (pinnedThreads.length > 0) { items.push( diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 07f461a2201..742bd7e5bf4 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -123,6 +123,18 @@ export class ThreadPinningUnsupportedError extends Schema.TaggedErrorClass()( + "ThreadPinReorderUnsupportedError", + { + environmentId: EnvironmentId, + threadId: ThreadId, + }, +) { + override get message(): string { + return "This environment's server does not support reordering pinned threads yet. Update the server to reorder pins."; + } +} + export function useThreadActions() { const closeTerminal = useAtomCommand(terminalEnvironment.close); const archiveThreadMutation = useAtomCommand(threadEnvironment.archive, { @@ -583,7 +595,7 @@ export function useThreadActions() { if (!readEnvironmentSupportsPinReorder(target.environmentId)) { return AsyncResult.failure( Cause.fail( - new ThreadPinningUnsupportedError({ + new ThreadPinReorderUnsupportedError({ environmentId: target.environmentId, threadId: target.threadId, }), From 64c0f77f4ee5b8ee08902d2062058645c2e25207 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 7 Aug 2026 06:18:51 -0700 Subject: [PATCH 4/5] fix(mobile): stale move callbacks, double-tap guard, cross-env pinned tiebreak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remaining Macroscope findings on the Move up/down commit: - handleMenuAction and the tablet sidebar's renderListItem were missing the new move callbacks/inputs in their dependency arrays, so recycled rows could move the WRONG pinned thread (stale thread closure) or show stale enabled/disabled move actions - movePinnedThread now carries the same in-flight guard as snoozeThread: a second tap before the first write's event lands would plan from the same snapshot and silently collapse two moves into one - sortPinnedThreadsByOrderKey tiebreaks by id THEN environmentId — thread ids are only environment-unique, and the pinned block merges environments, so id alone could leave equal-key rows in stream-arrival order and diverge across clients Co-Authored-By: Claude Fable 5 --- .../src/features/home/useThreadListActions.ts | 50 +++++++++++-------- .../threads/ThreadNavigationSidebar.tsx | 3 ++ .../features/threads/thread-list-v2-items.tsx | 2 + .../src/state/threadSort.test.ts | 27 +++++++++- .../client-runtime/src/state/threadSort.ts | 11 +++- 5 files changed, 70 insertions(+), 23 deletions(-) diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index 1b735cd1e46..3103c5379be 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -414,8 +414,13 @@ export function useThreadListActions(): { 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( async (thread: EnvironmentThreadShell, direction: "up" | "down") => { + if (movePinnedInFlightRef.current) return false; if (!environmentSupportsPinReorder(thread.environmentId)) { Alert.alert( "Could not move thread", @@ -449,28 +454,33 @@ export function useThreadListActions(): { pinned.map((shell) => [scopedThreadKey(shell.environmentId, shell.id), shell]), ); selectionHaptic(); - for (const assignment of assignments) { - const target = shellByKey.get(assignment.id); - if (target === undefined) continue; - const result = await reorderPinnedMutation({ - environmentId: target.environmentId, - input: { threadId: target.id, orderKey: assignment.orderKey }, - }); - if (result._tag === "Failure") { - const error = Cause.squash(result.cause); - Alert.alert( - "Could not move thread", - error instanceof Error && error.message.trim().length > 0 - ? error.message - : "The pinned 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. - return false; + movePinnedInFlightRef.current = true; + try { + for (const assignment of assignments) { + const target = shellByKey.get(assignment.id); + if (target === undefined) continue; + const result = await reorderPinnedMutation({ + environmentId: target.environmentId, + input: { threadId: target.id, orderKey: assignment.orderKey }, + }); + if (result._tag === "Failure") { + const error = Cause.squash(result.cause); + Alert.alert( + "Could not move thread", + error instanceof Error && error.message.trim().length > 0 + ? error.message + : "The pinned 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. + return false; + } } + return true; + } finally { + movePinnedInFlightRef.current = false; } - return true; }, [reorderPinnedMutation], ); diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index b7b1c2ff052..d2eb3292f39 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -1087,13 +1087,16 @@ function ThreadNavigationSidebarPane( }, [ archiveThread, + arrangedPinnedKeys, confirmDeletePendingTask, confirmDeleteThread, handleChangeRequestState, handleSelectThread, handleSwipeableClose, handleSwipeableWillOpen, + movePinnedThread, openPendingTask, + pinReorderEnvironmentIds, pinThread, pinningEnvironmentIds, projectByKey, 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 829652f61e2..eb618146172 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -548,6 +548,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { [ handleArchive, handleDelete, + handleMovePinnedDown, + handleMovePinnedUp, handlePin, handleSettle, handleSnooze, diff --git a/packages/client-runtime/src/state/threadSort.test.ts b/packages/client-runtime/src/state/threadSort.test.ts index 00e1c5b2b1a..f4ea270a001 100644 --- a/packages/client-runtime/src/state/threadSort.test.ts +++ b/packages/client-runtime/src/state/threadSort.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vite-plus/test"; -import { planPinnedMove, sortThreads, type ThreadSortInput } from "./threadSort.ts"; +import { + planPinnedMove, + sortPinnedThreadsByOrderKey, + sortThreads, + type ThreadSortInput, +} from "./threadSort.ts"; type TestThread = { readonly id: string } & ThreadSortInput; @@ -115,3 +120,23 @@ describe("planPinnedMove", () => { expect([...keys].sort()).toEqual(keys); }); }); + +describe("sortPinnedThreadsByOrderKey", () => { + it("breaks equal keys by id THEN environment so merged lists are stable everywhere", () => { + const sorted = sortPinnedThreadsByOrderKey([ + { + id: "thread-1", + createdAt: "2026-03-09T10:00:00.000Z", + pinOrderKey: "m", + environmentId: "env-b", + }, + { + id: "thread-1", + createdAt: "2026-03-09T11:00:00.000Z", + pinOrderKey: "m", + environmentId: "env-a", + }, + ]); + expect(sorted.map((thread) => thread.environmentId)).toEqual(["env-a", "env-b"]); + }); +}); diff --git a/packages/client-runtime/src/state/threadSort.ts b/packages/client-runtime/src/state/threadSort.ts index 236b7cc1e24..9352d58dbc8 100644 --- a/packages/client-runtime/src/state/threadSort.ts +++ b/packages/client-runtime/src/state/threadSort.ts @@ -225,6 +225,10 @@ export function sortPinnedThreadsByOrderKey< readonly id: string; readonly createdAt: string; readonly pinOrderKey?: string | null | undefined; + /** Thread ids are only unique within an environment, and the pinned + block merges environments — the tiebreak needs both parts or two + clients could render equal-key threads in stream-arrival order. */ + readonly environmentId?: string | undefined; }, >(threads: readonly T[]): T[] { const keyed: T[] = []; @@ -232,17 +236,20 @@ export function sortPinnedThreadsByOrderKey< for (const thread of threads) { (thread.pinOrderKey != null ? keyed : keyless).push(thread); } + const identityTiebreak = (left: T, right: T) => + left.id.localeCompare(right.id) || + (left.environmentId ?? "").localeCompare(right.environmentId ?? ""); keyed.sort((left, right) => { const leftKey = left.pinOrderKey!; const rightKey = right.pinOrderKey!; - return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : left.id.localeCompare(right.id); + return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : identityTiebreak(left, right); }); keyless.sort((left, right) => { const leftMs = Date.parse(left.createdAt); const rightMs = Date.parse(right.createdAt); return ( (Number.isNaN(rightMs) ? 0 : rightMs) - (Number.isNaN(leftMs) ? 0 : leftMs) || - left.id.localeCompare(right.id) + identityTiebreak(left, right) ); }); return [...keyed, ...keyless]; From cec4a57e3687cc0611eabb2cd1a66c80b5f12c9d Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 7 Aug 2026 06:48:21 -0700 Subject: [PATCH 5/5] test(server): cover pinOrderKey retention, reorder, and clear through projections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit follow-ups: - projector: full pin order key lifecycle — fresh pin stores the key, legacy thread.pinned events without the field preserve it, reorder persists the new slot, unpin clears it - snapshot query: thread-1 fixture now carries a persisted pinned_at + pin_order_key, asserting the non-null path end to end through SQLite hydration into both the thread and shell surfaces - docs: topOfPinnedRunOrderKey comment says undefined, matching the type Co-Authored-By: Claude Fable 5 --- .../Layers/ProjectionSnapshotQuery.test.ts | 12 ++- .../orchestration/projector.pinned.test.ts | 77 +++++++++++++++++++ apps/web/src/hooks/useThreadActions.ts | 4 +- 3 files changed, 87 insertions(+), 6 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 0e328cd955a..c89124751b5 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -87,6 +87,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { pending_approval_count, pending_user_input_count, has_actionable_proposed_plan, + pinned_at, + pin_order_key, created_at, updated_at, deleted_at @@ -105,6 +107,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { 1, 0, 0, + '2026-02-24T00:00:01.000Z', + 'gm', '2026-02-24T00:00:02.000Z', '2026-02-24T00:00:03.000Z', NULL @@ -317,8 +321,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { settledAt: null, snoozedUntil: null, snoozedAt: null, - pinnedAt: null, - pinOrderKey: null, + pinnedAt: "2026-02-24T00:00:01.000Z", + pinOrderKey: "gm", titleRegeneration: null, deletedAt: null, messages: [ @@ -434,8 +438,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { settledAt: null, snoozedUntil: null, snoozedAt: null, - pinnedAt: null, - pinOrderKey: null, + pinnedAt: "2026-02-24T00:00:01.000Z", + pinOrderKey: "gm", titleRegeneration: null, session: { threadId: ThreadId.make("thread-1"), diff --git a/apps/server/src/orchestration/projector.pinned.test.ts b/apps/server/src/orchestration/projector.pinned.test.ts index 35bd063667a..791bd4b75e6 100644 --- a/apps/server/src/orchestration/projector.pinned.test.ts +++ b/apps/server/src/orchestration/projector.pinned.test.ts @@ -75,3 +75,80 @@ it.effect("projects pin lifecycle events", () => expect(unpinned.threads[0]?.pinnedAt).toBeNull(); }), ); + +it.effect("projects pin order key lifecycle", () => + Effect.gen(function* () { + const now = "2026-01-01T00:00:00.000Z"; + const created = yield* projectEvent( + createEmptyReadModel(now), + makeEvent({ + sequence: 1, + type: "thread.created", + payload: { + threadId: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { provider: "codex", model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }), + ); + expect(created.threads[0]?.pinOrderKey ?? null).toBeNull(); + + // Fresh pin carries the client's slot in the arranged order. + const pinned = yield* projectEvent( + created, + makeEvent({ + sequence: 2, + type: "thread.pinned", + payload: { + threadId: ThreadId.make("thread-1"), + pinnedAt: now, + pinOrderKey: "g", + updatedAt: now, + }, + }), + ); + expect(pinned.threads[0]?.pinOrderKey).toBe("g"); + + // Re-pins and events from pre-reorder servers omit the field entirely; + // the existing key must survive rather than being nulled out. + const repinned = yield* projectEvent( + pinned, + makeEvent({ + sequence: 3, + type: "thread.pinned", + payload: { threadId: ThreadId.make("thread-1"), pinnedAt: now, updatedAt: now }, + }), + ); + expect(repinned.threads[0]?.pinOrderKey).toBe("g"); + + // A drag persists the new slot. + const reordered = yield* projectEvent( + repinned, + makeEvent({ + sequence: 4, + type: "thread.pin-reordered", + payload: { threadId: ThreadId.make("thread-1"), orderKey: "m", updatedAt: now }, + }), + ); + expect(reordered.threads[0]?.pinOrderKey).toBe("m"); + + // Unpin clears the slot: re-pinning is "pin again", not "restore an + // ancient position". + const unpinned = yield* projectEvent( + reordered, + makeEvent({ + sequence: 5, + type: "thread.unpinned", + payload: { threadId: ThreadId.make("thread-1"), updatedAt: now }, + }), + ); + expect(unpinned.threads[0]?.pinOrderKey).toBeNull(); + }), +); diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 742bd7e5bf4..22548b23360 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -100,8 +100,8 @@ export class ThreadSnoozeBlockedError extends Schema.TaggedErrorClass