Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
35 changes: 35 additions & 0 deletions apps/web/src/hooks/threadPinAction.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vite-plus/test";

import * as ThreadPinAction from "./threadPinAction";

describe("pin action ownership", () => {
it("does not revive the first Undo after a later pin and unpin", () => {
const firstUnpin = ThreadPinAction.begin("env/thread");
ThreadPinAction.invalidate("env/thread");
const secondUnpin = ThreadPinAction.begin("env/thread");
expect(firstUnpin.isCurrent()).toBe(false);
expect(secondUnpin.isCurrent()).toBe(true);
firstUnpin.finish();
expect(secondUnpin.isCurrent()).toBe(true);
secondUnpin.finish();
expect(firstUnpin.isCurrent()).toBe(false);
});

it("rejects a late unpin completion after a newer pin started", () => {
const pendingUnpin = ThreadPinAction.begin("env/late");
ThreadPinAction.invalidate("env/late");
expect(pendingUnpin.isCurrent()).toBe(false);
});

it("expires an Undo without invalidating another environment or thread", () => {
const first = ThreadPinAction.begin("one/thread");
const otherEnvironment = ThreadPinAction.begin("two/thread");
const otherThread = ThreadPinAction.begin("one/other");
first.finish();
expect(first.isCurrent()).toBe(false);
expect(otherEnvironment.isCurrent()).toBe(true);
expect(otherThread.isCurrent()).toBe(true);
otherEnvironment.finish();
otherThread.finish();
});
});
20 changes: 20 additions & 0 deletions apps/web/src/hooks/threadPinAction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Shared across hook instances so sidebar, header and menu actions invalidate each other.
const currentActions = new Map<string, symbol>();

/** Claims a thread's pin state for one unpin Undo; any later claim or invalidation expires it. */
export function begin(key: string) {
const token = Symbol();
currentActions.set(key, token);
const isCurrent = () => currentActions.get(key) === token;
return {
isCurrent,
finish: () => {
if (isCurrent()) currentActions.delete(key);
},
};
}

