1import {
2 AuthOrchestrationOperateScope,
3 EnvironmentId,
4 ThreadId,
5 type ContextMenuItem,
6} from "@t3tools/contracts";
7import { AsyncResult } from "effect/reactivity";
8import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
9
10import type { ThreadActionMenuId } from "../components/threadActionMenu.logic";
11
12function deferred<T>() {
13 let resolve!: (value: T) => void;
14 const promise = new Promise<T>((complete) => {
15 resolve = complete;
16 });
17 return { promise, resolve };
18}
19
20const state = vi.hoisted(() => ({
21 granted: new Set<string>(),
22 effects: [] as string[],
23 completed: deferred<void>(),
24 show: vi.fn<
25 (
26 items: ReadonlyArray<ContextMenuItem<ThreadActionMenuId>>,
27 position: { x: number; y: number },
28 ) => Promise<ThreadActionMenuId | null>
29 >(),
30}));
31
32function recordEffect(action: string) {
33 state.effects.push(action);
34 state.completed.resolve();
35}
36
37vi.mock("../components/CustomSnoozeDialog", () => ({ requestCustomSnooze: vi.fn() }));
38vi.mock("react", () => ({
39 useCallback: (callback: unknown) => callback,
40 useMemo: (factory: () => unknown) => factory(),
41}));
42vi.mock("@tanstack/react-router", () => ({
43 useRouter: () => ({ navigate: async () => recordEffect("project-settings") }),
44}));
45vi.mock("../state/session", () => ({
46 readEnvironmentScope: (environmentId: string, scope: string) =>
47 scope === AuthOrchestrationOperateScope && state.granted.has(environmentId),
48}));
49vi.mock("../state/entities", () => ({
50 readEnvironmentSupportsAutoSettleOptOut: () => true,
51 readEnvironmentSupportsPinning: () => true,
52 readEnvironmentSupportsSettlement: () => true,
53 readEnvironmentSupportsSnooze: () => true,
54 readEnvironmentSupportsTitleRegeneration: () => true,
55 readThreadShell: () => ({
56 id: "thread",
57 environmentId: "secondary",
58 projectId: "project",
59 title: "Thread",
60 branch: "main",
61 worktreePath: null,
62 runtime: null,
63 latestRun: null,
64 }),
65 useProjects: () => [{ id: "project", environmentId: "secondary" }],
66}));
67vi.mock("../state/environments", () => ({ usePrimaryEnvironmentId: () => "primary" }));
68vi.mock("../state/threads", () => ({ threadEnvironment: { updateMetadata: "metadata" } }));
69vi.mock("../state/use-atom-command", () => ({
70 useAtomCommand: () => async () => {
71 recordEffect("metadata");
72 return AsyncResult.success(undefined);
73 },
74}));
75vi.mock("../localApi", () => ({
76 readLocalApi: () => ({
77 contextMenu: { show: state.show, close: () => {} },
78 dialogs: {
79 confirm: async () => {
80 recordEffect("confirm");
81 return false;
82 },
83 },
84 }),
85}));
86vi.mock("../logicalProject", () => ({
87 deriveLogicalProjectKeyFromSettings: () => "project",
88 derivePhysicalProjectKey: () => "project",
89 selectProjectGroupingSettings: () => ({}),
90}));
91vi.mock("../sidebarProjectGrouping", () => ({
92 buildPhysicalToLogicalProjectKeyMap: () => new Map(),
93}));
94vi.mock("../uiStateStore", () => ({
95 useUiStateStore: (select: (store: unknown) => unknown) =>
96 select({
97 markThreadUnread: () => recordEffect("mark-unread"),
98 }),
99}));
100vi.mock("../components/ui/toast", () => ({
101 stackedThreadToast: (toast: unknown) => toast,
102 toastManager: { add: () => state.completed.resolve() },
103}));
104vi.mock("../components/Sidebar.snooze", () => ({
105 resolveSnoozePresets: () => [
106 { id: "hour", label: "In 1 hour", whenLabel: "3 PM", snoozedUntil: "2099-01-01T00:00:00Z" },
107 ],
108 snoozeWakeDescription: () => "later",
109}));
110vi.mock("./useCopyToClipboard", () => ({
111 useCopyToClipboard: () => ({ copyToClipboard: () => recordEffect("copy") }),
112}));
113vi.mock("./useHandleNewThread", () => ({
114 useNewThreadHandler: () => async () => recordEffect("draft"),
115}));
116vi.mock("./useSettings", () => ({
117 useClientSettings: (select: (settings: unknown) => unknown) =>
118 select({
119 confirmThreadDelete: true,
120 confirmThreadArchive: true,
121 timestampFormat: "12-hour",
122 }),
123}));
124vi.mock("./useThreadActions", () => ({
125 useThreadActions: () =>
126 Object.fromEntries(
127 [
128 "markThreadUnread",
129 "settleThread",
130 "unsettleThread",
131 "snoozeThread",
132 "unsnoozeThread",
133 "pinThread",
134 "confirmAndUnpinThread",
135 "archiveThread",
136 "deleteThread",
137 ].map((action) => [
138 action,
139 async () => {
140 recordEffect(action === "markThreadUnread" ? "mark-unread" : action);
141 return AsyncResult.success(undefined);
142 },
143 ]),
144 ),
145}));
146
147import { useThreadActionMenu } from "./useThreadActionMenu";
148
149const target = {
150 environmentId: EnvironmentId.make("secondary"),
151 threadId: ThreadId.make("thread"),
152};
153const position = { x: 10, y: 20 };
154const createMenu = () =>
155 useThreadActionMenu({
156 threadRef: target,
157 projectCwd: "/project",
158 onStartRename: () => recordEffect("rename"),
159 });
160
161beforeEach(() => {
162 state.granted = new Set(["primary"]);
163 state.effects = [];
164 state.completed = deferred<void>();
165 state.show.mockReset().mockResolvedValue(null);
166});
167
168describe("thread menu permissions", () => {
169 it("disables mutations for a denied secondary environment", () => {
170 createMenu().openMenu(position);
171 const items = state.show.mock.calls[0]![0];
172 expect(items.find((item) => item.id === "rename")?.disabled).toBe(true);
173 expect(items.find((item) => item.id === "delete")?.disabled).toBe(true);
174 expect(items.find((item) => item.id === "copy")?.disabled).not.toBe(true);
175 });
176
177 it("allows the target grant even when the primary environment is denied", () => {
178 state.granted = new Set(["secondary"]);
179 createMenu().openMenu(position);
180 expect(state.show.mock.calls[0]![0].find((item) => item.id === "rename")?.disabled).not.toBe(
181 true,
182 );
183 });
184
185 it("refreshes availability when a retained menu opener gains permission", () => {
186 const menu = createMenu();
187 menu.openMenu(position);
188 expect(state.show.mock.calls[0]![0].find((item) => item.id === "rename")?.disabled).toBe(true);
189 state.granted.add("secondary");
190 menu.openMenu(position);
191 expect(state.show.mock.calls[1]![0].find((item) => item.id === "rename")?.disabled).not.toBe(
192 true,
193 );
194 });
195
196 it.each(["rename", "regenerate-title", "delete", "pin", "settle", "archive"] as const)(
197 "%s rechecks after the native menu closes",
198 async (action) => {
199 state.granted.add("secondary");
200 const choice = deferred<ThreadActionMenuId | null>();
201 state.show.mockReturnValue(choice.promise);
202 createMenu().openMenu(position);
203 state.granted.delete("secondary");
204 choice.resolve(action);
205 await state.completed.promise;
206 expect(state.effects).toEqual([]);
207 },
208 );
209
210 it.each([
211 ["new-thread-on-branch", "draft"],
212 ["copy-thread-id", "copy"],
213 ["mark-unread", "mark-unread"],
214 ["project-settings", "project-settings"],
215 ] as const)("keeps %s available without task permission", async (action, effect) => {
216 state.show.mockResolvedValue(action);
217 createMenu().openMenu(position);
218 await state.completed.promise;
219 expect(state.effects).toEqual([effect]);
220 });
221});