From dbc60262e576d31e55b2a8c9defb07c063dfb551 Mon Sep 17 00:00:00 2001 From: Alex Southwell <4596216+saphid@users.noreply.github.com> Date: Mon, 9 Nov 2026 19:36:38 +1100 Subject: [PATCH 1/2] fix(clients): undoing a settle puts the thread back where you placed it Co-Authored-By: Claude Opus 5.5 --- .../src/features/home/useThreadListActions.ts | 8 ++++++++ apps/web/src/hooks/useThreadActions.ts | 2 ++ .../src/hooks/useThreadActions.undo.test.ts | 19 +++++++++++++++++++ .../client-runtime/src/state/threadUndo.ts | 11 ++++++++++- 4 files changed, 39 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index 3893569c6886d..aebbe53d2b784 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -245,6 +245,14 @@ export function useThreadListActions(): { return unsnoozeThread(thread); case "pin": return pinThread(thread, step.orderKey); + case "reorderActive": { + const result = await reorderActiveMutation({ + environmentId: thread.environmentId, + input: { threadId: thread.id, orderKey: step.orderKey }, + }); + if (result._tag === "Failure") throw Cause.squash(result.cause); + return true; + } case "snooze": { const failureTitle = action === "Unpinned" ? "Could not undo unpin" : "Could not undo settle"; diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 1b8c3001eac48..7d8bd2cf3c578 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -911,6 +911,8 @@ export function useThreadActions() { target, step.orderKey === undefined ? {} : { orderKey: step.orderKey }, ); + case "reorderActive": + return reorderActiveThread(target, step.orderKey); case "snooze": return snoozeThreadMutation({ environmentId: target.environmentId, diff --git a/apps/web/src/hooks/useThreadActions.undo.test.ts b/apps/web/src/hooks/useThreadActions.undo.test.ts index 5ec8f1fc64304..b42a769d943ef 100644 --- a/apps/web/src/hooks/useThreadActions.undo.test.ts +++ b/apps/web/src/hooks/useThreadActions.undo.test.ts @@ -17,6 +17,7 @@ const commands = vi.hoisted(() => ({ unsettle: vi.fn(), snooze: vi.fn(), unsnooze: vi.fn(), + reorderActive: vi.fn(), })); const router = vi.hoisted(() => ({ navigate: vi.fn(async () => {}), @@ -49,6 +50,7 @@ const threadShell = vi.hoisted(() => ({ title: "Thread", pinOrderKey: "a0", pinnedAt: null as string | null, + activeOrderKey: null as string | null, snoozedUntil: null as string | null, projectId: "project", environmentId: "undo-env", @@ -82,6 +84,8 @@ vi.mock("../state/use-atom-command", () => ({ return commands.snooze; case threadEnvironment.unsnooze: return commands.unsnooze; + case threadEnvironment.reorderActive: + return commands.reorderActive; default: return vi.fn(); } @@ -107,6 +111,7 @@ beforeEach(() => { router.state.location.href = "/"; router.state.matches[0]!.params = {}; threadShell.pinnedAt = null; + threadShell.activeOrderKey = null; threadShell.snoozedUntil = null; shellLoaded.current = true; }); @@ -270,6 +275,20 @@ describe("settle and snooze Undo", () => { }); }); + it("puts a manually ordered thread back in its active position", async () => { + threadShell.activeOrderKey = "b5"; + await useThreadActions().settleThread(target); + await currentUndo()(); + expect(commands.unsettle).toHaveBeenCalledOnce(); + expect(commands.reorderActive).toHaveBeenCalledExactlyOnceWith({ + environmentId: target.environmentId, + input: { threadId: target.threadId, orderKey: "b5" }, + }); + expect(commands.unsettle.mock.invocationCallOrder[0]).toBeLessThan( + commands.reorderActive.mock.invocationCallOrder[0]!, + ); + }); + it("stops settle restoration if restoring the pin fails", async () => { threadShell.pinnedAt = "2026-01-01T00:00:00.000Z"; threadShell.snoozedUntil = "2030-01-01T09:00:00.000Z"; diff --git a/packages/client-runtime/src/state/threadUndo.ts b/packages/client-runtime/src/state/threadUndo.ts index c9a8ac402bee5..4ee6cf7b903ca 100644 --- a/packages/client-runtime/src/state/threadUndo.ts +++ b/packages/client-runtime/src/state/threadUndo.ts @@ -39,6 +39,7 @@ export type ThreadUndoStep = | { readonly action: "unsettle" } | { readonly action: "unsnooze" } | { readonly action: "pin"; readonly orderKey?: string } + | { readonly action: "reorderActive"; readonly orderKey: string } | { readonly action: "snooze"; readonly snoozedUntil: string }; export type ThreadUndoPlan = readonly [ThreadUndoStep, ...ThreadUndoStep[]]; @@ -48,6 +49,7 @@ export function planThreadUndo( thread?: { readonly pinnedAt?: string | null; readonly pinOrderKey?: string | null; + readonly activeOrderKey?: string | null; readonly snoozedUntil?: string | null; } | null, ): ThreadUndoPlan { @@ -62,7 +64,14 @@ export function planThreadUndo( ? [{ action: "snooze" as const, snoozedUntil: thread.snoozedUntil }] : []; if (action === "unpin") return [pin, ...snooze]; - return [{ action: "unsettle" }, ...(thread?.pinnedAt != null ? [pin] : []), ...snooze]; + // Settling clears a manual active position; pinned threads are placed by their pin instead. + const placement: ThreadUndoStep[] = + thread?.pinnedAt != null + ? [pin] + : thread?.activeOrderKey != null + ? [{ action: "reorderActive", orderKey: thread.activeOrderKey }] + : []; + return [{ action: "unsettle" }, ...placement, ...snooze]; } export async function executeThreadUndo( From 201f7e8bcb976ffbb3d2a97d56daadee5adcfa0a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 15:49:40 +0000 Subject: [PATCH 2/2] fix(clients): undoing a settle keeps an automatically ordered thread in place Settle clears unsettledAt, and Unsettle stamps it with the current time, so undoing a settle moved a thread without a manual Active key to the top. The Undo plan now sends the thread's previous anchor with thread.unsettle, and the server restores it instead of stamping a new one. A plain Unsettle is unchanged. Co-Authored-By: Claude Opus 5.5 --- .../src/features/home/useThreadListActions.ts | 15 +++++++++---- .../Orchestrator.control-reads.test.ts | 21 +++++++++++++++++++ .../src/orchestration-v2/Orchestrator.ts | 21 ++++++++++++++++++- apps/web/src/hooks/useThreadActions.ts | 10 ++++++--- .../src/hooks/useThreadActions.undo.test.ts | 17 +++++++++++++++ .../client-runtime/src/operations/commands.ts | 4 ++++ .../src/state/threadCommands.ts | 9 +++++++- .../client-runtime/src/state/threadUndo.ts | 9 ++++++-- packages/contracts/src/orchestrationV2.ts | 2 ++ 9 files changed, 97 insertions(+), 11 deletions(-) diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index 1b53bc1e79101..b100b6aa5e6da 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -116,6 +116,7 @@ function useThreadActionExecutor( action: ThreadListAction, thread: EnvironmentThreadShell, onStarted?: () => void, + restoreUnsettledAt?: string | null, ) => { if (!checkThreadOperationPermission(thread, actionFailureTitle(action))) return false; const key = scopedThreadKey(thread.environmentId, thread.id); @@ -155,7 +156,11 @@ function useThreadActionExecutor( // suppressed until real activity clears the pin server-side. await unsettleMutation({ environmentId: thread.environmentId, - input: { threadId: thread.id, reason: "user" }, + input: { + threadId: thread.id, + reason: "user", + ...(restoreUnsettledAt === undefined ? {} : { restoreUnsettledAt }), + }, }) : await ( action === "settle" @@ -265,7 +270,7 @@ export function useThreadListActions(): { async (step) => { switch (step.action) { case "unsettle": - return unsettleThread(thread); + return unsettleThread(thread, step.restoreUnsettledAt); case "unsnooze": return unsnoozeThread(thread); case "pin": @@ -421,8 +426,10 @@ export function useThreadListActions(): { snoozeInFlightThreadKeys.current.delete(key); return succeeded; }; - const unsettleThread = async (thread: EnvironmentThreadShell) => - (await executeAction("unsettle", thread)) === true; + const unsettleThread = async ( + thread: EnvironmentThreadShell, + restoreUnsettledAt?: string | null, + ) => (await executeAction("unsettle", thread, undefined, restoreUnsettledAt)) === true; const pinThread = async (thread: EnvironmentThreadShell, restoreOrderKey?: string) => { if (!checkThreadOperationPermission(thread, "Could not pin thread")) return false; if (!environmentConfigLoaded(thread.environmentId)) { diff --git a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts index 2845860efac20..28cf03313b2d6 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts @@ -2005,6 +2005,27 @@ it.effect("explicit settle clears snooze atomically and stays idempotent after r } as const; assert.deepInclude(yield* projections.getThreadShell(threadId), active); assert.deepInclude((yield* projections.getThreadProjection(threadId)).thread, active); + + // Undo of a settle restores the cleared Active anchor instead of stamping a new one. + for (const restoreUnsettledAt of [DateTime.formatIso(now), null]) { + yield* TestClock.adjust("1 second"); + yield* orchestrator.dispatch({ + type: "thread.settle", + commandId: CommandId.make(`settle-before-undo-${restoreUnsettledAt}`), + threadId, + }); + yield* orchestrator.dispatch({ + type: "thread.unsettle", + commandId: CommandId.make(`undo-settle-${restoreUnsettledAt}`), + threadId, + reason: "user", + restoreUnsettledAt, + }); + assert.deepInclude(yield* projections.getThreadShell(threadId), { + ...active, + unsettledAt: restoreUnsettledAt === null ? null : now, + }); + } }).pipe(Effect.provide(layerTest)), ); diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index ad2fb655a116f..ad6c9ecc3c6c7 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -4499,6 +4499,21 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }); } } + let restoredUnsettledAt: DateTime.Utc | null | undefined; + if (command.type === "thread.unsettle" && command.restoreUnsettledAt !== undefined) { + const parsed = + command.restoreUnsettledAt === null + ? Option.some(null) + : DateTime.make(command.restoreUnsettledAt); + if (Option.isNone(parsed)) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Thread ${command.threadId} unsettle anchor ${command.restoreUnsettledAt} is not a valid time.`, + }); + } + restoredUnsettledAt = parsed.value; + } let snoozedUntil: DateTime.Utc | null = null; if (command.type === "thread.snooze") { const projection = yield* loadProjectionForCommand(command, ["runs", "runtimeRequests"], { @@ -4628,7 +4643,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...thread, settledOverride: "active", settledAt: null, - unsettledAt: alreadyPinnedActive ? (thread.unsettledAt ?? null) : now, + unsettledAt: alreadyPinnedActive + ? (thread.unsettledAt ?? null) + : restoredUnsettledAt === undefined + ? now + : restoredUnsettledAt, updatedAt: alreadyPinnedActive ? thread.updatedAt : now, }; } diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index f1a7c022076de..fe3dfa3aff869 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -698,7 +698,7 @@ export function useThreadActions() { return deleteResult; }; - const unsettleThread = async (target: ScopedThreadRef) => { + const unsettleThread = async (target: ScopedThreadRef, restoreUnsettledAt?: string | null) => { if (!readEnvironmentServerConfigLoaded(target.environmentId)) { return AsyncResult.failure( Cause.fail( @@ -713,7 +713,11 @@ export function useThreadActions() { // reason "user" pins active until real activity, suppressing auto-settle. return unsettleThreadMutation({ environmentId: target.environmentId, - input: { threadId: target.threadId, reason: "user" }, + input: { + threadId: target.threadId, + reason: "user", + ...(restoreUnsettledAt === undefined ? {} : { restoreUnsettledAt }), + }, }); }; @@ -956,7 +960,7 @@ export function useThreadActions() { async (step) => { switch (step.action) { case "unsettle": - return unsettleThread(target); + return unsettleThread(target, step.restoreUnsettledAt); case "unsnooze": return unsnoozeThread(target); case "pin": diff --git a/apps/web/src/hooks/useThreadActions.undo.test.ts b/apps/web/src/hooks/useThreadActions.undo.test.ts index fe3e0990cc791..aa6bb76437c2c 100644 --- a/apps/web/src/hooks/useThreadActions.undo.test.ts +++ b/apps/web/src/hooks/useThreadActions.undo.test.ts @@ -63,6 +63,7 @@ const threadShell = vi.hoisted(() => ({ pinOrderKey: "a0", pinnedAt: null as string | null, activeOrderKey: null as string | null, + unsettledAt: null as string | null, snoozedUntil: null as string | null, projectId: "project", environmentId: "undo-env", @@ -124,6 +125,7 @@ beforeEach(() => { router.state.matches[0]!.params = {}; threadShell.pinnedAt = null; threadShell.activeOrderKey = null; + threadShell.unsettledAt = null; threadShell.snoozedUntil = null; shellLoaded.current = true; }); @@ -330,6 +332,21 @@ describe("settle and snooze Undo", () => { ); }); + it("puts an automatically ordered thread back at its Active anchor", async () => { + threadShell.unsettledAt = "2026-01-02T00:00:00.000Z"; + await useThreadActions().settleThread(target); + await currentUndo()(); + expect(commands.unsettle).toHaveBeenCalledExactlyOnceWith({ + environmentId: target.environmentId, + input: { + threadId: target.threadId, + reason: "user", + restoreUnsettledAt: "2026-01-02T00:00:00.000Z", + }, + }); + expect(commands.reorderActive).not.toHaveBeenCalled(); + }); + it("stops settle restoration if restoring the pin fails", async () => { threadShell.pinnedAt = "2026-01-01T00:00:00.000Z"; threadShell.snoozedUntil = "2030-01-01T09:00:00.000Z"; diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index c6f5c8c34166e..50717d0cafd41 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -105,6 +105,7 @@ export type SettleThreadInput = ThreadCommandInput; export interface UnsettleThreadInput extends ThreadCommandInput { readonly reason: "user"; + readonly restoreUnsettledAt?: string | null; } export interface PinThreadInput extends ThreadCommandInput { @@ -629,6 +630,9 @@ export const unsettleThread = Effect.fn("EnvironmentCommands.unsettleThread")(fu commandId: yield* allocateCommandId(input), threadId: input.threadId, reason: input.reason, + ...(input.restoreUnsettledAt === undefined + ? {} + : { restoreUnsettledAt: input.restoreUnsettledAt }), }); }); diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index 865f6273b20ad..0e9de03f5c066 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -493,7 +493,14 @@ export function createThreadEnvironmentAtoms( ...thread, settledOverride: input.reason === "user" ? "active" : null, settledAt: null, - unsettledAt: thread.settledOverride === "active" ? (thread.unsettledAt ?? null) : now, + unsettledAt: + thread.settledOverride === "active" + ? (thread.unsettledAt ?? null) + : input.restoreUnsettledAt === undefined + ? now + : input.restoreUnsettledAt === null + ? null + : DateTime.makeUnsafe(input.restoreUnsettledAt), })), snooze: optimistic.wrap(commands.snooze, (thread, input, now, accepted) => (!accepted && diff --git a/packages/client-runtime/src/state/threadUndo.ts b/packages/client-runtime/src/state/threadUndo.ts index 4ee6cf7b903ca..20ea56400c284 100644 --- a/packages/client-runtime/src/state/threadUndo.ts +++ b/packages/client-runtime/src/state/threadUndo.ts @@ -36,7 +36,7 @@ export function invalidate(kind: string, threadKey: string) { } export type ThreadUndoStep = - | { readonly action: "unsettle" } + | { readonly action: "unsettle"; readonly restoreUnsettledAt?: string | null } | { readonly action: "unsnooze" } | { readonly action: "pin"; readonly orderKey?: string } | { readonly action: "reorderActive"; readonly orderKey: string } @@ -50,6 +50,7 @@ export function planThreadUndo( readonly pinnedAt?: string | null; readonly pinOrderKey?: string | null; readonly activeOrderKey?: string | null; + readonly unsettledAt?: string | null; readonly snoozedUntil?: string | null; } | null, ): ThreadUndoPlan { @@ -71,7 +72,11 @@ export function planThreadUndo( : thread?.activeOrderKey != null ? [{ action: "reorderActive", orderKey: thread.activeOrderKey }] : []; - return [{ action: "unsettle" }, ...placement, ...snooze]; + const unsettle: ThreadUndoStep = + thread == null + ? { action: "unsettle" } + : { action: "unsettle", restoreUnsettledAt: thread.unsettledAt ?? null }; + return [unsettle, ...placement, ...snooze]; } export async function executeThreadUndo( diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index ee950ecb43036..44e9aa8290e9a 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -3174,6 +3174,8 @@ export const OrchestrationV2Command = Schema.Union([ commandId: CommandId, threadId: ThreadId, reason: Schema.Literal("user"), + // Undo of a settle restores the Active anchor it cleared instead of moving the thread to the top. + restoreUnsettledAt: Schema.optional(Schema.NullOr(IsoDateTime)), }), Schema.Struct({ type: Schema.Literal("thread.snooze"),