/** Expires any outstanding Undo for the thread, e.g. when it is pinned or reordered. */
export function invalidate(key: string) {
currentActions.delete(key);
}
55 changes: 52 additions & 3 deletions apps/web/src/hooks/useThreadActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import {
scopeThreadRef,
scopedThreadKey,
} from "@t3tools/client-runtime/environment";
import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
import {
isAtomCommandInterrupted,
settlePromise,
squashAtomCommandFailure,
} from "@t3tools/client-runtime/state/runtime";
import { canSnooze, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled";
import { EnvironmentId, type ScopedThreadRef, ThreadId } from "@t3tools/contracts";
import * as Cause from "effect/Cause";
Expand Down Expand Up @@ -39,6 +43,7 @@ import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes";
import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
import { stackedThreadToast, toastManager } from "../components/ui/toast";
import { useClientSettings } from "./useSettings";
import * as ThreadPinAction from "./threadPinAction";
import { useAtomCommand } from "../state/use-atom-command";

export class ThreadArchiveBlockedError extends Schema.TaggedError<ThreadArchiveBlockedError>()(
Expand Down Expand Up @@ -573,6 +578,7 @@ export function useThreadActions() {
const orderKey = readEnvironmentSupportsPinReorder(target.environmentId)
? (opts.orderKey ?? topOfPinnedRunOrderKey())
: undefined;
ThreadPinAction.invalidate(scopedThreadKey(target));
return pinThreadMutation({
environmentId: target.environmentId,
input: {
Expand All @@ -596,12 +602,54 @@ export function useThreadActions() {
),
);
}
return unpinThreadMutation({
const thread = readThreadShell(target);
const orderKey = thread?.pinOrderKey ?? undefined;
const action = ThreadPinAction.begin(scopedThreadKey(target));
const result = await unpinThreadMutation({
environmentId: target.environmentId,
input: { threadId: target.threadId },
});
if (result._tag === "Success" && action.isCurrent()) {
let undoStarted = false;
// Reuses the app's Base UI toast action: https://base-ui.com/react/components/toast
const toastId = toastManager.add({
...stackedThreadToast({
type: "success",
title: "Thread unpinned",
description: thread?.title,
timeout: 5_000,
actionProps: {
children: "Undo",
onClick: () => {
if (undoStarted || !action.isCurrent()) return;
undoStarted = true;
toastManager.close(toastId);
void pinThread(target, orderKey === undefined ? {} : { orderKey }).then(
Comment thread
saphid marked this conversation as resolved.
Outdated
(undone) => {
if (undone._tag === "Failure" && !isAtomCommandInterrupted(undone)) {
const error = squashAtomCommandFailure(undone);
toastManager.add(
stackedThreadToast({
type: "error",
title: "Failed to undo unpin",
description:
error instanceof Error ? error.message : "An error occurred.",
}),
);
}
},
);
},
},
}),
onClose: action.finish,
});
} else {
action.finish();
}
return result;
},
[unpinThreadMutation],
[pinThread, unpinThreadMutation],
);

const confirmAndUnpinThread = useCallback(
Expand Down Expand Up @@ -639,6 +687,7 @@ export function useThreadActions() {
),
);
}
ThreadPinAction.invalidate(scopedThreadKey(target));
return reorderPinnedThreadMutation({
environmentId: target.environmentId,
input: { threadId: target.threadId, orderKey },
Expand Down
69 changes: 69 additions & 0 deletions apps/web/src/hooks/useThreadActions.undo.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { EnvironmentId, ThreadId } from "@t3tools/contracts";
import { afterEach, describe, expect, it, vi } from "vite-plus/test";

import { useThreadActions } from "./useThreadActions";
import { threadEnvironment } from "../state/threads";
import { toastManager } from "../components/ui/toast";

const commands = vi.hoisted(() => ({ pin: vi.fn(), unpin: vi.fn() }));
vi.mock("react", async (original) => ({
...(await original<typeof import("react")>()),
useCallback: (callback: unknown) => callback,
useMemo: (create: () => unknown) => create(),
useRef: (value: unknown) => ({ current: value }),
}));
vi.mock("@tanstack/react-router", () => ({ useRouter: () => ({}) }));
vi.mock("./useSettings", () => ({ useClientSettings: () => false }));
vi.mock("./useHandleNewThread", () => ({ useNewThreadHandler: () => vi.fn() }));
vi.mock("../composerDraftStore", () => ({ useComposerDraftStore: () => vi.fn() }));
vi.mock("../terminalUiStateStore", () => ({ useTerminalUiStateStore: () => vi.fn() }));
vi.mock("../uiStateStore", () => ({ useUiStateStore: () => vi.fn() }));
vi.mock("../state/entities", async (original) => ({
...(await original<typeof import("../state/entities")>()),
readEnvironmentSupportsPinning: () => true,
readEnvironmentSupportsPinReorder: () => true,
readThreadShell: () => ({ title: "Thread", pinOrderKey: "a0" }),
}));
vi.mock("../state/use-atom-command", () => ({
useAtomCommand: (command: unknown) =>
command === threadEnvironment.pin
? commands.pin
: command === threadEnvironment.unpin
? commands.unpin
: vi.fn(),
}));

afterEach(() => vi.restoreAllMocks());

describe("unpin Undo", () => {
it("ignores an old toast across hook instances and still restores the latest unpin", async () => {
commands.pin.mockResolvedValue({ _tag: "Success", value: undefined });
commands.unpin.mockResolvedValue({ _tag: "Success", value: undefined });
const add = vi.spyOn(toastManager, "add").mockReturnValue("toast");
vi.spyOn(toastManager, "close").mockImplementation(() => {});
const sidebar = useThreadActions();
const header = useThreadActions();
const target = {
environmentId: EnvironmentId.make("undo-env"),
threadId: ThreadId.make("thread"),
};
await sidebar.unpinThread(target);
const staleUndo = add.mock.calls[0]?.[0].actionProps?.onClick;
await header.pinThread(target, { orderKey: "a1" });
await header.unpinThread(target);
const latestUndo = add.mock.calls[1]?.[0].actionProps?.onClick;
expect(staleUndo).toBeTypeOf("function");
expect(latestUndo).toBeTypeOf("function");
const event = {} as Parameters<NonNullable<typeof staleUndo>>[0];
staleUndo?.(event);
expect(commands.pin).toHaveBeenCalledTimes(1);
latestUndo?.(event);
expect(commands.pin).toHaveBeenCalledTimes(2);
expect(commands.pin).toHaveBeenLastCalledWith({
environmentId: target.environmentId,
input: { threadId: target.threadId, orderKey: "a0" },
});
latestUndo?.(event);
expect(commands.pin).toHaveBeenCalledTimes(2);
});
});
Loading