@gusnips/react 0.9.24 → 0.9.26

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.
Files changed (116) hide show
  1. package/README.md +173 -36
  2. package/dist/api-client.d.ts +51 -51
  3. package/dist/api-client.d.ts.map +1 -1
  4. package/dist/api-client.js +3 -3
  5. package/dist/api-error.d.ts +11 -11
  6. package/dist/api-error.js +11 -11
  7. package/dist/auth-email.d.ts +40 -0
  8. package/dist/auth-email.d.ts.map +1 -0
  9. package/dist/auth-email.js +106 -0
  10. package/dist/auth-email.js.map +1 -0
  11. package/dist/auth-store.d.ts +23 -23
  12. package/dist/auth-store.js +13 -13
  13. package/dist/commands.d.ts +80 -0
  14. package/dist/commands.d.ts.map +1 -0
  15. package/dist/commands.js +192 -0
  16. package/dist/commands.js.map +1 -0
  17. package/dist/copy.d.ts +3 -3
  18. package/dist/copy.js +3 -3
  19. package/dist/deploy-recovery.d.ts +40 -40
  20. package/dist/deploy-recovery.js +40 -40
  21. package/dist/describe-error.d.ts +68 -68
  22. package/dist/describe-error.d.ts.map +1 -1
  23. package/dist/describe-error.js +5 -5
  24. package/dist/error-boundary.d.ts +12 -12
  25. package/dist/guards.d.ts +15 -15
  26. package/dist/guards.js +3 -3
  27. package/dist/hydrate.d.ts +15 -16
  28. package/dist/hydrate.d.ts.map +1 -1
  29. package/dist/hydrate.js +9 -9
  30. package/dist/hydrate.js.map +1 -1
  31. package/dist/i18n.d.ts +15 -16
  32. package/dist/i18n.d.ts.map +1 -1
  33. package/dist/i18n.js +5 -6
  34. package/dist/i18n.js.map +1 -1
  35. package/dist/index.d.ts +1 -0
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +1 -0
  38. package/dist/index.js.map +1 -1
  39. package/dist/internal-path.d.ts +8 -7
  40. package/dist/internal-path.d.ts.map +1 -1
  41. package/dist/internal-path.js +8 -7
  42. package/dist/internal-path.js.map +1 -1
  43. package/dist/prerender-contract.d.ts +7 -7
  44. package/dist/prerender-contract.js +7 -7
  45. package/dist/query.d.ts +16 -17
  46. package/dist/query.d.ts.map +1 -1
  47. package/dist/query.js +2 -2
  48. package/dist/query.js.map +1 -1
  49. package/dist/sse.d.ts +19 -20
  50. package/dist/sse.d.ts.map +1 -1
  51. package/dist/sse.js +18 -19
  52. package/dist/sse.js.map +1 -1
  53. package/dist/states.d.ts +12 -12
  54. package/dist/states.d.ts.map +1 -1
  55. package/dist/supabase-session.d.ts +47 -48
  56. package/dist/supabase-session.d.ts.map +1 -1
  57. package/dist/supabase-session.js +35 -35
  58. package/dist/supabase-session.js.map +1 -1
  59. package/dist/theme.d.ts +4 -4
  60. package/dist/theme.js +4 -4
  61. package/dist/ui/class-name.d.ts +3 -4
  62. package/dist/ui/class-name.d.ts.map +1 -1
  63. package/dist/ui/class-name.js +3 -4
  64. package/dist/ui/class-name.js.map +1 -1
  65. package/dist/ui/combobox.d.ts +11 -11
  66. package/dist/ui/combobox.d.ts.map +1 -1
  67. package/dist/ui/dialog.d.ts +5 -5
  68. package/dist/ui/dialog.js +2 -2
  69. package/dist/ui/drawer.d.ts +10 -10
  70. package/dist/ui/drawer.d.ts.map +1 -1
  71. package/dist/ui/drawer.js +2 -2
  72. package/dist/ui/input.d.ts +2 -2
  73. package/dist/ui/input.js +2 -2
  74. package/dist/ui/menu.d.ts +9 -9
  75. package/dist/ui/menu.d.ts.map +1 -1
  76. package/dist/ui/menu.js +4 -4
  77. package/dist/ui/menu.js.map +1 -1
  78. package/dist/ui/select.d.ts +12 -12
  79. package/dist/ui/select.d.ts.map +1 -1
  80. package/dist/ui/select.js +3 -3
  81. package/dist/ui/tabs.d.ts +14 -15
  82. package/dist/ui/tabs.d.ts.map +1 -1
  83. package/dist/ui/tabs.js +13 -14
  84. package/dist/ui/tabs.js.map +1 -1
  85. package/package.json +5 -1
  86. package/src/api-client.ts +51 -51
  87. package/src/api-error.ts +11 -11
  88. package/src/auth-email.test.ts +145 -0
  89. package/src/auth-email.ts +112 -0
  90. package/src/auth-store.ts +23 -23
  91. package/src/commands-hook.test.ts +444 -0
  92. package/src/commands.test.ts +245 -0
  93. package/src/commands.ts +268 -0
  94. package/src/copy.ts +3 -3
  95. package/src/deploy-recovery.ts +40 -40
  96. package/src/describe-error.ts +68 -68
  97. package/src/error-boundary.tsx +12 -12
  98. package/src/guards.tsx +15 -15
  99. package/src/hydrate.ts +15 -16
  100. package/src/i18n.ts +15 -16
  101. package/src/index.ts +2 -0
  102. package/src/internal-path.ts +8 -7
  103. package/src/prerender-contract.ts +7 -7
  104. package/src/query.ts +16 -17
  105. package/src/sse.ts +19 -20
  106. package/src/states.ts +12 -12
  107. package/src/supabase-session.ts +46 -47
  108. package/src/theme.ts +4 -4
  109. package/src/ui/class-name.ts +3 -4
  110. package/src/ui/combobox.tsx +11 -11
  111. package/src/ui/dialog.tsx +5 -5
  112. package/src/ui/drawer.tsx +10 -10
  113. package/src/ui/input.tsx +2 -2
  114. package/src/ui/menu.tsx +8 -8
  115. package/src/ui/select.tsx +12 -12
  116. package/src/ui/tabs.tsx +14 -15
