Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 19 additions & 4 deletions apps/mobile/src/features/home/useThreadListActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
);

Expand Down
21 changes: 20 additions & 1 deletion apps/server/src/orchestration-v2/Orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"], {
Expand Down Expand Up @@ -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,
};
}
Expand Down
12 changes: 9 additions & 3 deletions apps/web/src/hooks/useThreadActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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 }),
},
});
};

Expand Down Expand Up @@ -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,
Expand Down
36 changes: 36 additions & 0 deletions apps/web/src/hooks/useThreadActions.undo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>();
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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();
}
Expand All @@ -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;
});
Expand Down Expand Up @@ -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";
Expand Down
4 changes: 4 additions & 0 deletions packages/client-runtime/src/operations/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 }),
});
});

Expand Down
9 changes: 8 additions & 1 deletion packages/client-runtime/src/state/threadCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -493,7 +493,14 @@ export function createThreadEnvironmentAtoms<R, E>(
...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 &&
Expand Down
18 changes: 16 additions & 2 deletions packages/client-runtime/src/state/threadUndo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]];
Expand All @@ -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 {
Expand All @@ -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<Result>(
Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/orchestrationV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
Loading