@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.
- package/README.md +173 -36
- package/dist/api-client.d.ts +51 -51
- package/dist/api-client.d.ts.map +1 -1
- package/dist/api-client.js +3 -3
- package/dist/api-error.d.ts +11 -11
- package/dist/api-error.js +11 -11
- package/dist/auth-email.d.ts +40 -0
- package/dist/auth-email.d.ts.map +1 -0
- package/dist/auth-email.js +106 -0
- package/dist/auth-email.js.map +1 -0
- package/dist/auth-store.d.ts +23 -23
- package/dist/auth-store.js +13 -13
- package/dist/commands.d.ts +80 -0
- package/dist/commands.d.ts.map +1 -0
- package/dist/commands.js +192 -0
- package/dist/commands.js.map +1 -0
- package/dist/copy.d.ts +3 -3
- package/dist/copy.js +3 -3
- package/dist/deploy-recovery.d.ts +40 -40
- package/dist/deploy-recovery.js +40 -40
- package/dist/describe-error.d.ts +68 -68
- package/dist/describe-error.d.ts.map +1 -1
- package/dist/describe-error.js +5 -5
- package/dist/error-boundary.d.ts +12 -12
- package/dist/guards.d.ts +15 -15
- package/dist/guards.js +3 -3
- package/dist/hydrate.d.ts +15 -16
- package/dist/hydrate.d.ts.map +1 -1
- package/dist/hydrate.js +9 -9
- package/dist/hydrate.js.map +1 -1
- package/dist/i18n.d.ts +15 -16
- package/dist/i18n.d.ts.map +1 -1
- package/dist/i18n.js +5 -6
- package/dist/i18n.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/internal-path.d.ts +8 -7
- package/dist/internal-path.d.ts.map +1 -1
- package/dist/internal-path.js +8 -7
- package/dist/internal-path.js.map +1 -1
- package/dist/prerender-contract.d.ts +7 -7
- package/dist/prerender-contract.js +7 -7
- package/dist/query.d.ts +16 -17
- package/dist/query.d.ts.map +1 -1
- package/dist/query.js +2 -2
- package/dist/query.js.map +1 -1
- package/dist/sse.d.ts +19 -20
- package/dist/sse.d.ts.map +1 -1
- package/dist/sse.js +18 -19
- package/dist/sse.js.map +1 -1
- package/dist/states.d.ts +12 -12
- package/dist/states.d.ts.map +1 -1
- package/dist/supabase-session.d.ts +47 -48
- package/dist/supabase-session.d.ts.map +1 -1
- package/dist/supabase-session.js +35 -35
- package/dist/supabase-session.js.map +1 -1
- package/dist/theme.d.ts +4 -4
- package/dist/theme.js +4 -4
- package/dist/ui/class-name.d.ts +3 -4
- package/dist/ui/class-name.d.ts.map +1 -1
- package/dist/ui/class-name.js +3 -4
- package/dist/ui/class-name.js.map +1 -1
- package/dist/ui/combobox.d.ts +11 -11
- package/dist/ui/combobox.d.ts.map +1 -1
- package/dist/ui/dialog.d.ts +5 -5
- package/dist/ui/dialog.js +2 -2
- package/dist/ui/drawer.d.ts +10 -10
- package/dist/ui/drawer.d.ts.map +1 -1
- package/dist/ui/drawer.js +2 -2
- package/dist/ui/input.d.ts +2 -2
- package/dist/ui/input.js +2 -2
- package/dist/ui/menu.d.ts +9 -9
- package/dist/ui/menu.d.ts.map +1 -1
- package/dist/ui/menu.js +4 -4
- package/dist/ui/menu.js.map +1 -1
- package/dist/ui/select.d.ts +12 -12
- package/dist/ui/select.d.ts.map +1 -1
- package/dist/ui/select.js +3 -3
- package/dist/ui/tabs.d.ts +14 -15
- package/dist/ui/tabs.d.ts.map +1 -1
- package/dist/ui/tabs.js +13 -14
- package/dist/ui/tabs.js.map +1 -1
- package/package.json +5 -1
- package/src/api-client.ts +51 -51
- package/src/api-error.ts +11 -11
- package/src/auth-email.test.ts +145 -0
- package/src/auth-email.ts +112 -0
- package/src/auth-store.ts +23 -23
- package/src/commands-hook.test.ts +444 -0
- package/src/commands.test.ts +245 -0
- package/src/commands.ts +268 -0
- package/src/copy.ts +3 -3
- package/src/deploy-recovery.ts +40 -40
- package/src/describe-error.ts +68 -68
- package/src/error-boundary.tsx +12 -12
- package/src/guards.tsx +15 -15
- package/src/hydrate.ts +15 -16
- package/src/i18n.ts +15 -16
- package/src/index.ts +2 -0
- package/src/internal-path.ts +8 -7
- package/src/prerender-contract.ts +7 -7
- package/src/query.ts +16 -17
- package/src/sse.ts +19 -20
- package/src/states.ts +12 -12
- package/src/supabase-session.ts +46 -47
- package/src/theme.ts +4 -4
- package/src/ui/class-name.ts +3 -4
- package/src/ui/combobox.tsx +11 -11
- package/src/ui/dialog.tsx +5 -5
- package/src/ui/drawer.tsx +10 -10
- package/src/ui/input.tsx +2 -2
- package/src/ui/menu.tsx +8 -8
- package/src/ui/select.tsx +12 -12
- 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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
|
13
|
-
*
|
|
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
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* `role="status"`: a spinner as the indexable body of a page whose entire purpose was to be
|
|
45
|
-
*
|
|
46
|
-
*
|
|
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
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
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
|
+
});
|