1import {
2 AuthOrchestrationOperateScope,
3 AuthSourceControlWriteScope,
4 EnvironmentId,
5 ProjectId,
6 ThreadId,
7 type ScopedThreadRef,
8} from "@t3tools/contracts";
9import * as Cause from "effect/Cause";
10import { AsyncResult } from "effect/reactivity";
11import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
12
13import { scopedThreadKey } from "@t3tools/client-runtime/environment";
14import { useDiffPanelStore } from "../diffPanelStore";
15import { browserMiniPlayerSource, usePreviewMiniPlayerStore } from "../previewMiniPlayerStore";
16import { useRightPanelStore } from "../rightPanelStore";
17
18function deferred<T>() {
19 let resolve!: (value: T) => void;
20 const promise = new Promise<T>((complete) => {
21 resolve = complete;
22 });
23 return { promise, resolve };
24}
25
26const state = vi.hoisted(() => ({
27 serverConfig: { shellThreadFamilies: false },
28 familyLookupFails: false,
29 scopes: new Map<string, Set<string>>(),
30 threads: [] as {
31 environmentId: EnvironmentId;
32 id: ThreadId;
33 projectId: ProjectId;
34 title: string;
35 worktreePath: string | null;
36 lineage: { parentThreadId: ThreadId | null; sideChat?: boolean };
37 session: { status: "ready" | "stopped" } | null;
38 latestTurn: null;
39 }[],
40 familyThreads: [] as {
41 id: ThreadId;
42 lineage: { parentThreadId: ThreadId | null; sideChat?: boolean };
43 }[],
44 requests: [] as { action: string; environmentId: string; input: { threadId?: string } }[],
45 localEffects: [] as string[],
46 confirm: vi.fn<(message: string) => Promise<boolean>>(),
47 toasts: [] as string[],
48 afterRequest: undefined as ((action: string) => void) | undefined,
49 sessionLookupFails: false,
50}));
51
52vi.mock("react", () => ({
53 useCallback: (callback: unknown) => callback,
54 useMemo: (factory: () => unknown) => factory(),
55 useRef: (current: unknown) => ({ current }),
56}));
57vi.mock("@tanstack/react-router", () => ({
58 useRouter: () => ({
59 state: { location: { href: "/" }, matches: [] },
60 history: { subscribe: () => () => {} },
61 navigate: async () => {},
62 }),
63}));
64vi.mock("../state/session", () => ({
65 environmentSession: { sessionStateAtom: "session" },
66 readEnvironmentScope: (environmentId: string, scope: string) =>
67 state.scopes.get(environmentId)?.has(scope) === true,
68}));
69vi.mock("../state/use-atom-command", () => ({
70 useAtomCommand:
71 (action: string) =>
72 async (request: { environmentId: string; input: { threadId?: string } }) => {
73 state.requests.push({ action, ...request });
74 const scope =
75 action === "removeWorktree" ? AuthSourceControlWriteScope : AuthOrchestrationOperateScope;
76 if (!state.scopes.get(request.environmentId)?.has(scope)) {
77 return AsyncResult.failure(Cause.fail(new Error("Server denied the request")));
78 }
79 state.afterRequest?.(action);
80 return AsyncResult.success(undefined);
81 },
82}));
83vi.mock("../state/use-atom-query-runner", () => ({
84 useAtomQueryRunner: (family: unknown) => async (environmentId: string) =>
85 family === "threadFamily"
86 ? state.familyLookupFails
87 ? AsyncResult.failure(Cause.fail(new Error("Family lookup failed")))
88 : AsyncResult.success({ threads: state.familyThreads })
89 : state.sessionLookupFails
90 ? AsyncResult.failure(Cause.fail(new Error("Session lookup failed")))
91 : AsyncResult.success({
92 authenticated: true,
93 scopes: [...(state.scopes.get(environmentId) ?? [])],
94 auth: { serverUpdateScope: "environment:maintain" },
95 }),
96}));
97vi.mock("../state/orchestration", () => ({
98 orchestrationEnvironment: { threadFamily: "threadFamily" },
99}));
100vi.mock("../state/server", async (importOriginal) => {
101 const actual = await importOriginal<typeof import("../state/server")>();
102 const { Atom } = await import("effect/reactivity");
103 return {
104 ...actual,
105 environmentServerConfigsAtom: Atom.make(new Map([["secondary", state.serverConfig]])),
106 };
107});
108vi.mock("../state/threads", () => ({
109 threadEnvironment: Object.fromEntries(
110 [
111 "archive",
112 "unarchive",
113 "delete",
114 "settle",
115 "unsettle",
116 "pin",
117 "unpin",
118 "reorderPin",
119 "snooze",
120 "unsnooze",
121 "stopSession",
122 ].map((action) => [action, action]),
123 ),
124}));
125vi.mock("../state/vcs", () => ({
126 vcsEnvironment: { removeWorktree: "removeWorktree", refreshStatus: "refreshStatus" },
127}));
128vi.mock("../state/entities", () => ({
129 readEnvironmentServerConfigLoaded: () => true,
130 readThreadShell: (ref: ScopedThreadRef) =>
131 state.threads.find(
132 (thread) => thread.environmentId === ref.environmentId && thread.id === ref.threadId,
133 ) ?? null,
134 readThreadShells: () => state.threads,
135 readEnvironmentThreadRefs: (environmentId: EnvironmentId) =>
136 state.threads
137 .filter((thread) => thread.environmentId === environmentId)
138 .map((thread) => ({ environmentId, threadId: thread.id })),
139 readProject: () => ({ workspaceRoot: "/project" }),
140}));
141vi.mock("../components/Sidebar.logic", () => ({
142 getFallbackThreadIdAfterDelete: () => null,
143 pinOrderKeyBetween: () => "a",
144}));
145vi.mock("../composerDraftStore", () => ({
146 useComposerDraftStore: (select: (store: unknown) => unknown) =>
147 select({
148 clearDraftThread: (ref: ScopedThreadRef) =>
149 state.localEffects.push(`clear-draft:${ref.threadId}`),
150 clearProjectDraftThreadById: () => state.localEffects.push("clear-project-draft"),
151 }),
152}));
153vi.mock("../terminalUiStateStore", () => ({
154 useTerminalUiStateStore: (select: (store: unknown) => unknown) =>
155 select({
156 clearTerminalUiState: () => state.localEffects.push("clear-terminal-ui"),
157 }),
158}));
159vi.mock("../uiStateStore", () => ({
160 useUiStateStore: (select: (store: unknown) => unknown) =>
161 select({
162 markThreadVisited: () => state.localEffects.push("mark-visited"),
163 }),
164}));
165vi.mock("../lib/archivedThreadsState", () => ({
166 refreshArchivedThreadsForEnvironment: () => state.localEffects.push("refresh-archive"),
167}));
168vi.mock("../lib/composerDraftUploads", () => ({
169 releaseComposerDraftUploads: (ref: ScopedThreadRef) =>
170 state.localEffects.push(`release-uploads:${ref.threadId}`),
171}));
172vi.mock("../localApi", () => ({
173 readLocalApi: () => ({ dialogs: { confirm: state.confirm } }),
174}));
175vi.mock("../threadRoutes", () => ({
176 resolveThreadRouteRef: () => null,
177 buildThreadRouteParams: (ref: ScopedThreadRef) => ref,
178}));
179vi.mock("../components/ui/toast", () => ({
180 stackedThreadToast: (toast: unknown) => toast,
181 toastManager: {
182 add: (toast: { title?: string }) => {
183 if (toast.title) state.toasts.push(toast.title);
184 },
185 },
186}));
187vi.mock("./useHandleNewThread", () => ({ useNewThreadHandler: () => async () => {} }));
188vi.mock("./useSettings", () => ({
189 useClientSettings: (select: (settings: unknown) => unknown) =>
190 select({
191 sidebarThreadSortOrder: "createdAt",
192 confirmThreadDelete: true,
193 confirmThreadUnpin: true,
194 }),
195}));
196
197import { useThreadActions } from "./useThreadActions";
198
199function seedThreadPanelState(ref: ScopedThreadRef) {
200 useRightPanelStore.getState().show(ref);
201 useDiffPanelStore.getState().selectGitScope(ref, "branch");
202 usePreviewMiniPlayerStore.getState().open(ref, browserMiniPlayerSource("tab"));
203}
204
205function threadPanelStatePresence(ref: ScopedThreadRef) {
206 const key = scopedThreadKey(ref);
207 return {
208 rightPanel: key in useRightPanelStore.getState().byThreadKey,
209 diffPanel: key in useDiffPanelStore.getState().byThreadKey,
210 miniPlayer: key in usePreviewMiniPlayerStore.getState().byThreadKey,
211 };
212}
213const panelStateKept = { rightPanel: true, diffPanel: true, miniPlayer: true };
214const panelStateCleared = { rightPanel: false, diffPanel: false, miniPlayer: false };
215
216const primary = EnvironmentId.make("primary");
217const secondary = EnvironmentId.make("secondary");
218const target = { environmentId: secondary, threadId: ThreadId.make("thread") };
219type ThreadActions = ReturnType<typeof useThreadActions>;
220const operations = [
221 {
222 name: "archive",
223 run: (actions: ThreadActions, ref: ScopedThreadRef) => actions.archiveThread(ref),
224 },
225 {
226 name: "unarchive",
227 run: (actions: ThreadActions, ref: ScopedThreadRef) => actions.unarchiveThread(ref),
228 },
229 {
230 name: "settle",
231 run: (actions: ThreadActions, ref: ScopedThreadRef) => actions.settleThread(ref),
232 },
233 {
234 name: "unsettle",
235 run: (actions: ThreadActions, ref: ScopedThreadRef) => actions.unsettleThread(ref),
236 },
237 {
238 name: "snooze",
239 run: (actions: ThreadActions, ref: ScopedThreadRef) =>
240 actions.snoozeThread(ref, "2099-01-01T00:00:00Z"),
241 },
242 {
243 name: "unsnooze",
244 run: (actions: ThreadActions, ref: ScopedThreadRef) => actions.unsnoozeThread(ref),
245 },
246 { name: "pin", run: (actions: ThreadActions, ref: ScopedThreadRef) => actions.pinThread(ref) },
247 {
248 name: "unpin",
249 run: (actions: ThreadActions, ref: ScopedThreadRef) => actions.unpinThread(ref),
250 },
251 {
252 name: "reorderPin",
253 run: (actions: ThreadActions, ref: ScopedThreadRef) => actions.reorderPinnedThread(ref, "b"),
254 },
255] as const;
256
257beforeEach(() => {
258 state.serverConfig.shellThreadFamilies = false;
259 state.familyLookupFails = false;
260 state.familyThreads = [];
261 state.scopes = new Map([
262 [primary, new Set([AuthOrchestrationOperateScope])],
263 [secondary, new Set<string>()],
264 ]);
265 state.threads = [
266 {
267 environmentId: secondary,
268 id: target.threadId,
269 projectId: ProjectId.make("project"),
270 title: "Thread",
271 worktreePath: null,
272 lineage: { parentThreadId: null },
273 session: null,
274 latestTurn: null,
275 },
276 ];
277 state.requests = [];
278 state.localEffects = [];
279 state.confirm.mockReset().mockResolvedValue(true);
280 state.toasts = [];
281 state.afterRequest = undefined;
282 state.sessionLookupFails = false;
283 useRightPanelStore.setState(useRightPanelStore.getInitialState(), true);
284 useDiffPanelStore.setState(useDiffPanelStore.getInitialState(), true);
285 usePreviewMiniPlayerStore.setState(usePreviewMiniPlayerStore.getInitialState(), true);
286});
287
288describe("thread action permissions", () => {
289 it.each(operations)("$name requires the target environment's grant", async ({ run }) => {
290 const result = await run(useThreadActions(), target);
291 expect(result._tag).toBe("Failure");
292 expect(state.requests).toEqual([]);
293 expect(state.localEffects).toEqual([]);
294 });
295
296 it.each(operations)("$name rechecks a retained callback after revocation", async ({ run }) => {
297 state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope);
298 const actions = useThreadActions();
299 state.scopes.get(secondary)!.clear();
300 await run(actions, target);
301 expect(state.requests).toEqual([]);
302 });
303
304 it.each(operations)(
305 "$name works after the target gains only task permission",
306 async ({ name, run }) => {
307 const actions = useThreadActions();
308 state.scopes.get(primary)!.clear();
309 state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope);
310 expect((await run(actions, target))._tag).toBe("Success");
311 expect(state.requests).toEqual([
312 expect.objectContaining({ action: name, environmentId: secondary }),
313 ]);
314 },
315 );
316
317 it.each(["confirmAndDeleteThread", "confirmAndUnpinThread"] as const)(
318 "%s blocks a forbidden confirmation",
319 async (action) => {
320 await useThreadActions()[action](target);
321 expect(state.confirm).not.toHaveBeenCalled();
322 expect(state.requests).toEqual([]);
323 },
324 );
325
326 it.each(["confirmAndDeleteThread", "confirmAndUnpinThread"] as const)(
327 "%s rechecks after the confirmation",
328 async (action) => {
329 state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope);
330 const confirmation = deferred<boolean>();
331 state.confirm.mockReturnValue(confirmation.promise);
332 const result = useThreadActions()[action](target);
333 expect(state.confirm).toHaveBeenCalledOnce();
334 state.scopes.get(secondary)!.clear();
335 confirmation.resolve(true);
336 await result;
337 expect(state.requests).toEqual([]);
338 expect(state.localEffects).toEqual([]);
339 },
340 );
341
342 it("names an archived thread in its delete confirmation and preserves it on cancel", async () => {
343 state.threads = [];
344 state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope);
345 state.confirm.mockResolvedValue(false);
346 await useThreadActions().confirmAndDeleteThread(target, "Archived release notes");
347 expect(state.confirm).toHaveBeenCalledWith(
348 expect.stringContaining('Delete thread "Archived release notes"?'),
349 { variant: "destructive" },
350 );
351 expect(state.requests).toEqual([]);
352 });
353
354 it("deletes an archived thread only with its own environment's grant", async () => {
355 state.serverConfig.shellThreadFamilies = true;
356 state.familyThreads = [
357 {
358 id: ThreadId.make("side-chat"),
359 lineage: { parentThreadId: target.threadId, sideChat: true },
360 },
361 { id: ThreadId.make("fork"), lineage: { parentThreadId: target.threadId } },
362 {
363 id: ThreadId.make("other-side-chat"),
364 lineage: { parentThreadId: ThreadId.make("other-parent"), sideChat: true },
365 },
366 ];
367 state.threads = [];
368 seedThreadPanelState(target);
369 const actions = useThreadActions();
370 await actions.deleteThread(target);
371 expect(state.requests).toEqual([]);
372 expect(threadPanelStatePresence(target)).toEqual(panelStateKept);
373 state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope);
374 expect((await actions.deleteThread(target))._tag).toBe("Success");
375 expect(state.requests).toEqual([
376 expect.objectContaining({ action: "delete", environmentId: secondary }),
377 ]);
378 expect(threadPanelStatePresence(target)).toEqual(panelStateCleared);
379 expect(state.localEffects).toEqual([
380 "release-uploads:thread",
381 "clear-draft:thread",
382 "release-uploads:side-chat",
383 "clear-draft:side-chat",
384 "refresh-archive",
385 "clear-terminal-ui",
386 ]);
387 expect(state.localEffects).not.toContain("clear-draft:fork");
388 expect(state.localEffects).not.toContain("clear-draft:other-side-chat");
389
390 // The family read only finds drafts to clear; it never blocks the delete.
391 state.familyLookupFails = true;
392 state.requests = [];
393 state.localEffects = [];
394 expect((await actions.deleteThread(target))._tag).toBe("Success");
395 expect(state.requests).toEqual([
396 expect.objectContaining({ action: "delete", environmentId: secondary }),
397 ]);
398 expect(state.localEffects).toContain("clear-draft:thread");
399 });
400
401 it("stops before delete and local cleanup when permission is revoked during session stop", async () => {
402 state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope);
403 state.threads[0]!.session = { status: "ready" };
404 state.afterRequest = () => state.scopes.get(secondary)!.clear();
405 seedThreadPanelState(target);
406 expect((await useThreadActions().deleteThread(target))._tag).toBe("Failure");
407 expect(state.requests.map((request) => request.action)).toEqual(["stopSession"]);
408 expect(state.localEffects).toEqual([]);
409 expect(threadPanelStatePresence(target)).toEqual(panelStateKept);
410 });
411
412 it.each([
413 { reason: "without source-control permission", sessionLookupFails: false },
414 { reason: "when the permission lookup fails", sessionLookupFails: true },
415 ])("deletes a worktree thread and keeps its worktree $reason", async ({ sessionLookupFails }) => {
416 state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope);
417 state.threads[0]!.worktreePath = "/worktrees/thread";
418 state.sessionLookupFails = sessionLookupFails;
419 seedThreadPanelState(target);
420 expect((await useThreadActions().deleteThread(target))._tag).toBe("Success");
421 expect(state.confirm).not.toHaveBeenCalled();
422 expect(state.requests.map((request) => request.action)).toEqual(["stopSession", "delete"]);
423 expect(state.localEffects).toContain("clear-terminal-ui");
424 expect(threadPanelStatePresence(target)).toEqual(panelStateCleared);
425 });
426
427 it("clears the drafts of the side chats deleted with it", async () => {
428 state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope);
429 const parent = state.threads[0]!;
430 state.threads.push(
431 {
432 ...parent,
433 id: ThreadId.make("side-chat"),
434 lineage: { parentThreadId: parent.id, sideChat: true },
435 },
436 { ...parent, id: ThreadId.make("fork"), lineage: { parentThreadId: parent.id } },
437 );
438 expect((await useThreadActions().deleteThread(target))._tag).toBe("Success");
439 expect(state.localEffects).toEqual(
440 expect.arrayContaining(["release-uploads:side-chat", "clear-draft:side-chat"]),
441 );
442 expect(state.localEffects).not.toContain("clear-draft:fork");
443 });
444
445 it("does not request worktree removal after its grant is revoked during delete", async () => {
446 state.scopes
447 .get(secondary)!
448 .add(AuthOrchestrationOperateScope)
449 .add(AuthSourceControlWriteScope);
450 state.threads[0]!.worktreePath = "/worktrees/thread";
451 state.afterRequest = () => state.scopes.get(secondary)!.delete(AuthSourceControlWriteScope);
452 const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
453 try {
454 const result = await useThreadActions().deleteThread(target);
455 expect(result._tag).toBe("Success");
456 expect(state.requests.map((request) => request.action)).toEqual(["stopSession", "delete"]);
457 expect(state.toasts).toContain("Failed to delete worktree");
458 expect(state.localEffects).toContain("clear-terminal-ui");
459 } finally {
460 consoleError.mockRestore();
461 }
462 });
463});