Skip to content
Merged
2 changes: 2 additions & 0 deletions apps/mobile/src/features/home/HomeRouteScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ThreadUndoNotice } from "./ThreadUndoNotice";
import { Toolbar } from "../../components/ui/Toolbar";
import { use } from "react";
import { NativePrimaryColumnContext } from "../../native/v5-workspace-context";
Expand Down Expand Up @@ -306,6 +307,7 @@ export function HomeRouteScreen() {
selectedProjectKey={selectedProjectKey}
threads={threads}
/>
<ThreadUndoNotice visible={visible} />
</>
</AndroidHomeFabLayout>
);
Expand Down
29 changes: 29 additions & 0 deletions apps/mobile/src/features/home/ThreadUndoNotice.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Toast } from "../../components/ui/Toast";
import { APP_BAR_HEIGHT } from "../../lib/layoutMetrics";
import { dismissThreadUndoNotice, useThreadUndoNotice } from "./thread-undo-notice";

export function ThreadUndoNotice(props: { readonly visible: boolean }) {
const notice = useThreadUndoNotice();
const insets = useSafeAreaInsets();
if (!props.visible || notice === null) return null;
return (
<View
className="absolute inset-x-3 z-[100]"
style={{ top: insets.top + APP_BAR_HEIGHT + 8 }}
pointerEvents="box-none"
>
<Toast
message={`${notice.action} thread`}
action={{
label: "Undo",
onPress: () => {
void notice.undo();
},
}}
onDismiss={dismissThreadUndoNotice}
/>
</View>
);
}
56 changes: 56 additions & 0 deletions apps/mobile/src/features/home/thread-undo-notice.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import * as ThreadUndo from "@t3tools/client-runtime/state/thread-undo";
import { useSyncExternalStore } from "react";

type UndoNotice = {
readonly action: "Settled" | "Snoozed" | "Unpinned";
readonly claim: ReturnType<typeof ThreadUndo.begin>;
readonly undo: () => Promise<void>;
};

let notice: UndoNotice | null = null;
let expiry: ReturnType<typeof setTimeout> | undefined;
const listeners = new Set<() => void>();

function notify() {
for (const listener of listeners) listener();
}

export function readThreadUndoNotice() {
return notice;
}

export function dismissThreadUndoNotice() {
const previous = notice;
notice = null;
clearTimeout(expiry);
previous?.claim.finish();
notify();
}

ThreadUndo.subscribe(() => {
if (notice !== null && !notice.claim.isCurrent()) dismissThreadUndoNotice();
});

export function showThreadUndoNotice(options: UndoNotice) {
if (!options.claim.isCurrent()) return;
dismissThreadUndoNotice();
notice = {
...options,
undo: async () => {
if (notice?.claim !== options.claim || !options.claim.isCurrent()) return;
dismissThreadUndoNotice();
await options.undo();
},
};
notify();
expiry = setTimeout(dismissThreadUndoNotice, 5_000);
}

