diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index 6a5508437fb55..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,11 +270,19 @@ 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": 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"; @@ -413,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 cc9c27770886e..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,11 +960,13 @@ 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": return sendPin(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 98f69e9de5a09..aa6bb76437c2c 100644 --- a/apps/web/src/hooks/useThreadActions.undo.test.ts +++ b/apps/web/src/hooks/useThreadActions.undo.test.ts @@ -18,6 +18,7 @@ const commands = vi.hoisted(() => ({ unsettle: vi.fn(), snooze: vi.fn(), unsnooze: vi.fn(), + reorderActive: vi.fn(), })); const router = vi.hoisted(() => { const listeners = new Set<(event: { action: { type: string } }) => void>(); @@ -61,6 +62,8 @@ const threadShell = vi.hoisted(() => ({ title: "Thread", 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", @@ -94,6 +97,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(); } @@ -119,6 +124,8 @@ beforeEach(() => { router.state.location.href = "/"; router.state.matches[0]!.params = {}; threadShell.pinnedAt = null; + threadShell.activeOrderKey = null; + threadShell.unsettledAt = null; threadShell.snoozedUntil = null; shellLoaded.current = true; }); @@ -311,6 +318,35 @@ 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("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 c9a8ac402bee5..20ea56400c284 100644 --- a/packages/client-runtime/src/state/threadUndo.ts +++ b/packages/client-runtime/src/state/threadUndo.ts @@ -36,9 +36,10 @@ 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 } | { readonly action: "snooze"; readonly snoozedUntil: string }; export type ThreadUndoPlan = readonly [ThreadUndoStep, ...ThreadUndoStep[]]; @@ -48,6 +49,8 @@ export function planThreadUndo( thread?: { readonly pinnedAt?: string | null; readonly pinOrderKey?: string | null; + readonly activeOrderKey?: string | null; + readonly unsettledAt?: string | null; readonly snoozedUntil?: string | null; } | null, ): ThreadUndoPlan { @@ -62,7 +65,18 @@ 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 }] + : []; + 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"),