1import {
2 AuthOrchestrationOperateScope,
3 EnvironmentId,
4 ProjectId,
5 ThreadId,
6 type AuthEnvironmentScope,
7 type EditorId,
8 type ThreadPullRequestLink,
9} from "@t3tools/contracts";
10import { AsyncResult } from "effect/reactivity";
11import { expect, it, vi } from "vite-plus/test";
12
13import { act, render } from "../../test/render";
14import { useRightPanelStore } from "../rightPanelStore";
15
16const state = vi.hoisted(() => ({
17 allowed: true,
18 listeners: new Set<() => void>(),
19 openEditor: vi.fn(),
20 link: vi.fn(),
21 unlink: vi.fn(),
22 search: vi.fn(),
23 choose: vi.fn(),
24 openFile: vi.fn(),
25 openExternal: vi.fn(),
26 copy: vi.fn(),
27 pullRequests: [] as ThreadPullRequestLink[],
28}));
29
30vi.mock(import("../state/use-atom-query-runner"), async (original) => ({
31 ...(await original()),
32 useAtomQueryRunner: (() => state.search) as never,
33}));
34vi.mock(import("../state/use-atom-command"), async (original) => {
35 const { threadEnvironment } = await import("../state/threads");
36 return {
37 ...(await original()),
38 useAtomCommand: ((command: unknown) =>
39 command === threadEnvironment.linkPullRequest
40 ? state.link
41 : command === threadEnvironment.unlinkPullRequest
42 ? state.unlink
43 : state.openEditor) as never,
44 };
45});
46vi.mock(import("../state/session"), async (original) => {
47 const { useSyncExternalStore } = await import("react");
48 const readEnvironmentScope = (id: EnvironmentId | null, scope: AuthEnvironmentScope) =>
49 id !== null &&
50 (scope !== AuthOrchestrationOperateScope || id !== threadRef.environmentId || state.allowed);
51 return {
52 ...(await original()),
53 readEnvironmentScope,
54 useEnvironmentScope: (id: EnvironmentId | null, scope: AuthEnvironmentScope) =>
55 useSyncExternalStore(
56 (listener) => {
57 state.listeners.add(listener);
58 return () => state.listeners.delete(listener);
59 },
60 () => readEnvironmentScope(id, scope),
61 ),
62 };
63});
64vi.mock(import("../state/server"), async (original) => {
65 const actual = await original();
66 const { Atom } = await import("effect/reactivity");
67 const config = Atom.make(() => serverConfig);
68 return {
69 ...actual,
70 serverEnvironment: { ...actual.serverEnvironment, configValueAtom: (() => config) as never },
71 environmentServerConfigsAtom: Atom.make(
72 () => new Map([[threadRef.environmentId, serverConfig]]),
73 ) as never,
74 };
75});
76vi.mock(import("../state/entities"), async (original) => ({
77 ...(await original()),
78 readThreadShell: (() => ({ pullRequests: state.pullRequests })) as never,
79 useProjects: (() => [{ id: projectId, environmentId: threadRef.environmentId }]) as never,
80}));
81vi.mock(import("../remoteOpen"), async (original) => ({
82 ...(await original()),
83 useRemoteOpenResolution: () => ({ state: { mode: "local-exec" }, isResolved: true }) as never,
84}));
85vi.mock(import("../hooks/useLocalStorage"), async (original) => ({
86 ...(await original()),
87 useLocalStorage: (() => ["vscode", vi.fn()]) as never,
88 getLocalStorageItem: (() => "vscode") as never,
89}));
90vi.mock(import("../localApi"), async (original) => ({
91 ...(await original()),
92 readLocalApi: () =>
93 ({
94 contextMenu: { show: state.choose },
95 shell: { openExternal: state.openExternal },
96 }) as never,
97}));
98vi.mock(import("@t3tools/client-runtime/change-request-links"), async (original) => ({
99 ...(await original()),
100 resolvePullRequestPreviewTarget: () => null,
101 findProjectForChangeRequest: ((projects: readonly { id: ProjectId }[]) => projects[0]) as never,
102}));
103
104import ChatMarkdown from "./ChatMarkdown";
105
106const threadRef = {
107 environmentId: EnvironmentId.make("selected"),
108 threadId: ThreadId.make("thread"),
109};
110const projectId = ProjectId.make("project");
111const linkedPullRequest: ThreadPullRequestLink = {
112 host: "github.com",
113 repository: "example/repo",
114 number: 42,
115 url: "https://github.com/example/repo/pull/42",
116 source: "manual",
117 linkedAt: "2026-09-08T00:00:00.000Z",
118 snapshot: null,
119 stack: null,
120};
121const serverConfig = {
122 availableEditors: ["vscode", "file-manager"] as readonly EditorId[],
123 shellRevealInFileManager: true,
124 shellRevealInFileManagerKind: "xdg-open",
125 environment: { platform: { os: "linux" }, capabilities: {} },
126};
127
128function setup() {
129 using stack = new DisposableStack();
130 state.allowed = true;
131 state.listeners.clear();
132 state.pullRequests = [];
133 state.openEditor.mockReset().mockResolvedValue(AsyncResult.success(undefined));
134 state.link.mockReset().mockResolvedValue(AsyncResult.success(undefined));
135 state.unlink.mockReset().mockResolvedValue(AsyncResult.success(undefined));
136 state.search.mockReset().mockResolvedValue(AsyncResult.success({ entries: [] }));
137 state.choose.mockReset().mockResolvedValue(null);
138 state.openFile.mockReset();
139 state.openExternal.mockReset().mockResolvedValue(undefined);
140 state.copy.mockReset().mockResolvedValue(undefined);
141 const { openFile } = useRightPanelStore.getState();
142 useRightPanelStore.setState({ openFile: state.openFile });
143 stack.defer(() => useRightPanelStore.setState({ openFile }));
144 stack.use(vi.spyOn(navigator.clipboard, "writeText").mockImplementation(state.copy));
145 stack.use(
146 vi.spyOn(navigator.clipboard, "write").mockImplementation(async (items) => {
147 const item = items[0]!;
148 await state.copy(await (await item.getType("text/plain")).text());
149 }),
150 );
151 return stack.move();
152}
153
154async function renderMarkdown(text: string) {
155 const screen = await render(<ChatMarkdown cwd="/work" threadRef={threadRef} text={text} />);
156 const anchor = () => screen.container.querySelector("a")!;
157 return Object.assign(screen, {
158 anchor,
159 openContextMenu: () =>
160 act(async () => {
161 anchor().dispatchEvent(
162 new MouseEvent("contextmenu", {
163 bubbles: true,
164 cancelable: true,
165 clientX: 10,
166 clientY: 10,
167 }),
168 );
169 }),
170 });
171}
172
173function offeredActions() {
174 return state.choose.mock.lastCall![0].map((item: { id: string }) => item.id);
175}
176
177async function setAllowed(allowed: boolean) {
178 await act(async () => {
179 state.allowed = allowed;
180 for (const listener of state.listeners) listener();
181 });
182}
183
184it("removes host actions after revocation while keeping file preview and copying", async () => {
185 using _setup = setup();
186 await using screen = await renderMarkdown("[Readme](docs/readme.md)");
187 await screen.openContextMenu();
188 expect(offeredActions()).toEqual(["open", "reveal", "copy-relative", "copy-full"]);
189 await setAllowed(false);
190 state.choose.mockResolvedValue("copy-full");
191 await screen.openContextMenu();
192 expect(offeredActions()).toEqual(["copy-relative", "copy-full"]);
193 await vi.waitFor(() => expect(state.copy).toHaveBeenCalledWith("/work/docs/readme.md"));
194 await act(async () => screen.anchor().click());
195 expect(state.openFile).toHaveBeenCalledWith(threadRef, "docs/readme.md", undefined);
196 expect(state.openEditor).not.toHaveBeenCalled();
197});
198
199it("does not launch an editor after revocation during a native context menu", async () => {
200 using _setup = setup();
201 let choose: (action: string) => void = () => {
202 throw new Error("Menu not opened");
203 };
204 state.choose.mockImplementationOnce(
205 () =>
206 new Promise<string>((resolve) => {
207 choose = resolve;
208 }),
209 );
210 await using screen = await renderMarkdown("[Readme](docs/readme.md)");
211 await screen.openContextMenu();
212 expect(offeredActions()).toContain("open");
213 await act(async () => {
214 state.allowed = false;
215 choose("open");
216 });
217 expect(state.openEditor).not.toHaveBeenCalled();
218});
219
220it("does not reveal a file after revocation during its workspace lookup", async () => {
221 using _setup = setup();
222 let finishLookup: () => void = () => {
223 throw new Error("Lookup not started");
224 };
225 state.search.mockImplementationOnce(
226 () =>
227 new Promise((resolve) => {
228 finishLookup = () => resolve(AsyncResult.success({ entries: [] }));
229 }),
230 );
231 state.choose.mockResolvedValue("reveal");
232 await using screen = await renderMarkdown("[Readme](readme.md)");
233 await screen.openContextMenu();
234 expect(state.search).toHaveBeenCalledOnce();
235 await act(async () => {
236 state.allowed = false;
237 finishLookup();
238 });
239 expect(state.openEditor).not.toHaveBeenCalled();
240});
241
242it.each(["link-to-thread", "unlink-from-thread"])(
243 "rechecks %s after a native context menu returns",
244 async (action) => {
245 using _setup = setup();
246 state.pullRequests = action === "unlink-from-thread" ? [linkedPullRequest] : [];
247 const command = action === "link-to-thread" ? state.link : state.unlink;
248 let choose: (action: string) => void = () => {
249 throw new Error("Menu not opened");
250 };
251 state.choose.mockImplementationOnce(
252 () =>
253 new Promise<string>((resolve) => {
254 choose = resolve;
255 }),
256 );
257 await using screen = await renderMarkdown(`[Review](${linkedPullRequest.url})`);
258 await screen.openContextMenu();
259 expect(offeredActions()).toContain(action);
260 await act(async () => {
261 state.allowed = false;
262 choose(action);
263 });
264 expect(command).not.toHaveBeenCalled();
265
266 await setAllowed(true);
267 state.choose.mockResolvedValue(action);
268 await screen.openContextMenu();
269 await vi.waitFor(() =>
270 expect(command).toHaveBeenCalledExactlyOnceWith({
271 environmentId: threadRef.environmentId,
272 input: expect.objectContaining({
273 threadId: threadRef.threadId,
274 repository: "example/repo",
275 number: 42,
276 }),
277 }),
278 );
279 },
280);