export function useThreadUndoNotice() {
return useSyncExternalStore((listener) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}, readThreadUndoNotice);
}
210 changes: 207 additions & 3 deletions apps/mobile/src/features/home/useThreadListActions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
EnvironmentId,
ProjectId,
ProviderInstanceId,
RunId,
ThreadId,
} from "@t3tools/contracts";
import * as Cause from "effect/Cause";
Expand All @@ -14,12 +15,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"
const state = vi.hoisted(() => ({
pendingOrder: null as object | null,
dropBusy: false,
failedAction: null as string | null,
archivedRefreshes: [] as string[],
scopes: new Map<string, Set<string>>(),
shells: [] as EnvironmentThreadShell[],
requests: [] as {
action: string;
environmentId: string;
input: { threadId: string; orderKey?: string };
input: { threadId: string; orderKey?: string; snoozedUntil?: string; reason?: string };
}[],
dialogs: [] as { onConfirm: () => void }[],
alerts: [] as {
Expand Down Expand Up @@ -49,7 +52,9 @@ vi.mock("../../components/ui/Dialog", () => ({
confirm: (dialog: { onConfirm: () => void }) => state.dialogs.push(dialog),
}));
vi.mock("../archive/useArchivedThreadSnapshots", () => ({
refreshArchivedThreadsForEnvironment: () => {},
refreshArchivedThreadsForEnvironment: (environmentId: string) => {
state.archivedRefreshes.push(environmentId);
},
}));
vi.mock("../../state/session", () => ({
readEnvironmentScope: (environmentId: string, scope: string) =>
Expand Down Expand Up @@ -133,13 +138,16 @@ vi.mock("../../state/threads", () => ({
if (!state.scopes.get(request.environmentId)?.has(AuthOrchestrationOperateScope)) {
return AsyncResult.failure(Cause.fail(new Error("Thread operation denied")));
}
if (action === state.failedAction)
return AsyncResult.failure(Cause.fail(new Error("Server rejected action")));
state.afterRequest?.();
return AsyncResult.success(undefined);
},
]),
),
}));

import { dismissThreadUndoNotice, readThreadUndoNotice } from "./thread-undo-notice";
import { useArchivedThreadListActions, useThreadListActions } from "./useThreadListActions";

const primaryEnvironmentId = EnvironmentId.make("primary");
Expand Down Expand Up @@ -168,6 +176,8 @@ const mutationCases = [
] as const;

beforeEach(() => {
state.failedAction = null;
state.archivedRefreshes = [];
state.pendingOrder = null;
state.dropBusy = false;
state.scopes = new Map([
Expand All @@ -181,7 +191,11 @@ beforeEach(() => {
state.afterRequest = undefined;
});

afterEach(() => vi.unstubAllEnvs());
afterEach(() => {
dismissThreadUndoNotice();
vi.useRealTimers();
vi.unstubAllEnvs();
});

describe("thread list operation permissions", () => {
it.each(mutationCases)(
Expand Down Expand Up @@ -315,3 +329,193 @@ describe("pinned thread operation permissions", () => {
expect(state.requests).toHaveLength(1);
});
});

describe("thread action undo", () => {
it.each([
["settleThread", "settle", "Settled", "unsettle"],
["snoozeThread", "snooze", "Snoozed", "unsnooze"],
] as const)(
"%s keeps the successful request's undo when a duplicate is rejected",
async (handler, action, notice, inverse) => {
const actions = useThreadListActions();
const thread = makeThread();
const first = actions[handler](thread, "2099-01-01T09:00:00.000Z");

expect(await actions[handler](thread, "2099-01-01T09:00:00.000Z")).toBe(false);
expect(await first).toBe(true);
expect(state.requests.map(({ action }) => action)).toEqual([action]);
expect(readThreadUndoNotice()?.action).toBe(notice);
await readThreadUndoNotice()!.undo();
expect(state.requests.map(({ action }) => action)).toEqual([action, inverse]);
},
);

it("keeps the latest unpin undo after concurrent unpin requests", async () => {
const actions = useThreadListActions();
const thread = makeThread();
expect(await Promise.all([actions.unpinThread(thread), actions.unpinThread(thread)])).toEqual([
true,
true,
]);
expect(readThreadUndoNotice()?.action).toBe("Unpinned");
await readThreadUndoNotice()!.undo();
expect(state.requests.map(({ action }) => action)).toEqual(["unpin", "unpin", "pin"]);
});

it("rejects a concurrent archive request without sending another mutation", async () => {
const actions = useThreadListActions();
const thread = makeThread();
const first = actions.archiveThread(thread);
expect(await actions.archiveThread(thread)).toBe(false);
expect(await first).toBe(true);
expect(state.requests.map(({ action }) => action)).toEqual(["archive"]);
expect(state.archivedRefreshes).toEqual([primaryEnvironmentId]);
});

it("restores pin placement and snooze after settling, only once", async () => {
const thread = makeThread({
pinnedAt: "2026-09-02T00:00:00.000Z",
pinOrderKey: "m",
snoozedUntil: "2099-01-01T09:00:00.000Z",
});
expect(await useThreadListActions().settleThread(thread)).toBe(true);
const undo = readThreadUndoNotice()!.undo;
expect(readThreadUndoNotice()?.action).toBe("Settled");
await undo();
await undo();
expect(state.requests.map(({ action }) => action)).toEqual([
"settle",
"unsettle",
"pin",
"snooze",
]);
expect(state.requests[2]?.input).toEqual({ threadId: thread.id, orderKey: "m" });
expect(state.requests[3]?.input).toEqual({
threadId: thread.id,
snoozedUntil: thread.snoozedUntil,
});
expect(readThreadUndoNotice()).toBeNull();
});

it("wakes a snoozed thread through the undo action", async () => {
const thread = makeThread();
await useThreadListActions().snoozeThread(thread, "2099-01-01T09:00:00.000Z");
expect(readThreadUndoNotice()?.action).toBe("Snoozed");
await readThreadUndoNotice()!.undo();
expect(state.requests.at(-1)).toMatchObject({
action: "unsnooze",
environmentId: thread.environmentId,
input: { threadId: thread.id, reason: "user" },
});
});

it.each([null, "2099-01-01T09:00:00.000Z", "2000-01-01T09:00:00.000Z"])(
"restores an unpinned thread's previous position and unexpired snooze (%s)",
async (snoozedUntil) => {
const thread = makeThread({
pinnedAt: "2026-09-02T00:00:00.000Z",
pinOrderKey: "m",
snoozedUntil,
});
await useThreadListActions().unpinThread(thread);
expect(readThreadUndoNotice()?.action).toBe("Unpinned");
await readThreadUndoNotice()!.undo();
const restoresSnooze = snoozedUntil != null && Date.parse(snoozedUntil) > Date.now();
expect(state.requests.map(({ action }) => action)).toEqual(
restoresSnooze ? ["unpin", "pin", "snooze"] : ["unpin", "pin"],
);
expect(state.requests[1]).toMatchObject({
action: "pin",
input: { threadId: thread.id, orderKey: "m" },
});
if (restoresSnooze) {
expect(state.requests[2]).toMatchObject({
action: "snooze",
environmentId: thread.environmentId,
input: { threadId: thread.id, snoozedUntil },
});
}
},
);

it("does not restore snooze if undoing an unpin fails", async () => {
const thread = makeThread({
pinnedAt: "2026-09-02T00:00:00.000Z",
snoozedUntil: "2099-01-01T09:00:00.000Z",
});
await useThreadListActions().unpinThread(thread);
state.failedAction = "pin";
await readThreadUndoNotice()!.undo();
expect(state.requests.map(({ action }) => action)).toEqual(["unpin", "pin"]);
});

it("invalidates a retained undo when another hook pins the thread", async () => {
const thread = makeThread();
await useThreadListActions().unpinThread(thread);
const undo = readThreadUndoNotice()!.undo;
await useThreadListActions().pinThread(thread);
await undo();
expect(state.requests.map(({ action }) => action)).toEqual(["unpin", "pin"]);
expect(readThreadUndoNotice()).toBeNull();
});

it("does not offer undo for a failed action", async () => {
state.failedAction = "settle";
expect(await useThreadListActions().settleThread(makeThread())).toBe(false);
expect(readThreadUndoNotice()).toBeNull();
});

it("stops restoring after a failed inverse command", async () => {
const thread = makeThread({
pinnedAt: "2026-09-02T00:00:00.000Z",
snoozedUntil: "2099-01-01T09:00:00.000Z",
});
await useThreadListActions().settleThread(thread);
state.failedAction = "unsettle";
await readThreadUndoNotice()!.undo();
expect(state.requests.map(({ action }) => action)).toEqual(["settle", "unsettle"]);
expect(state.alerts.at(-1)?.title).toBe("Could not un-settle thread");
});

it("rechecks the destination permission before undoing", async () => {
await useThreadListActions().unpinThread(makeThread());
state.scopes.get(primaryEnvironmentId)!.clear();
await readThreadUndoNotice()!.undo();
expect(state.requests.map(({ action }) => action)).toEqual(["unpin"]);
});

it("expires undo after five seconds", async () => {
vi.useFakeTimers();
await useThreadListActions().unpinThread(makeThread());
const undo = readThreadUndoNotice()!.undo;
vi.advanceTimersByTime(5_000);
expect(readThreadUndoNotice()).toBeNull();
await undo();
expect(state.requests.map(({ action }) => action)).toEqual(["unpin"]);
});

it("refreshes the archive after archiving on a settlement-capable server", async () => {
await useThreadListActions().archiveThread(makeThread());
expect(state.requests.map(({ action }) => action)).toEqual(["archive"]);
expect(state.archivedRefreshes).toEqual([primaryEnvironmentId]);
});
});

describe("thread archive guard", () => {
it("keeps active provider runs out of the archive", async () => {
const thread = makeThread({
runtime: {
status: "running",
activeRunId: RunId.make("active-run"),
providerInstanceId: ProviderInstanceId.make("codex"),
providerName: "codex",
lastError: null,
updatedAt: "2026-09-02T00:00:00.000Z",
},
});
expect(await useThreadListActions().archiveThread(thread)).toBe(false);
expect(state.requests).toEqual([]);
expect(state.archivedRefreshes).toEqual([]);
expect(state.alerts.at(-1)?.title).toBe("Could not archive thread");
});
});
Loading
Loading