Skip to content
Source
Sign inSign up

terminalFocus.ts

235 lines9.4 KB

1import { scopedThreadKey } from "@t3tools/client-runtime/environment";
2import type { ScopedThreadRef } from "@t3tools/contracts";
3​
4import { selectThreadRightPanelState, useRightPanelStore } from "../rightPanelStore";
5import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
6​
7export type TerminalFocusOwner = "drawer" | "right-panel";
8​
9export function getTerminalFocusOwner(): TerminalFocusOwner | null {
10 const activeElement = document.activeElement;
11 if (!(activeElement instanceof HTMLElement)) return null;
12 if (!activeElement.isConnected) return null;
13 const owner = activeElement.closest<HTMLElement>("[data-terminal-owner]")?.dataset.terminalOwner;
14 if (owner === "drawer" || owner === "right-panel") return owner;
15 return null;
17​
18export function isTerminalFocused(): boolean {
19 return getTerminalFocusOwner() !== null;
21​
22export function subscribeToTerminalFocusChanges(listener: () => void): () => void {
23 window.addEventListener("focusin", listener, true);
24 window.addEventListener("focusout", listener, true);
25 return () => {
26 window.removeEventListener("focusin", listener, true);
27 window.removeEventListener("focusout", listener, true);
28 };
30​
31interface TerminalFocusTarget {
32 readonly threadRef: ScopedThreadRef;
33 readonly terminalId: string;
34 readonly element?: HTMLElement;
35 /** Returns false when the target cannot take focus yet (hidden, or no operate grant). */
36 readonly focus: () => boolean;
38​
39const focusTargets = new Set<TerminalFocusTarget>();
40const lastThreadFocus = new Map<string, "composer" | { terminalId: string }>();
41// One request at a time: the newest terminal the user asked for wins, so an
42// older request never fires later and steals focus.
43let pendingFocus: { readonly threadKey: string; readonly terminalId: string } | null = null;
44// The thread ChatView currently shows, and the thread a navigating handler
45// asked to focus a terminal in. The mark outlives `pendingFocus`: the new
46// thread's viewports mount and consume the handler's request in the commit's
47// layout phase, before ChatView's thread-enter effect runs.
48let enteredThreadKey: string | null = null;
49// Drawer visibility reruns reuse the root; a remounted chat must not inherit parked requests.
50const lastEnteredThreadByRoot = new WeakMap<HTMLElement, string>();
51let navigationRequestThreadKey: string | null = null;
52​
53function isPending(threadKey: string, terminalId: string): boolean {
54 return pendingFocus?.threadKey === threadKey && pendingFocus.terminalId === terminalId;
56​
57function focusMountedTarget(threadKey: string, terminalId: string): boolean {
58 for (const target of focusTargets) {
59 if (
60 target.terminalId === terminalId &&
61 scopedThreadKey(target.threadRef) === threadKey &&
62 target.focus()
63 ) {
64 return true;
65 }
66 }
67 return false;
69​
70/**
71 * Focuses a thread's terminal when its viewport can take focus, or once it can.
72 * Opening, creating or switching to a terminal usually mounts or shows its
73 * viewport after the handler returns, so the request waits for the viewport.
74 */
75export function focusTerminal(threadRef: ScopedThreadRef, terminalId: string): void {
76 const threadKey = scopedThreadKey(threadRef);
77 pendingFocus = focusMountedTarget(threadKey, terminalId) ? null : { threadKey, terminalId };
79​
80/**
81 * Focuses the drawer terminal the store now marks active, after an open, close
82 * or switch. A thread whose drawer ended up closed drops its waiting request.
83 */
84export function focusActiveDrawerTerminal(threadRef: ScopedThreadRef): void {
85 const { terminalOpen, activeTerminalId } = selectThreadTerminalUiState(
86 useTerminalUiStateStore.getState().terminalUiStateByThreadKey,
87 threadRef,
88 );
89 if (terminalOpen && activeTerminalId) focusTerminal(threadRef, activeTerminalId);
90 else cancelTerminalFocus(threadRef);
92​
93/** Focuses the active terminal of the right panel when it shows a terminal surface. */
94export function focusActivePanelTerminal(threadRef: ScopedThreadRef): void {
95 const { isOpen, activeSurfaceId, surfaces } = selectThreadRightPanelState(
96 useRightPanelStore.getState().byThreadKey,
97 threadRef,
98 );
99 const surface = isOpen ? surfaces.find((candidate) => candidate.id === activeSurfaceId) : null;
100 if (surface?.kind === "terminal") focusTerminal(threadRef, surface.activeTerminalId);
102​
103/**
104 * After a right panel surface closes, focuses the terminal surface the panel
105 * fell back to. A terminal surface that was already active keeps its focus as is.
106 */
107export function focusPanelTerminalAfterClose(
108 threadRef: ScopedThreadRef,
109 previousActiveSurfaceId: string | null,
110): void {
111 const { activeSurfaceId } = selectThreadRightPanelState(
112 useRightPanelStore.getState().byThreadKey,
113 threadRef,
114 );
115 if (activeSurfaceId !== previousActiveSurfaceId) focusActivePanelTerminal(threadRef);
117​
118/**
119 * Focuses a terminal from a handler that may also navigate to its thread (a
120 * sidebar terminal reveal). When it does, that thread's entry keeps this request
121 * instead of focusing the drawer.
122 */
123export function focusTerminalOnNavigation(threadRef: ScopedThreadRef, terminalId: string): void {
124 const threadKey = scopedThreadKey(threadRef);
125 navigationRequestThreadKey = threadKey === enteredThreadKey ? null : threadKey;
126 focusTerminal(threadRef, terminalId);
128​
129/** Restores composer or terminal focus; explicit terminal navigation takes precedence. */
130export function restoreFocusOnThreadEnter(
131 threadRef: ScopedThreadRef,
132 content: { readonly root: HTMLElement; readonly focusComposer: () => void },
133): () => void {
134 const threadKey = scopedThreadKey(threadRef);
135 const requestedByHandler =
136 navigationRequestThreadKey === threadKey ||
137 (lastEnteredThreadByRoot.get(content.root) === threadKey &&
138 pendingFocus?.threadKey === threadKey);
139 if (!requestedByHandler) pendingFocus = null;
140 lastEnteredThreadByRoot.set(content.root, threadKey);
141 navigationRequestThreadKey = null;
142 enteredThreadKey = threadKey;
143 const previous = lastThreadFocus.get(threadKey);
144 const remember = () => {
145 const element = content.root.ownerDocument.activeElement;
146 if (!element) return;
147 const terminal = Array.from(focusTargets).find((target) => target.element?.contains(element));
148 if (terminal) {
149 if (scopedThreadKey(terminal.threadRef) === threadKey)
150 lastThreadFocus.set(threadKey, { terminalId: terminal.terminalId });
151 } else if (
152 content.root.contains(element) &&
153 element.closest('[data-workspace-area="composer"]')
154 ) {
155 lastThreadFocus.set(threadKey, "composer");
156 }
157 };
158 content.root.ownerDocument.addEventListener("focusin", remember);
159 if (!requestedByHandler) {
160 const drawer = selectThreadTerminalUiState(
161 useTerminalUiStateStore.getState().terminalUiStateByThreadKey,
162 threadRef,
163 );
164 const panel = selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, threadRef);
165 const panelTerminal = panel.isOpen
166 ? panel.surfaces.find((surface) => surface.id === panel.activeSurfaceId)
167 : null;
168 if (previous === "composer") {
169 content.focusComposer();
170 } else if (
171 previous &&
172 ((drawer.terminalOpen &&
173 drawer.terminalGroups
174 .find((group) => group.id === drawer.activeTerminalGroupId)
175 ?.terminalIds.includes(previous.terminalId)) ||
176 (panelTerminal?.kind === "terminal" &&
177 panelTerminal.terminalIds.includes(previous.terminalId)))
178 ) {
179 focusTerminal(threadRef, previous.terminalId);
180 } else if (drawer.terminalOpen && drawer.activeTerminalId) {
181 focusTerminal(threadRef, drawer.activeTerminalId);
182 } else content.focusComposer();
183 }
184 if (requestedByHandler) remember();
185 return () => {
186 content.root.ownerDocument.removeEventListener("focusin", remember);
187 // A handler navigating elsewhere marks its target before this cleanup runs.
188 if (enteredThreadKey === threadKey) enteredThreadKey = null;
189 };
191​
192function cancelTerminalFocus(threadRef: ScopedThreadRef): void {
193 if (pendingFocus?.threadKey === scopedThreadKey(threadRef)) pendingFocus = null;
195​
196/**
197 * Retries a waiting request once a mounted viewport can take it: when it is
198 * shown (a viewport kept mounted while hidden has `display: none`) or when the
199 * operate grant arrives. Neither ever invents a request.
200 */
201export function consumePendingTerminalFocus(threadRef: ScopedThreadRef, terminalId: string): void {
202 const threadKey = scopedThreadKey(threadRef);
203 if (isPending(threadKey, terminalId) && focusMountedTarget(threadKey, terminalId)) {
204 pendingFocus = null;
205 }
207​
208export function registerTerminalFocusTarget(target: TerminalFocusTarget): () => void {
209 focusTargets.add(target);
210 if (isPending(scopedThreadKey(target.threadRef), target.terminalId) && target.focus()) {
211 pendingFocus = null;
212 }
213 return () => {
214 focusTargets.delete(target);
215 };
217​
218/**
219 * A viewport's `focus` for its target. Focus lands on the mount until the
220 * surface exists, and the surface takes it over from there.
221 */
222export function focusTerminalViewport(input: {
223 readonly mount: HTMLElement;
224 readonly surface: { focus(): void } | null;
225 readonly canOperate: boolean;
226 readonly visible: boolean;
227}): boolean {
228 // A hidden drawer is clipped, not display:none: the mount would take focus
229 // while the surface refuses it, and the request would be spent on a wrapper
230 // that ignores typing.
231 if (!input.canOperate || !input.visible) return false;
232 input.mount.focus({ preventScroll: true });
233 input.surface?.focus();
234 return input.mount.contains(document.activeElement);