package/src/auth-store.ts CHANGED
@@ -3,14 +3,14 @@ import { create, type StoreApi, type UseBoundStore } from "zustand";
3
3
  /**
4
4
  * Session FLAGS only.
5
5
  *
6
- * Who the person is — their plan, their staff bit, whether they are suspended — comes from
7
- * `GET /auth/me` through react-query, never from here. Mirroring server state in a client store
8
- * is how two sources of truth start disagreeing, and the one that is wrong is always the one on
9
- * screen. Three repos wrote this same store; two of them wrote that same warning in a comment.
6
+ * Who the person is, including their plan, staff bit and suspension status, comes from `GET
7
+ * /auth/me` through react-query, never from here. Mirroring server state in a client store is how
8
+ * two sources of truth start disagreeing, and the one that is wrong is always the one on screen.
9
+ * Three repos wrote this same store; two of them wrote that same warning in a comment.
10
10
  *
11
11
  * `TUser` stays generic because it is the one part that differs: one product carries an
12
- * anonymous-browsing flag, another does not. Keep it to what a GUARD needs — an id and an
13
- * email is what all three donors had.
12
+ * anonymous-browsing flag, another does not. Keep it to what a GUARD needs. All three donors had an
13
+ * id and an email.
14
14
  */
15
15
  export interface AuthState<TUser> {
16
16
  user: TUser | null;
@@ -21,10 +21,10 @@ export interface AuthState<TUser> {
21
21
  */
22
22
  isLoading: boolean;
23
23
  /**
24
- * Set (or clear) the signed-in user. This ENDS the loading state — knowing who they are is
25
- * what the bootstrap was waiting for. Two of the three donors left `isLoading` alone here and
26
- * relied on a separate `setLoading(false)`; forgetting that call leaves every guard spinning,
27
- * so the safe default is to do it in one write.
24
+ * Set (or clear) the signed-in user. This ENDS the loading state, because the bootstrap was
25
+ * waiting to learn who they are. Two of the three donors left `isLoading` alone here and relied
26
+ * on a separate `setLoading(false)`; forgetting that call leaves every guard spinning, so the
27
+ * safe default is to do it in one write.
28
28
  */
29
29
  setUser: (user: TUser | null) => void;
30
30
  setLoading: (isLoading: boolean) => void;
@@ -38,12 +38,12 @@ export interface AuthState<TUser> {
38
38
  * The whole reason this is a factory and not a store: `isLoading` must start FALSE where there
39
39
  * is no window, and only a factory can decide that at the call site rather than at import.
40
40
  *
41
- * A session bootstrap can only be in flight in a browser. The BUILD renders this app to files
42
- * with no window at all, so `true` there is a wait that never ends — one donor shipped a route
43
- * guard holding its loading screen forever, and prerendered a public page as 1,174 bytes of
44
- * `role="status"`: a spinner as the indexable body of a page whose entire purpose was to be
45
- * found. With no browser there is no session and never will be, which is exactly the state a
46
- * first-time visitor arrives in, so that is what the page should render.
41
+ * A session bootstrap can only be in flight in a browser. The BUILD renders this app to files with
42
+ * no window at all, so `true` there is a wait that never ends. One donor shipped a route guard
43
+ * holding its loading screen forever, and prerendered a public page as 1,174 bytes of
44
+ * `role="status"`: a spinner as the indexable body of a page whose entire purpose was to be found.
45
+ * With no browser there is no session and never will be, which is exactly the state a first-time
46
+ * visitor arrives in, so that is what the page should render.
47
47
  *
48
48
  * Only one of the three donors knew this. It is invariant 7.
49
49
  */
@@ -70,13 +70,13 @@ type AuthSet<TUser, TExtra> = (
70
70
  /**
71
71
  * The session flags as a plain object, for a product whose store needs more than them.
72
72
  *
73
- * `createAuthStore` owns its `create()` call, which means it owns the whole store: a product
74
- * cannot wrap it in `persist`, cannot add an action, and cannot add a field. Both adopters that
75
- * met it had a superset and neither could use it. One holds a remember-me choice, a profile row
76
- * and the sign-in methods themselves, under `persist`. The other adds `isAnonymous` — and that
77
- * one is the reason this takes a `derive` function rather than just letting the caller spread
78
- * extra keys in: `isAnonymous` is read off the user, so it has to be rewritten by `setUser` and
79
- * `clear`, which are exactly the two writes the product does not own.
73
+ * `createAuthStore` owns its `create()` call, which means it owns the whole store: a product cannot
74
+ * wrap it in `persist`, cannot add an action, and cannot add a field. Both adopters that met it had
75
+ * a superset and neither could use it. One holds a remember-me choice, a profile row and the
76
+ * sign-in methods themselves, under `persist`. The other adds `isAnonymous`, which is the reason
77
+ * this takes a `derive` function rather than just letting the caller spread extra keys in:
78
+ * `isAnonymous` is read off the user, so it has to be rewritten by `setUser` and `clear`, which are
79
+ * exactly the two writes the product does not own.
80
80
  *
81
81
  * The caller keeps `create`, so middleware, extra actions and the store's own name stay theirs:
82
82
  *
@@ -0,0 +1,444 @@
1
+ // @vitest-environment happy-dom
2
+ import { act, createElement, useLayoutEffect, type ChangeEvent } from "react";
3
+ import { createRoot, type Root } from "react-dom/client";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { useSlashCommands, type SlashCommand } from "./commands.ts";
6
+
7
+ interface Action extends SlashCommand {
8
+ action: string;
9
+ }
10
+
11
+ const commands: readonly Action[] = [
12
+ {
13
+ name: "help",
14
+ description: "See commands",
15
+ aliases: ["commands"],
16
+ tags: ["keyboard"],
17
+ action: "help",
18
+ },
19
+ {
20
+ name: "new",
21
+ description: "Start a chat",
22
+ aliases: ["fresh"],
23
+ tags: ["conversation"],
24
+ action: "new",
25
+ },
26
+ {
27
+ name: "stop",
28
+ description: "Stop the reply",
29
+ aliases: ["cancel"],
30
+ tags: ["reply"],
31
+ action: "stop",
32
+ },
33
+ {
34
+ name: "model",
35
+ description: "Choose a model",
36
+ aliases: ["llm"],
37
+ tags: ["engine"],
38
+ takesArg: true,
39
+ action: "model",
40
+ },
41
+ ];
42
+
43
+ const roots = new Set<Root>();
44
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
45
+
46
+ afterEach(() => {
47
+ for (const root of roots) act(() => root.unmount());
48
+ roots.clear();
49
+ document.body.replaceChildren();
50
+ });
51
+
52
+ function composer(initialText = "/", initialCommands = commands) {
53
+ const element = document.createElement("div");
54
+ document.body.append(element);
55
+ const root = createRoot(element);
56
+ roots.add(root);
57
+ let text = initialText;
58
+ let registry = initialCommands;
59
+ let current: ReturnType<typeof useSlashCommands<Action>> | undefined;
60
+ const execute = vi.fn();
61
+ const error = vi.fn();
62
+ const send = vi.fn();
63
+ const changed = vi.fn((next: string) => {
64
+ text = next;
65
+ render();
66
+ });
67
+
68
+ function Harness() {
69
+ const slash = useSlashCommands({
70
+ text,
71
+ commands: registry,
72
+ onChange: changed,
73
+ onExecute: execute,
74
+ onError: error,
75
+ });
76
+ useLayoutEffect(() => {
77
+ current = slash;
78
+ }, [slash]);
79
+ return createElement(
80
+ "div",
81
+ null,
82
+ createElement("textarea", {
83
+ value: text,
84
+ onChange: (event: ChangeEvent<HTMLTextAreaElement>) => changed(event.currentTarget.value),
85
+ "aria-label": "Message",
86
+ role: "combobox",
87
+ "aria-autocomplete": "list",
88
+ "aria-expanded": slash.open,
89
+ "aria-controls": slash.open ? slash.listId : undefined,
90
+ "aria-activedescendant": slash.activeId,
91
+ onKeyDown: (event) => {
92
+ if (slash.handleKeyDown(event)) return;
93
+ if (event.key === "Enter" && !event.shiftKey) {
94
+ event.preventDefault();
95
+ send(text);
96
+ }
97
+ },
98
+ }),
99
+ createElement(
100
+ "button",
101
+ {
102
+ type: "button",
103
+ onClick: () => {
104
+ if (!slash.submit()) send(text);
105
+ },
106
+ },
107
+ "Send",
108
+ ),
109
+ slash.open
110
+ ? createElement(
111
+ "div",
112
+ { id: slash.listId, role: "listbox", "aria-label": "Commands" },
113
+ ...slash.items.map((command, index) =>
114
+ createElement(
115
+ "button",
116
+ {
117
+ key: command.name,
118
+ type: "button",
119
+ tabIndex: -1,
120
+ id: slash.itemId(command),
121
+ role: "option",
122
+ "aria-selected": slash.index === index,
123
+ onMouseEnter: () => slash.highlight(index),
124
+ onMouseDown: (event) => event.preventDefault(),
125
+ onClick: (event) => slash.pick(command, event.altKey),
126
+ },
127
+ `/${command.name}`,
128
+ ),
129
+ ),
130
+ )
131
+ : null,
132
+ );
133
+ }
134
+
135
+ function render() {
136
+ root.render(createElement(Harness));
137
+ }
138
+ act(render);
139
+ const input = element.querySelector("textarea");
140
+ const sendButton = element.querySelector("button");
141
+ if (!input || !sendButton) throw new Error("Composer did not render");
142
+ input.focus();
143
+
144
+ return {
145
+ execute,
146
+ error,
147
+ send,
148
+ changed,
149
+ input,
150
+ get text() {
151
+ return text;
152
+ },
153
+ get menu() {
154
+ if (!current) throw new Error("Hook did not render");
155
+ return current;
156
+ },
157
+ change(next: string) {
158
+ act(() => {
159
+ text = next;
160
+ render();
161
+ });
162
+ },
163
+ register(next: readonly Action[]) {
164
+ act(() => {
165
+ registry = next;
166
+ render();
167
+ });
168
+ },
169
+ press(key: string, options: KeyboardEventInit = {}) {
170
+ const event = new KeyboardEvent("keydown", {
171
+ key,
172
+ bubbles: true,
173
+ cancelable: true,
174
+ ...options,
175
+ });
176
+ act(() => input.dispatchEvent(event));
177
+ return event;
178
+ },
179
+ clickSend() {
180
+ act(() => sendButton.click());
181
+ },
182
+ clickOption(name: string, options: MouseEventInit = {}) {
183
+ const option = Array.from(element.querySelectorAll("button[role=option]")).find(
184
+ (button) => button.textContent === `/${name}`,
185
+ );
186
+ if (!option) throw new Error(`Command not rendered: ${name}`);
187
+ const down = new MouseEvent("mousedown", { bubbles: true, cancelable: true });
188
+ act(() => {
189
+ option.dispatchEvent(down);
190
+ option.dispatchEvent(new MouseEvent("click", { bubbles: true, ...options }));
191
+ });
192
+ return down;
193
+ },
194
+ };
195
+ }
196
+
197
+ describe("useSlashCommands", () => {
198
+ it("wraps arrow selection and links the active row to the input", () => {
199
+ const chat = composer();
200
+ expect(chat.menu.index).toBe(0);
201
+ expect(chat.input.getAttribute("aria-activedescendant")).toBe(chat.menu.itemId(commands[0]!));
202
+ expect(chat.press("ArrowUp").defaultPrevented).toBe(true);
203
+ expect(chat.menu.index).toBe(3);
204
+ chat.press("ArrowDown");
205
+ expect(chat.menu.index).toBe(0);
206
+ expect(chat.input.getAttribute("aria-controls")).toBe(chat.menu.listId);
207
+ });
208
+
209
+ it("executes a selected local action once without sending or clearing the draft", () => {
210
+ const chat = composer();
211
+ chat.press("ArrowDown");
212
+ chat.press("Enter");
213
+ expect(chat.execute).toHaveBeenCalledExactlyOnceWith(commands[1], undefined, false);
214
+ expect(chat.send).not.toHaveBeenCalled();
215
+ expect(chat.changed).not.toHaveBeenCalled();
216
+ expect(chat.text).toBe("/");
217
+ });
218
+
219
+ it("Tab completes a name without running its action", () => {
220
+ const chat = composer("/he");
221
+ expect(chat.press("Tab").defaultPrevented).toBe(true);
222
+ expect(chat.text).toBe("/help");
223
+ expect(chat.execute).not.toHaveBeenCalled();
224
+ });
225
+
226
+ it("retains arguments when keyboard selection completes a partial name", () => {
227
+ const chat = composer("/mo large model");
228
+ chat.press("Enter");
229
+ expect(chat.text).toBe("/model large model");
230
+ expect(chat.execute).not.toHaveBeenCalled();
231
+ chat.press("Enter", { altKey: true });
232
+ expect(chat.execute).toHaveBeenCalledExactlyOnceWith(commands[3], "large model", true);
233
+ });
234
+
235
+ it("completes an argument-taking alias before keyboard or button submission", () => {
236
+ const keyboard = composer("/llm");
237
+ keyboard.press("Enter");
238
+ expect(keyboard.text).toBe("/model ");
239
+ expect(keyboard.execute).not.toHaveBeenCalled();
240
+ const button = composer("/llm");
241
+ button.clickSend();
242
+ expect(button.text).toBe("/model ");
243
+ expect(button.execute).not.toHaveBeenCalled();
244
+ });
245
+
246
+ it("button submission uses an exact alias without entering the message path", () => {
247
+ const chat = composer("/cancel");
248
+ chat.clickSend();
249
+ expect(chat.execute).toHaveBeenCalledExactlyOnceWith(commands[2], undefined, false);
250
+ expect(chat.send).not.toHaveBeenCalled();
251
+ });
252
+
253
+ it("Escape stays closed until the query changes", () => {
254
+ const chat = composer("/he");
255
+ expect(chat.press("Escape").defaultPrevented).toBe(true);
256
+ expect(chat.menu.open).toBe(false);
257
+ chat.register([commands[1]!]);
258
+ expect(chat.menu.open).toBe(false);
259
+ expect(chat.menu.activeId).toBeUndefined();
260
+ chat.register(commands);
261
+ chat.change("/hel");
262
+ expect(chat.menu.open).toBe(true);
263
+ expect(chat.menu.index).toBe(0);
264
+ chat.change("/he");
265
+ expect(chat.menu.open).toBe(true);
266
+ });
267
+
268
+ it("reopens the full list even when Escape dismissed the same bare slash", () => {
269
+ const chat = composer();
270
+ chat.press("ArrowDown");
271
+ chat.press("Escape");
272
+ expect(chat.menu.open).toBe(false);
273
+ act(() => chat.menu.showAll());
274
+ expect(chat.menu.open).toBe(true);
275
+ expect(chat.menu.index).toBe(0);
276
+ expect(chat.menu.items).toEqual(commands);
277
+ expect(chat.text).toBe("/");
278
+ expect(chat.execute).not.toHaveBeenCalled();
279
+ expect(chat.send).not.toHaveBeenCalled();
280
+ });
281
+
282
+ it("returns an unknown draft to the full list for retry", () => {
283
+ const chat = composer("/missing arg");
284
+ chat.clickSend();
285
+ act(() => chat.menu.showAll());
286
+ expect(chat.text).toBe("/");
287
+ expect(chat.menu.open).toBe(true);
288
+ expect(chat.menu.items).toEqual(commands);
289
+ expect(chat.execute).not.toHaveBeenCalled();
290
+ });
291
+
292
+ it("protects IME when given a native keyboard event directly", () => {
293
+ const chat = composer("/help");
294
+ const event = new KeyboardEvent("keydown", {
295
+ key: "Enter",
296
+ isComposing: true,
297
+ cancelable: true,
298
+ });
299
+ act(() => expect(chat.menu.handleKeyDown(event)).toBe(true));
300
+ expect(event.defaultPrevented).toBe(false);
301
+ expect(chat.execute).not.toHaveBeenCalled();
302
+ });
303
+
304
+ it("leaves Shift+Enter and modified arrow/Tab keys to the text field", () => {
305
+ const chat = composer();
306
+ for (const [key, options] of [
307
+ ["Enter", { shiftKey: true }],
308
+ ["ArrowDown", { shiftKey: true }],
309
+ ["ArrowUp", { metaKey: true }],
310
+ ["Tab", { shiftKey: true }],
311
+ ] satisfies Array<[string, KeyboardEventInit]>) {
312
+ expect(chat.press(key, options).defaultPrevented).toBe(false);
313
+ }
314
+ expect(chat.execute).not.toHaveBeenCalled();
315
+ expect(chat.send).not.toHaveBeenCalled();
316
+ });
317
+
318
+ it("lets IME accept text without executing a command or sending a message", () => {
319
+ const chat = composer("/help");
320
+ expect(chat.press("Enter", { isComposing: true }).defaultPrevented).toBe(false);
321
+ expect(chat.execute).not.toHaveBeenCalled();
322
+ expect(chat.send).not.toHaveBeenCalled();
323
+ chat.change("plain text");
324
+ chat.press("Enter", { isComposing: true });
325
+ expect(chat.send).not.toHaveBeenCalled();
326
+ });
327
+
328
+ it("also protects the final IME key reported only as keyCode 229", () => {
329
+ const chat = composer("/help");
330
+ const event = new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true });
331
+ Object.defineProperty(event, "keyCode", { value: 229 });
332
+ act(() => chat.input.dispatchEvent(event));
333
+ expect(event.defaultPrevented).toBe(false);
334
+ expect(chat.execute).not.toHaveBeenCalled();
335
+ expect(chat.send).not.toHaveBeenCalled();
336
+ });
337
+
338
+ it("exposes empty results and reports unknown commands without sending them", () => {
339
+ const chat = composer("/missing");
340
+ expect(chat.menu.open).toBe(true);
341
+ expect(chat.menu.items).toEqual([]);
342
+ expect(chat.menu.activeId).toBeUndefined();
343
+ chat.press("Enter");
344
+ expect(chat.error).toHaveBeenCalledExactlyOnceWith({ kind: "unknown", name: "missing" });
345
+ expect(chat.send).not.toHaveBeenCalled();
346
+ expect(chat.text).toBe("/missing");
347
+ });
348
+
349
+ it("tags find a row but cannot execute through direct submission", () => {
350
+ const chat = composer("/conversation");
351
+ expect(chat.menu.items.map((command) => command.name)).toEqual(["new"]);
352
+ chat.clickSend();
353
+ expect(chat.error).toHaveBeenCalledWith({ kind: "unknown", name: "conversation" });
354
+ expect(chat.execute).not.toHaveBeenCalled();
355
+ chat.press("Tab");
356
+ expect(chat.text).toBe("/new");
357
+ chat.clickSend();
358
+ expect(chat.execute).toHaveBeenCalledExactlyOnceWith(commands[1], undefined, false);
359
+ });
360
+
361
+ it("does not auto-dispatch an ambiguous alias on Enter or button submission", () => {
362
+ const registry = commands.slice(0, 2).map((command) => ({ ...command, aliases: ["shared"] }));
363
+ const chat = composer("/shared", registry);
364
+ chat.press("Enter");
365
+ chat.clickSend();
366
+ expect(chat.error).toHaveBeenCalledTimes(2);
367
+ expect(chat.error).toHaveBeenLastCalledWith({
368
+ kind: "ambiguous",
369
+ name: "shared",
370
+ commands: registry,
371
+ });
372
+ expect(chat.execute).not.toHaveBeenCalled();
373
+ expect(chat.send).not.toHaveBeenCalled();
374
+ });
375
+
376
+ it("lets an explicit arrow selection choose between ambiguous aliases", () => {
377
+ const registry = commands.slice(0, 2).map((command) => ({ ...command, aliases: ["shared"] }));
378
+ const chat = composer("/shared", registry);
379
+ chat.press("ArrowDown");
380
+ chat.press("Enter");
381
+ expect(chat.execute).toHaveBeenCalledExactlyOnceWith(registry[1], undefined, false);
382
+ expect(chat.error).not.toHaveBeenCalled();
383
+ });
384
+
385
+ it("keeps focus on the text field when a row is clicked", () => {
386
+ const chat = composer();
387
+ expect(chat.clickOption("stop", { altKey: true }).defaultPrevented).toBe(true);
388
+ expect(document.activeElement).toBe(chat.input);
389
+ expect(chat.execute).toHaveBeenCalledExactlyOnceWith(commands[2], undefined, true);
390
+ expect(chat.send).not.toHaveBeenCalled();
391
+ });
392
+
393
+ it("refreshes matching and resets selection when capabilities change under a fixed draft", () => {
394
+ const chat = composer();
395
+ chat.press("ArrowDown");
396
+ expect(chat.menu.active?.name).toBe("new");
397
+ chat.register([commands[2]!]);
398
+ expect(chat.text).toBe("/");
399
+ expect(chat.menu.index).toBe(0);
400
+ expect(chat.menu.active?.name).toBe("stop");
401
+ chat.press("Enter");
402
+ expect(chat.execute).toHaveBeenCalledExactlyOnceWith(commands[2], undefined, false);
403
+ });
404
+
405
+ it("uses current translated search words even when the draft has not changed", () => {
406
+ const chat = composer("/comecar");
407
+ expect(chat.menu.items).toEqual([]);
408
+ const translated = commands.map((command) =>
409
+ command.name === "new"
410
+ ? { ...command, description: "Comece uma conversa", tags: ["começar", "conversa"] }
411
+ : command,
412
+ );
413
+ chat.register(translated);
414
+ expect(chat.menu.items.map((command) => command.name)).toEqual(["new"]);
415
+ expect(chat.text).toBe("/comecar");
416
+ });
417
+
418
+ it("re-resolves a retained row against current capabilities", () => {
419
+ const chat = composer();
420
+ const oldRow = chat.menu.items[2]!;
421
+ chat.register(commands.slice(0, 2));
422
+ act(() => chat.menu.pick(oldRow));
423
+ expect(chat.error).toHaveBeenCalledWith({ kind: "unknown", name: "stop" });
424
+ expect(chat.execute).not.toHaveBeenCalled();
425
+ });
426
+
427
+ it("direct submission does not depend on menu state and forwards Alt", () => {
428
+ const chat = composer("/cancel");
429
+ chat.press("Escape");
430
+ act(() => expect(chat.menu.submit(true)).toBe(true));
431
+ expect(chat.execute).toHaveBeenCalledExactlyOnceWith(commands[2], undefined, true);
432
+ });
433
+
434
+ it("leaves ordinary and multiline messages to the existing sender", () => {
435
+ const chat = composer("hi");
436
+ expect(chat.menu.open).toBe(false);
437
+ chat.clickSend();
438
+ expect(chat.send).toHaveBeenLastCalledWith("hi");
439
+ chat.change("/help\nquoted text");
440
+ chat.clickSend();
441
+ expect(chat.send).toHaveBeenLastCalledWith("/help\nquoted text");
442
+ expect(chat.execute).not.toHaveBeenCalled();
443
+ });
444
+ });