1import { scopeThreadRef } from "@t3tools/client-runtime/environment";
2import { EnvironmentId, ThreadId } from "@t3tools/contracts";
3import { describe, expect, it, vi } from "vite-plus/test";
4
5import { selectThreadRightPanelState, useRightPanelStore } from "../rightPanelStore";
6import { useTerminalUiStateStore } from "../terminalUiStateStore";
7import {
8 consumePendingTerminalFocus,
9 focusActiveDrawerTerminal,
10 focusActivePanelTerminal,
11 focusPanelTerminalAfterClose,
12 focusTerminal,
13 focusTerminalOnNavigation,
14 registerTerminalFocusTarget,
15 restoreFocusOnThreadEnter,
16} from "./terminalFocus";
17
18const threadRef = (threadId: string) =>
19 scopeThreadRef(EnvironmentId.make("env"), ThreadId.make(threadId));
20
21// Node has no DOM; entry only listens on the root's document for focus changes.
22const enterThread = (ref: ReturnType<typeof threadRef>) =>
23 restoreFocusOnThreadEnter(ref, {
24 root: {
25 ownerDocument: Object.assign(new EventTarget(), { activeElement: null }),
26 } as unknown as HTMLElement,
27 focusComposer: () => {},
28 });
29
30describe("focusTerminal", () => {
31 it("focuses a mounted target right away", () => {
32 const ref = threadRef("mounted");
33 const focus = vi.fn(() => true);
34 const unregister = registerTerminalFocusTarget({ threadRef: ref, terminalId: "a", focus });
35
36 focusTerminal(ref, "a");
37
38 expect(focus).toHaveBeenCalledTimes(1);
39 unregister();
40 });
41
42 it("waits for the requested terminal to mount, then consumes the request", () => {
43 const ref = threadRef("pending");
44 focusTerminal(ref, "b");
45
46 const other = vi.fn(() => true);
47 const unregisterOther = registerTerminalFocusTarget({
48 threadRef: ref,
49 terminalId: "a",
50 focus: other,
51 });
52 const target = vi.fn(() => true);
53 const unregisterTarget = registerTerminalFocusTarget({
54 threadRef: ref,
55 terminalId: "b",
56 focus: target,
57 });
58 unregisterTarget();
59 const remount = vi.fn(() => true);
60 const unregisterRemount = registerTerminalFocusTarget({
61 threadRef: ref,
62 terminalId: "b",
63 focus: remount,
64 });
65
66 expect(other).not.toHaveBeenCalled();
67 expect(target).toHaveBeenCalledTimes(1);
68 expect(remount).not.toHaveBeenCalled();
69 unregisterOther();
70 unregisterRemount();
71 });
72
73 it("keeps only the newest request for a thread", () => {
74 const ref = threadRef("newest");
75 focusTerminal(ref, "a");
76 focusTerminal(ref, "b");
77
78 const first = vi.fn(() => true);
79 const unregisterFirst = registerTerminalFocusTarget({
80 threadRef: ref,
81 terminalId: "a",
82 focus: first,
83 });
84 const second = vi.fn(() => true);
85 const unregisterSecond = registerTerminalFocusTarget({
86 threadRef: ref,
87 terminalId: "b",
88 focus: second,
89 });
90
91 expect(first).not.toHaveBeenCalled();
92 expect(second).toHaveBeenCalledTimes(1);
93 unregisterFirst();
94 unregisterSecond();
95 });
96
97 it("keeps the request when a mounted target cannot take focus", () => {
98 const ref = threadRef("hidden");
99 const hidden = vi.fn(() => false);
100 const unregisterHidden = registerTerminalFocusTarget({
101 threadRef: ref,
102 terminalId: "a",
103 focus: hidden,
104 });
105
106 focusTerminal(ref, "a");
107 unregisterHidden();
108 const shown = vi.fn(() => true);
109 const unregisterShown = registerTerminalFocusTarget({
110 threadRef: ref,
111 terminalId: "a",
112 focus: shown,
113 });
114
115 expect(hidden).toHaveBeenCalledTimes(1);
116 expect(shown).toHaveBeenCalledTimes(1);
117 unregisterShown();
118 });
119
120 it("focuses a registered hidden target once it is shown", () => {
121 const ref = threadRef("shown-later");
122 let canFocus = false;
123 const focus = vi.fn(() => canFocus);
124 const unregister = registerTerminalFocusTarget({ threadRef: ref, terminalId: "a", focus });
125
126 focusTerminal(ref, "a");
127 canFocus = true;
128 consumePendingTerminalFocus(ref, "a");
129 consumePendingTerminalFocus(ref, "a");
130
131 expect(focus).toHaveBeenCalledTimes(2);
132 unregister();
133 });
134
135 it("drops a parked request once a newer one targets another thread", () => {
136 const parked = threadRef("parked");
137 const hidden = vi.fn(() => false);
138 const unregisterHidden = registerTerminalFocusTarget({
139 threadRef: parked,
140 terminalId: "a",
141 focus: hidden,
142 });
143 focusTerminal(parked, "a");
144
145 const other = threadRef("other");
146 const unregisterOther = registerTerminalFocusTarget({
147 threadRef: other,
148 terminalId: "b",
149 focus: () => true,
150 });
151 focusTerminal(other, "b");
152 unregisterHidden();
153 const remount = vi.fn(() => true);
154 const unregisterRemount = registerTerminalFocusTarget({
155 threadRef: parked,
156 terminalId: "a",
157 focus: remount,
158 });
159
160 expect(remount).not.toHaveBeenCalled();
161 unregisterOther();
162 unregisterRemount();
163 });
164
165 it("drops a parked request when the thread's drawer closes", () => {
166 const ref = threadRef("closed");
167 useTerminalUiStateStore.getState().ensureTerminal(ref, "a", { open: true, active: true });
168 focusActiveDrawerTerminal(ref);
169 useTerminalUiStateStore.getState().setTerminalOpen(ref, false);
170 focusActiveDrawerTerminal(ref);
171
172 const focus = vi.fn(() => true);
173 const unregister = registerTerminalFocusTarget({ threadRef: ref, terminalId: "a", focus });
174
175 expect(focus).not.toHaveBeenCalled();
176 unregister();
177 });
178
179 it("entering a thread with its drawer open focuses the active drawer terminal", () => {
180 const ref = threadRef("entered");
181 useTerminalUiStateStore.getState().ensureTerminal(ref, "a", { open: true, active: true });
182 const focus = vi.fn(() => true);
183 const unregister = registerTerminalFocusTarget({ threadRef: ref, terminalId: "a", focus });
184
185 const leave = enterThread(ref);
186
187 expect(focus).toHaveBeenCalledTimes(1);
188 leave();
189 unregister();
190 });
191
192 it("entering a thread keeps the request its navigating handler made", () => {
193 const ref = threadRef("revealed");
194 useTerminalUiStateStore.getState().ensureTerminal(ref, "drawer", { open: true, active: true });
195 focusTerminalOnNavigation(ref, "panel");
196
197 // React commit order: the new thread's viewports attach and take the
198 // request before ChatView's thread-enter effect runs.
199 const drawer = vi.fn(() => true);
200 const unregisterDrawer = registerTerminalFocusTarget({
201 threadRef: ref,
202 terminalId: "drawer",
203 focus: drawer,
204 });
205 const panel = vi.fn(() => true);
206 const unregisterPanel = registerTerminalFocusTarget({
207 threadRef: ref,
208 terminalId: "panel",
209 focus: panel,
210 });
211 const leave = enterThread(ref);
212
213 expect(drawer).not.toHaveBeenCalled();
214 expect(panel).toHaveBeenCalledTimes(1);
215 leave();
216 unregisterDrawer();
217 unregisterPanel();
218 });
219
220 it("re-entering a thread after in-thread terminal actions focuses the drawer", () => {
221 const ref = threadRef("re-entered");
222 useTerminalUiStateStore.getState().ensureTerminal(ref, "drawer", { open: true, active: true });
223 const drawer = vi.fn(() => true);
224 const unregisterDrawer = registerTerminalFocusTarget({
225 threadRef: ref,
226 terminalId: "drawer",
227 focus: drawer,
228 });
229
230 const leaveFirst = enterThread(ref);
231 focusTerminal(ref, "drawer");
232 focusTerminalOnNavigation(ref, "drawer");
233 leaveFirst();
234 drawer.mockClear();
235 const leaveSecond = enterThread(ref);
236
237 expect(drawer).toHaveBeenCalledTimes(1);
238 leaveSecond();
239 unregisterDrawer();
240 });
241
242 it("a navigating handler's request only outranks the next thread entry", () => {
243 const ref = threadRef("revisited");
244 useTerminalUiStateStore.getState().ensureTerminal(ref, "drawer", { open: true, active: true });
245 focusTerminalOnNavigation(ref, "panel");
246 enterThread(threadRef("elsewhere"))();
247
248 const drawer = vi.fn(() => true);
249 const unregisterDrawer = registerTerminalFocusTarget({
250 threadRef: ref,
251 terminalId: "drawer",
252 focus: drawer,
253 });
254 const leave = enterThread(ref);
255
256 expect(drawer).toHaveBeenCalledTimes(1);
257 leave();
258 unregisterDrawer();
259 });
260
261 it("focuses the panel terminal a closed surface falls back to", () => {
262 const ref = threadRef("panel-fallback");
263 const panel = useRightPanelStore.getState();
264 panel.openTerminal(ref, "a");
265 panel.open(ref, "diff");
266 const { activeSurfaceId: diffSurfaceId } = selectThreadRightPanelState(
267 useRightPanelStore.getState().byThreadKey,
268 ref,
269 );
270 const focus = vi.fn(() => true);
271 const unregister = registerTerminalFocusTarget({ threadRef: ref, terminalId: "a", focus });
272 expect(diffSurfaceId).not.toBeNull();
273
274 useRightPanelStore.getState().closeSurface(ref, diffSurfaceId!);
275 focusActivePanelTerminal(ref);
276
277 expect(focus).toHaveBeenCalledTimes(1);
278 unregister();
279 });
280
281 it("closing a surface's last terminal focuses the terminal surface it falls back to", () => {
282 const ref = threadRef("panel-terminal-fallback");
283 const panel = useRightPanelStore.getState();
284 panel.openTerminal(ref, "b");
285 panel.openTerminal(ref, "a");
286 const surfaceA = selectThreadRightPanelState(
287 useRightPanelStore.getState().byThreadKey,
288 ref,
289 ).activeSurfaceId!;
290 const focusB = vi.fn(() => true);
291 const unregister = registerTerminalFocusTarget({
292 threadRef: ref,
293 terminalId: "b",
294 focus: focusB,
295 });
296
297 panel.closeTerminal(ref, surfaceA, "a");
298 focusActivePanelTerminal(ref);
299
300 expect(focusB).toHaveBeenCalledTimes(1);
301 unregister();
302 });
303
304 it("refocuses the panel terminal only when closing changed the active surface", () => {
305 const ref = threadRef("panel-after-close");
306 const panel = useRightPanelStore.getState();
307 panel.openTerminal(ref, "a");
308 const terminalSurfaceId = selectThreadRightPanelState(
309 useRightPanelStore.getState().byThreadKey,
310 ref,
311 ).activeSurfaceId!;
312 panel.open(ref, "diff");
313 const diffSurfaceId = selectThreadRightPanelState(
314 useRightPanelStore.getState().byThreadKey,
315 ref,
316 ).activeSurfaceId!;
317 const focus = vi.fn(() => true);
318 const unregister = registerTerminalFocusTarget({ threadRef: ref, terminalId: "a", focus });
319
320 panel.activateSurface(ref, terminalSurfaceId);
321 panel.closeSurface(ref, diffSurfaceId);
322 focusPanelTerminalAfterClose(ref, terminalSurfaceId);
323 expect(focus).not.toHaveBeenCalled();
324
325 panel.open(ref, "diff");
326 const reopenedDiffId = selectThreadRightPanelState(
327 useRightPanelStore.getState().byThreadKey,
328 ref,
329 ).activeSurfaceId!;
330 panel.closeSurface(ref, reopenedDiffId);
331 focusPanelTerminalAfterClose(ref, reopenedDiffId);
332 expect(focus).toHaveBeenCalledTimes(1);
333 unregister();
334 });
335});