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
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