@silo-code/sdk 0.51.0 → 0.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,244 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { retryFocus } from "./use-panel-entry-focus";
6
+
7
+ // The retry is the whole substance of `usePanelEntryFocus` — the hook around it
8
+ // is two subscriptions and a guard. Per the repo testing guide we drive the
9
+ // logic directly rather than rendering React: `requestAnimationFrame` is
10
+ // replaced with a hand-pumped queue so every frame is deterministic. This file
11
+ // opts into jsdom (the SDK's suite is node by default) because the retry reads
12
+ // the live `document.activeElement` to decide whether focus fell to nothing or
13
+ // moved somewhere it must not be yanked back from.
14
+ describe("retryFocus", () => {
15
+ let queue: FrameRequestCallback[] = [];
16
+ let origRaf: typeof globalThis.requestAnimationFrame;
17
+
18
+ beforeEach(() => {
19
+ queue = [];
20
+ origRaf = globalThis.requestAnimationFrame;
21
+ globalThis.requestAnimationFrame = ((cb: FrameRequestCallback) =>
22
+ queue.push(cb)) as typeof globalThis.requestAnimationFrame;
23
+ document.body.innerHTML = "";
24
+ });
25
+ afterEach(() => {
26
+ globalThis.requestAnimationFrame = origRaf;
27
+ });
28
+
29
+ /** Run exactly one scheduled frame (ticks queued during it run on the next flush). */
30
+ function flushFrame() {
31
+ const due = queue;
32
+ queue = [];
33
+ for (const cb of due) cb(0);
34
+ }
35
+
36
+ it("stops re-grabbing focus once it is no longer wanted (the region-cycle race)", () => {
37
+ const focus = vi.fn();
38
+ let wanted = true;
39
+ // Never reports focused, so without the guard it would grab every frame.
40
+ retryFocus(
41
+ focus,
42
+ () => false,
43
+ () => wanted,
44
+ );
45
+
46
+ flushFrame(); // wanted → grabs once
47
+ expect(focus).toHaveBeenCalledTimes(1);
48
+
49
+ wanted = false; // a newer focus intent superseded us
50
+ flushFrame(); // must bail BEFORE grabbing
51
+ flushFrame();
52
+ expect(focus).toHaveBeenCalledTimes(1);
53
+ });
54
+
55
+ it("re-grabs each frame until focus lands, then stops grabbing", () => {
56
+ const focus = vi.fn();
57
+ let focused = false;
58
+ retryFocus(
59
+ focus,
60
+ () => focused,
61
+ () => true,
62
+ );
63
+
64
+ flushFrame(); // not focused → grab
65
+ flushFrame(); // still not focused → grab
66
+ expect(focus).toHaveBeenCalledTimes(2);
67
+
68
+ focused = true;
69
+ flushFrame(); // landed → no further grab
70
+ expect(focus).toHaveBeenCalledTimes(2);
71
+ });
72
+
73
+ // Regression guard for symptom 1 (ADR 0034): a panel that merely re-mounts
74
+ // in the background — which happens to every panel of a workspace whose dock
75
+ // becomes visible again — must not grab focus from the tab the user actually
76
+ // left active. `stillWanted` is required precisely so a mount-time caller
77
+ // can't silently omit it and grab unconditionally.
78
+ it("never grabs on mount when the panel is not the active tab", () => {
79
+ const focus = vi.fn();
80
+ let isActive = false; // this panel re-mounted in the background
81
+ retryFocus(
82
+ focus,
83
+ () => false, // never reports focused → would grab every frame if unguarded
84
+ () => isActive,
85
+ );
86
+
87
+ flushFrame();
88
+ flushFrame();
89
+ expect(focus).not.toHaveBeenCalled();
90
+
91
+ // …and it stays bailed even if the panel becomes active later: the loop
92
+ // ended, so activation focus is the active tab's own concern, not a
93
+ // stale mount-time retry's.
94
+ isActive = true;
95
+ flushFrame();
96
+ expect(focus).not.toHaveBeenCalled();
97
+ });
98
+
99
+ it("never grabs when superseded before the first frame", () => {
100
+ const focus = vi.fn();
101
+ retryFocus(
102
+ focus,
103
+ () => false,
104
+ () => false,
105
+ );
106
+ flushFrame();
107
+ expect(focus).not.toHaveBeenCalled();
108
+ });
109
+
110
+ it("gives up after the frame cap so it never loops forever", () => {
111
+ const focus = vi.fn();
112
+ retryFocus(
113
+ focus,
114
+ () => false, // focus can never land (panel hidden, content disabled, …)
115
+ () => true,
116
+ 3,
117
+ );
118
+
119
+ for (let i = 0; i < 10; i++) flushFrame();
120
+ expect(focus).toHaveBeenCalledTimes(3);
121
+ expect(queue).toHaveLength(0);
122
+ });
123
+
124
+ it("re-grabs after landing only when focus fell to nothing", () => {
125
+ const focus = vi.fn();
126
+ let focused = true; // landed on the first frame
127
+ retryFocus(
128
+ focus,
129
+ () => focused,
130
+ () => true,
131
+ );
132
+
133
+ flushFrame(); // landed → no grab
134
+ expect(focus).not.toHaveBeenCalled();
135
+
136
+ // dockview's show/hide shuffle bounced focus off the content onto nothing.
137
+ focused = false;
138
+ flushFrame();
139
+ expect(focus).toHaveBeenCalledTimes(1);
140
+ });
141
+
142
+ it("does not yank focus back from another real element after it landed", () => {
143
+ const other = document.createElement("input");
144
+ document.body.append(other);
145
+ other.focus();
146
+
147
+ const focus = vi.fn();
148
+ let focused = true;
149
+ retryFocus(
150
+ focus,
151
+ () => focused,
152
+ () => true,
153
+ );
154
+
155
+ flushFrame(); // landed
156
+ focused = false; // the user moved on to `other` — not "lost to nothing"
157
+ flushFrame();
158
+ flushFrame();
159
+ expect(focus).not.toHaveBeenCalled();
160
+ });
161
+
162
+ describe("open menus", () => {
163
+ /** Focus a menu-like element and run one frame of a never-landing retry. */
164
+ function grabsWhileFocusedIn(menu: HTMLElement): boolean {
165
+ document.body.append(menu);
166
+ menu.tabIndex = -1;
167
+ menu.focus();
168
+ const focus = vi.fn();
169
+ retryFocus(
170
+ focus,
171
+ () => false,
172
+ () => true,
173
+ );
174
+ flushFrame();
175
+ return focus.mock.calls.length > 0;
176
+ }
177
+
178
+ // Right-clicking an *inactive* panel activates it, which starts the retry;
179
+ // without this guard the per-frame `focus()` steals focus from the menu
180
+ // that same click just opened and the menu flashes shut.
181
+ it("stands down for a Silo menu (ctx.ui.showMenu)", () => {
182
+ const menu = document.createElement("div");
183
+ menu.setAttribute("data-silo-menu", "tree-1");
184
+ menu.setAttribute("role", "menu");
185
+ expect(grabsWhileFocusedIn(menu)).toBe(false);
186
+ });
187
+
188
+ it("stands down for a Monaco context menu", () => {
189
+ const menu = document.createElement("div");
190
+ menu.className = "context-view";
191
+ expect(grabsWhileFocusedIn(menu)).toBe(false);
192
+ });
193
+
194
+ // Monaco renders its context menu into a shadow root, so from the light DOM
195
+ // `document.activeElement` is only the host element — the menu nodes live
196
+ // one level down, which is why a plain `closest` isn't enough.
197
+ it("stands down for a menu inside a shadow root", () => {
198
+ const host = document.createElement("div");
199
+ document.body.append(host);
200
+ const shadow = host.attachShadow({ mode: "open" });
201
+ const menu = document.createElement("div");
202
+ menu.className = "monaco-menu";
203
+ shadow.append(menu);
204
+ host.tabIndex = -1;
205
+ host.focus();
206
+
207
+ const focus = vi.fn();
208
+ retryFocus(
209
+ focus,
210
+ () => false,
211
+ () => true,
212
+ );
213
+ flushFrame();
214
+ expect(focus).not.toHaveBeenCalled();
215
+ });
216
+
217
+ it("ends the loop rather than resuming once the menu closes", () => {
218
+ const menu = document.createElement("div");
219
+ menu.setAttribute("data-silo-menu", "tree-1");
220
+ menu.tabIndex = -1;
221
+ document.body.append(menu);
222
+ menu.focus();
223
+
224
+ const focus = vi.fn();
225
+ retryFocus(
226
+ focus,
227
+ () => false,
228
+ () => true,
229
+ );
230
+ flushFrame(); // bails
231
+ expect(queue).toHaveLength(0);
232
+
233
+ // Nothing rescheduled: the menu's owner restores content focus itself.
234
+ menu.remove();
235
+ flushFrame();
236
+ expect(focus).not.toHaveBeenCalled();
237
+ });
238
+
239
+ it("still grabs when focus is merely on the body, not a menu", () => {
240
+ const plain = document.createElement("div");
241
+ expect(grabsWhileFocusedIn(plain)).toBe(true);
242
+ });
243
+ });
244
+ });
@@ -0,0 +1,237 @@
1
+ import { useCallback, useEffect, useRef } from "react";
2
+ import type { DockPanelApi } from "./types";
3
+
4
+ // Entry focus for a dock panel — landing the caret in the panel's content when
5
+ // the user enters it.
6
+ //
7
+ // A freshly-mounted or newly-activated dock panel does NOT reliably inherit DOM
8
+ // focus: dockview runs its own focus shuffle when panels mount/activate, and
9
+ // rich content (Monaco, xterm) only accepts focus once its internal textarea
10
+ // has been laid out with non-zero dimensions. A single `focus()` call loses
11
+ // these races, which is why a panel would otherwise need a second click before
12
+ // you can type. We retry across animation frames until the target actually
13
+ // reports focus, the panel is no longer the one we wanted to focus, or we hit a
14
+ // frame cap (~330ms) so we never loop forever when focus legitimately can't be
15
+ // grabbed (panel hidden, content disabled, etc.).
16
+
17
+ const DEFAULT_CAP = 20;
18
+
19
+ // Every menu implementation whose open state must suppress the retry: Silo's
20
+ // own menus (`ctx.ui.showMenu`, which renders `[data-silo-menu]` with
21
+ // `role="menu"`), a third-party menu that follows the same ARIA role, and
22
+ // Monaco's context menu (`.context-view` / `.monaco-menu`).
23
+ const MENU_SELECTOR =
24
+ '[data-silo-menu], [role="menu"], .context-view, .monaco-menu';
25
+
26
+ /**
27
+ * Whether focus currently sits inside an open floating menu. Monaco renders its
28
+ * context menu into a shadow root, where `document.activeElement` resolves only
29
+ * to the open `shadow-root-host` and the menu nodes hang off its `shadowRoot` —
30
+ * hence the shadow peek, not just a light-DOM `closest`.
31
+ */
32
+ function focusInMenu(active: Element | null): boolean {
33
+ if (!active) return false;
34
+ if (active.closest(MENU_SELECTOR)) return true;
35
+ return active.shadowRoot?.querySelector(MENU_SELECTOR) != null;
36
+ }
37
+
38
+ /**
39
+ * Repeatedly call `focus()` on successive animation frames until `isFocused()`
40
+ * returns true, `stillWanted()` returns false, or `cap` frames have elapsed.
41
+ *
42
+ * `stillWanted` is **required**, and must express "this panel is still the one
43
+ * that should hold focus" — normally `() => api.isActive`, or the center
44
+ * dock's focus-intent token (`focusGen`). It is deliberately not optional: a
45
+ * retry loop without it keeps grabbing focus for its entire frame budget no
46
+ * matter what else happened meanwhile. That matters most on *mount*, because
47
+ * re-entering a workspace re-mounts its panels — so an unguarded mount-time
48
+ * grab in a background tab yanks focus away from the tab the user actually
49
+ * left active. And because DOM focus landing inside a dockview panel makes
50
+ * dockview mark that panel's group active
51
+ * (`contentContainer.onDidFocus → doSetGroupActive`), stealing focus silently
52
+ * changes the visible active tab too. That was symptom 1 in ADR 0034.
53
+ *
54
+ * Panel content should reach for {@link usePanelEntryFocus} instead, which
55
+ * supplies the guard and both entry subscriptions for you. This raw form is
56
+ * host/`core.*` plumbing for the non-React callers that can't.
57
+ *
58
+ * @internal
59
+ */
60
+ export function retryFocus(
61
+ focus: () => void,
62
+ isFocused: () => boolean,
63
+ stillWanted: () => boolean,
64
+ cap = DEFAULT_CAP,
65
+ ): void {
66
+ let attempts = 0;
67
+ let landed = false;
68
+ const tick = () => {
69
+ // Bail before touching focus if we're no longer wanted — a newer focus
70
+ // intent superseded us (e.g. the user kept pressing the region-cycle chord).
71
+ // Checked here, not just after the grab below, so a stale retry can't yank
72
+ // focus back from the area the user actually moved to.
73
+ if (!stillWanted()) return;
74
+ attempts += 1;
75
+ if (isFocused()) {
76
+ landed = true;
77
+ } else {
78
+ // Before focus first lands, grab aggressively each frame — Monaco/xterm
79
+ // only accept focus once their textarea is laid out, so early calls lose.
80
+ // After it has landed, only re-grab if focus fell to NOTHING (<body>/null):
81
+ // dockview's show/hide shuffle can bounce focus off the just-focused
82
+ // content a frame or two later (the "keystrokes dropped after a switch"
83
+ // symptom). We watch the full frame budget to catch that — but never yank
84
+ // focus away from another real element the user may have moved to.
85
+ const active = document.activeElement;
86
+ // Focus legitimately moved into an open context menu — don't yank it
87
+ // back, or the menu's focus-out dismiss fires and it flashes shut.
88
+ // Right-clicking an *inactive* panel activates it, which starts this
89
+ // retry; without the guard the per-frame `focus()` steals focus from the
90
+ // just-opened menu (the focused case never starts the retry, which is why
91
+ // the bug only shows when the panel was unfocused). End the loop: the
92
+ // menu's owner restores content focus itself once the menu closes.
93
+ if (focusInMenu(active)) return;
94
+ const lostToNothing = active === null || active === document.body;
95
+ if (!landed || lostToNothing) focus();
96
+ }
97
+ if (attempts >= cap || !stillWanted()) return;
98
+ requestAnimationFrame(tick);
99
+ };
100
+ requestAnimationFrame(tick);
101
+ }
102
+
103
+ /**
104
+ * The content {@link usePanelEntryFocus} drives — how to focus it, how to tell
105
+ * whether it already has focus, and optionally how to release it.
106
+ *
107
+ * All three are called through a latest-ref, so inline closures over props or
108
+ * state are always current; you never need to memoize them.
109
+ *
110
+ * @category Core Types
111
+ * @public
112
+ */
113
+ export interface PanelEntryFocusTarget {
114
+ /**
115
+ * Imperatively focus the panel's content — e.g. `() => inputRef.current?.focus()`
116
+ * or `() => editor.focus()`. Safe to make a no-op while the content isn't
117
+ * ready to take focus; the retry simply expires.
118
+ */
119
+ focus: () => void;
120
+ /**
121
+ * Whether the content holds DOM keyboard focus **right now**, so the retry
122
+ * can stop as soon as it lands instead of burning the whole frame budget.
123
+ *
124
+ * Read the live `document.activeElement` — e.g.
125
+ * `() => document.activeElement === inputRef.current`. Don't trust a
126
+ * library's own tracker flag (Monaco's `hasTextFocus()`, say): those go
127
+ * stale-true when a blur is dropped during dockview's show/hide shuffle,
128
+ * which stops the retry believing focus landed when it didn't.
129
+ */
130
+ isFocused: () => boolean;
131
+ /**
132
+ * Release the content's focus when the panel's tab is deactivated. Only
133
+ * needed for content that tracks its own focus state internally — a Monaco
134
+ * editor or an xterm terminal is kept alive (hidden) while its tab is
135
+ * inactive, and if its tracker stays stuck believing it has focus it will
136
+ * steal keystrokes meant for the tab you switched to. A plain input needs
137
+ * nothing here.
138
+ */
139
+ blur?: () => void;
140
+ }
141
+
142
+ /**
143
+ * Land keyboard focus in a dock panel's content whenever the user enters the
144
+ * panel, winning dockview's focus shuffle — and, symmetrically, release it when
145
+ * the panel's tab is deactivated.
146
+ *
147
+ * A panel that puts the cursor somewhere on entry (a chat composer, a search
148
+ * box, an editor) needs three things that are easy to get individually wrong,
149
+ * and this hook is all three in one call:
150
+ *
151
+ * 1. **Both entry signals.** {@link DockPanelApi.onDidActiveChange} covers real
152
+ * activations. {@link DockPanelApi.onDidRequestFocus} covers a click on a
153
+ * tab that was *already* active — dockview treats that as a complete no-op
154
+ * and fires no active-change event, even though it is the commonest way a
155
+ * user asks for the cursor back (they were typing in a side panel, clicked
156
+ * the status bar, then clicked your tab).
157
+ * 2. **A guard.** Focus is only ever taken while the panel is the active one.
158
+ * Mount is not "the user just created this panel" — re-entering a workspace
159
+ * re-mounts every panel in its dock — and DOM focus landing in a panel makes
160
+ * dockview activate its group, so an unguarded grab silently changes the
161
+ * visible tab (ADR 0034).
162
+ * 3. **A retry.** A single `focus()` loses dockview's shuffle, and rich content
163
+ * only accepts focus once its internal textarea is laid out. The hook
164
+ * retries across frames until focus lands, standing down if the panel stops
165
+ * being active or focus moves into an open context menu.
166
+ *
167
+ * ```tsx
168
+ * function AcmeChatPanel({ api }: DockPanelProps) {
169
+ * const input = useRef<HTMLTextAreaElement | null>(null);
170
+ *
171
+ * usePanelEntryFocus(api, {
172
+ * focus: () => input.current?.focus(),
173
+ * isFocused: () => document.activeElement === input.current,
174
+ * });
175
+ *
176
+ * return <textarea ref={input} />;
177
+ * }
178
+ * ```
179
+ *
180
+ * The returned function runs the same guarded retry on demand, for content that
181
+ * becomes focusable at a moment that is neither a mount nor an activation — a
182
+ * session finishing its connect, an editor finishing its own mount. It is
183
+ * referentially stable, so it is safe in a dependency array:
184
+ *
185
+ * ```tsx
186
+ * const focusComposer = usePanelEntryFocus(api, { focus, isFocused });
187
+ *
188
+ * useEffect(() => {
189
+ * if (ready) focusComposer();
190
+ * }, [ready, focusComposer]);
191
+ * ```
192
+ *
193
+ * @param api - The surrounding panel's api ({@link DockPanelProps.api}).
194
+ * @param target - How to focus, test, and release the panel's content.
195
+ * @returns A stable callback that runs the guarded focus retry on demand.
196
+ *
197
+ * @category Consumer Services
198
+ * @public
199
+ */
200
+ export function usePanelEntryFocus(
201
+ api: DockPanelApi,
202
+ target: PanelEntryFocusTarget,
203
+ ): () => void {
204
+ // Read the callbacks through a ref rather than closing over them: the
205
+ // subscription effect depends on `api` alone (re-subscribing every render
206
+ // would be worse), so a captured `target` would freeze at its first render
207
+ // and an author's inline `() => { if (enabled) … }` would go stale.
208
+ const latest = useRef(target);
209
+ useEffect(() => {
210
+ latest.current = target;
211
+ });
212
+
213
+ const focusNow = useCallback(() => {
214
+ if (!api.isActive) return;
215
+ retryFocus(
216
+ () => latest.current.focus(),
217
+ () => latest.current.isFocused(),
218
+ () => api.isActive,
219
+ );
220
+ }, [api]);
221
+
222
+ useEffect(() => {
223
+ const subActive = api.onDidActiveChange(() => {
224
+ if (api.isActive) focusNow();
225
+ else latest.current.blur?.();
226
+ });
227
+ // `focusNow` guards on `isActive` itself: a Focus Request says only that
228
+ // the user asked, never that this panel became active.
229
+ const subRequest = api.onDidRequestFocus(focusNow);
230
+ return () => {
231
+ subActive.dispose();
232
+ subRequest.dispose();
233
+ };
234
+ }, [api, focusNow]);
235
+
236
+ return focusNow;
237
+ }