@corbet-labs/ccht 0.2.3 → 0.2.5
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 +71 -0
- package/package.json +13 -2
- package/source/CHANGELOG.md +19 -0
- package/source/Cargo.lock +1 -1
- package/source/Cargo.toml +1 -1
- package/src/auth.test.ts +115 -0
- package/src/components/ChatDock.component.test.ts +213 -0
- package/src/components/ChatDock.svelte +422 -0
- package/src/components/Dock.component.test.ts +92 -0
- package/src/components/Dock.svelte +6 -3
- package/src/components/StepConfig.component.test.ts +191 -0
- package/src/components/StepConfig.svelte +248 -0
- package/src/dock.test.ts +226 -0
- package/wasm/ccht_bg.wasm +0 -0
package/README.md
CHANGED
|
@@ -195,6 +195,77 @@ Rule: the app owns dock effects. The manager never persists, the component
|
|
|
195
195
|
never fetches, spawns, or stores; visibility, persistence, and focus targets
|
|
196
196
|
beyond the panel run through app callbacks and app-held state.
|
|
197
197
|
|
|
198
|
+
## Component (`ChatDock.svelte`)
|
|
199
|
+
|
|
200
|
+
Product-neutral bare chat panel: messages, composer, and history. All copy
|
|
201
|
+
is a prop; all effects are app callbacks. Import product types from the
|
|
202
|
+
component module:
|
|
203
|
+
|
|
204
|
+
```js
|
|
205
|
+
import ChatDock, {
|
|
206
|
+
defaultChatStatusLabel,
|
|
207
|
+
} from '@corbet-labs/ccht/components/ChatDock.svelte';
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
`ChatDockMessage` is `{ id, role: 'user' | 'assistant', content,
|
|
211
|
+
modelName?, requestId?, activity?: TurnState }` (reusing the shared
|
|
212
|
+
`TurnState` vocabulary, including all six turn statuses, `stop_reason`,
|
|
213
|
+
tools, usage updates, plans, permission requests, and errors).
|
|
214
|
+
`ChatHistoryItem` is `{ id, title?, message_count? }`.
|
|
215
|
+
|
|
216
|
+
Props: `kicker`, `title`, composer `composerId/Label/Placeholder`,
|
|
217
|
+
`sendLabel`, welcome `welcomeTitle/Body/Example`, `messages`,
|
|
218
|
+
`draft` (bindable), `history` + `activeHistoryId` + `showHistoryPicker`,
|
|
219
|
+
`sending`, `canSend`, `controlsLoading`, `composerDisabled`,
|
|
220
|
+
`onSend(text)`, `onStop?`, `onSelectHistory?(id)` (`''` means new),
|
|
221
|
+
`onCopy?(id, content)`, `onShowActivity?(msg)`,
|
|
222
|
+
`statusLabel?` (defaults to `defaultChatStatusLabel`: Responding… / Needs
|
|
223
|
+
approval / Done / Stopped / Declined / Failed).
|
|
224
|
+
|
|
225
|
+
Optional snippets: `headerExtra` (for example a run badge),
|
|
226
|
+
`statusNote` (for example a readiness hint pointing at configuration),
|
|
227
|
+
`messageBody(message)` (default: plain paragraph; Markdown rendering stays
|
|
228
|
+
app-owned), `activityExtra(activity)` (default generic renderer: Reasoning
|
|
229
|
+
details, tool list with raw I/O, context usage, plan, permission
|
|
230
|
+
request ids, error, and status with `stop_reason`).
|
|
231
|
+
|
|
232
|
+
Behavior: Enter without Shift submits (with an `isComposing` guard); the
|
|
233
|
+
draft clears optimistically and is restored when `onSend` rejects; Send is
|
|
234
|
+
disabled while submitting, `!canSend`, `controlsLoading`, or a blank draft;
|
|
235
|
+
Stop renders only while submitting with a handler; Show activity renders
|
|
236
|
+
only for assistant turns with a `requestId`, no activity yet, and a
|
|
237
|
+
handler. Copy keeps its own Copied feedback and calls `onCopy` too.
|
|
238
|
+
|
|
239
|
+
Stable classes: `ccht-chat-heading/kicker/title/actions/new-chat`,
|
|
240
|
+
`ccht-chat-status-note`, `ccht-chat-picker`, `ccht-chat-messages/message/
|
|
241
|
+
message-user/message-assistant/welcome/example/copy/model/reasoning/tool/
|
|
242
|
+
usage/plan/permissions/error/status`, `ccht-chat-composer/primary/stop`,
|
|
243
|
+
`ccht-chat-sr-only`. Colors resolve through `--ccht-*` variables with
|
|
244
|
+
plain fallbacks (see the component source for the full list).
|
|
245
|
+
|
|
246
|
+
## Component (`StepConfig.svelte`)
|
|
247
|
+
|
|
248
|
+
Generic per-step backend/model/options form (content only; the app wraps
|
|
249
|
+
it in `Dock`). `StepData` carries one step's `id`, `label`,
|
|
250
|
+
`backendValue`, `backendOptions`, `backendPlaceholder`, `accountConnected`,
|
|
251
|
+
`accountBusy`, `configurationError?`, `modelValue`, `modelOptions`,
|
|
252
|
+
`refreshable` (whether the step owns a Refresh row), `extraOptions`
|
|
253
|
+
(`SessionConfigOption[]`), and `extraValues`.
|
|
254
|
+
|
|
255
|
+
Props: `steps: StepData[]`, `refreshing`, `onSelectBackend(id, value)`,
|
|
256
|
+
`onSelectModel(id, value)`,
|
|
257
|
+
`onSelectOption(id, option, value)`, `onRefresh(id)`. Optional snippets:
|
|
258
|
+
`accountSlot({ step })` (rendered for disconnected steps; the app wires
|
|
259
|
+
`AccountConnection` there), `headerCopy`, `footerSlot`.
|
|
260
|
+
|
|
261
|
+
Each step renders a `fieldset`/`legend` group with `{label} assistant`
|
|
262
|
+
and `{label} model` selects (stable ids `ccht-step-{id}-assistant`,
|
|
263
|
+
`-model`, `-option-{opt}`), model-category options filtered out of the
|
|
264
|
+
extras, grouped select choices flattened in order, per-step Refresh with
|
|
265
|
+
loading state, and configuration errors as alerts. The `bottom`/`inline`
|
|
266
|
+
placements have no rail chrome; like `Dock`, this component never fetches,
|
|
267
|
+
spawns, or stores.
|
|
268
|
+
|
|
198
269
|
## Styling hooks
|
|
199
270
|
|
|
200
271
|
Unstyled-but-hooked markup: layout comes from the app, colors resolve through
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@corbet-labs/ccht",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
4
4
|
"description": "Reusable conversations for your applications, powered by the shared Rust/Wasm model",
|
|
5
5
|
"license": "LGPL-3.0-only WITH LGPL-3.0-linking-exception",
|
|
6
6
|
"type": "module",
|
|
@@ -20,7 +20,9 @@
|
|
|
20
20
|
"import": "./src/dock.ts"
|
|
21
21
|
},
|
|
22
22
|
"./components/AccountConnection.svelte": "./src/components/AccountConnection.svelte",
|
|
23
|
+
"./components/ChatDock.svelte": "./src/components/ChatDock.svelte",
|
|
23
24
|
"./components/Dock.svelte": "./src/components/Dock.svelte",
|
|
25
|
+
"./components/StepConfig.svelte": "./src/components/StepConfig.svelte",
|
|
24
26
|
"./ccht_bg.wasm": "./wasm/ccht_bg.wasm"
|
|
25
27
|
},
|
|
26
28
|
"files": [
|
|
@@ -42,9 +44,18 @@
|
|
|
42
44
|
"publishConfig": {
|
|
43
45
|
"access": "public"
|
|
44
46
|
},
|
|
47
|
+
"scripts": {
|
|
48
|
+
"test": "bun test src/dock.test.ts src/auth.test.ts && vitest run"
|
|
49
|
+
},
|
|
45
50
|
"peerDependencies": {
|
|
46
51
|
"svelte": "^5"
|
|
47
52
|
},
|
|
48
53
|
"sideEffects": false,
|
|
49
|
-
"
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@sveltejs/vite-plugin-svelte": "^6",
|
|
56
|
+
"@testing-library/svelte": "^5",
|
|
57
|
+
"jsdom": "^26",
|
|
58
|
+
"vitest": "^3"
|
|
59
|
+
},
|
|
60
|
+
"gitHead": "3ca5914db5c7eaefa694b7e5f0da1296f8159699"
|
|
50
61
|
}
|
package/source/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.5 - 2026-09-19
|
|
4
|
+
|
|
5
|
+
- No Rust changes. `StepConfig` steps carry `refreshable`: the Refresh row
|
|
6
|
+
renders only for steps that own refreshable controls.
|
|
7
|
+
|
|
8
|
+
## 0.2.4 - 2026-09-19
|
|
9
|
+
|
|
10
|
+
- No Rust changes; the Rust API and behavior are unchanged from 0.2.3.
|
|
11
|
+
- ADDED: `web/src/components/ChatDock.svelte` — product-neutral bare chat
|
|
12
|
+
panel (messages, composer, history) over the shared `TurnState`
|
|
13
|
+
vocabulary, with app-owned copy, slots, and callbacks.
|
|
14
|
+
- ADDED: `web/src/components/StepConfig.svelte` — generic per-step
|
|
15
|
+
backend/model/options form (content-only; the app wraps it in `Dock`).
|
|
16
|
+
- ADDED: `web/src/dock.test.ts` + `web/src/auth.test.ts` (`bun test`) and
|
|
17
|
+
component suites (`vitest run`) covering dock state conformance, auth
|
|
18
|
+
contracts, and rail/chat/config behavior.
|
|
19
|
+
- FIXED: focus return prefers the live remounted tab over a stale invoker
|
|
20
|
+
node (`Dock.svelte`).
|
|
21
|
+
|
|
3
22
|
## 0.2.3 - 2026-09-19
|
|
4
23
|
|
|
5
24
|
- No code changes from 0.2.2. Repack the npm distribution from the verified
|
package/source/Cargo.lock
CHANGED
package/source/Cargo.toml
CHANGED
package/src/auth.test.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/** Contract tests for the web auth mirrors (web/src/auth.ts).
|
|
2
|
+
*
|
|
3
|
+
* Covers the Rust `AuthState` shape validation, the authenticated/account
|
|
4
|
+
* projections, and the render-gate for device-code challenges. Fixtures
|
|
5
|
+
* only; no DOM, network, or storage.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, test } from 'bun:test';
|
|
8
|
+
import { authAccount, isAuthenticated, isAuthState, parseAuthState, validateChallenge } from './auth.ts';
|
|
9
|
+
|
|
10
|
+
describe('isAuthenticated', () => {
|
|
11
|
+
test('only the authenticated object shape counts', () => {
|
|
12
|
+
expect(isAuthenticated('unknown')).toBe(false);
|
|
13
|
+
expect(isAuthenticated('unauthenticated')).toBe(false);
|
|
14
|
+
expect(isAuthenticated({ authenticated: {} })).toBe(true);
|
|
15
|
+
expect(isAuthenticated({ authenticated: { account: null } })).toBe(true);
|
|
16
|
+
expect(isAuthenticated({ authenticated: { account: 'julian' } })).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('authAccount', () => {
|
|
21
|
+
test('returns the display label or null', () => {
|
|
22
|
+
expect(authAccount('unknown')).toBeNull();
|
|
23
|
+
expect(authAccount('unauthenticated')).toBeNull();
|
|
24
|
+
expect(authAccount({ authenticated: {} })).toBeNull();
|
|
25
|
+
expect(authAccount({ authenticated: { account: null } })).toBeNull();
|
|
26
|
+
expect(authAccount({ authenticated: { account: 'julian' } })).toBe('julian');
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('parseAuthState / isAuthState', () => {
|
|
31
|
+
test('accepts the exact Rust JSON shapes', () => {
|
|
32
|
+
expect(parseAuthState('unknown')).toBe('unknown');
|
|
33
|
+
expect(parseAuthState('unauthenticated')).toBe('unauthenticated');
|
|
34
|
+
expect(parseAuthState({ authenticated: {} })).toEqual({ authenticated: {} });
|
|
35
|
+
expect(parseAuthState({ authenticated: { account: null } })).toEqual({
|
|
36
|
+
authenticated: { account: null },
|
|
37
|
+
});
|
|
38
|
+
expect(parseAuthState({ authenticated: { account: 'a' } })).toEqual({
|
|
39
|
+
authenticated: { account: 'a' },
|
|
40
|
+
});
|
|
41
|
+
expect(parseAuthState(JSON.parse(JSON.stringify({ authenticated: { account: 'a' } })))).toEqual({
|
|
42
|
+
authenticated: { account: 'a' },
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('ignores unknown fields inside the inner object like serde', () => {
|
|
47
|
+
expect(parseAuthState({ authenticated: { account: 'a', extra: 1 } })).toEqual({
|
|
48
|
+
authenticated: { account: 'a' },
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('rejects everything else with a TypeError', () => {
|
|
53
|
+
for (const value of [
|
|
54
|
+
null,
|
|
55
|
+
undefined,
|
|
56
|
+
0,
|
|
57
|
+
true,
|
|
58
|
+
[],
|
|
59
|
+
{},
|
|
60
|
+
{ authenticated: null },
|
|
61
|
+
{ authenticated: 42 },
|
|
62
|
+
{ authenticated: { account: 42 } },
|
|
63
|
+
{ authenticated: { account: {} } },
|
|
64
|
+
{ unknown: {} },
|
|
65
|
+
{ authenticated: {}, extra: 1 },
|
|
66
|
+
'UNKNOWN',
|
|
67
|
+
'authenticated',
|
|
68
|
+
]) {
|
|
69
|
+
expect(() => parseAuthState(value)).toThrow(TypeError);
|
|
70
|
+
expect(isAuthState(value)).toBe(false);
|
|
71
|
+
}
|
|
72
|
+
expect(isAuthState('unknown')).toBe(true);
|
|
73
|
+
expect(isAuthState({ authenticated: { account: 'a' } })).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('never throws on hostile input', () => {
|
|
77
|
+
expect(() => isAuthState(Object.create(null))).not.toThrow();
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('validateChallenge', () => {
|
|
82
|
+
const good = { verification_url: 'https://auth.openai.com/codex/device', user_code: 'TEST-4826' };
|
|
83
|
+
|
|
84
|
+
test('accepts a well-formed challenge', () => {
|
|
85
|
+
expect(validateChallenge(good)).toBe(true);
|
|
86
|
+
expect(validateChallenge({ ...good, user_code: 'a'.repeat(64) })).toBe(true);
|
|
87
|
+
expect(validateChallenge({ ...good, verification_url: 'https://example.com/x?y=1' })).toBe(true);
|
|
88
|
+
expect(validateChallenge({ ...good, extra: 'ignored' })).toBe(true);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('rejects malformed challenges without throwing', () => {
|
|
92
|
+
for (const value of [
|
|
93
|
+
null,
|
|
94
|
+
undefined,
|
|
95
|
+
'code',
|
|
96
|
+
[],
|
|
97
|
+
{},
|
|
98
|
+
{ verification_url: good.verification_url },
|
|
99
|
+
{ user_code: good.user_code },
|
|
100
|
+
{ verification_url: 42, user_code: good.user_code },
|
|
101
|
+
{ ...good, user_code: '' },
|
|
102
|
+
{ ...good, user_code: 'a'.repeat(65) },
|
|
103
|
+
{ ...good, user_code: 'has space' },
|
|
104
|
+
{ ...good, user_code: 'semi;colon' },
|
|
105
|
+
{ ...good, user_code: 'uniçode' },
|
|
106
|
+
{ ...good, verification_url: 'http://auth.openai.com/codex/device' },
|
|
107
|
+
{ ...good, verification_url: 'not a url' },
|
|
108
|
+
{ ...good, verification_url: 'https://user:pass@auth.openai.com/' },
|
|
109
|
+
{ ...good, verification_url: 'https://' },
|
|
110
|
+
{ ...good, verification_url: 'javascript:alert(1)' },
|
|
111
|
+
]) {
|
|
112
|
+
expect(validateChallenge(value)).toBe(false);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/** Browser behavior of the generic bare chat panel (ChatDock.svelte).
|
|
2
|
+
*
|
|
3
|
+
* Covers welcome/message rendering, copy feedback, the send lifecycle
|
|
4
|
+
* (draft clear and restore, disabled gates, Enter/isComposing handling),
|
|
5
|
+
* Stop, history selection, activity gating, and status labels. The default
|
|
6
|
+
* activity renderer is exercised through fixture TurnState values.
|
|
7
|
+
*/
|
|
8
|
+
import { render, fireEvent, cleanup } from '@testing-library/svelte';
|
|
9
|
+
import { afterEach, describe, expect, test, vi } from 'vitest';
|
|
10
|
+
import ChatDock from './ChatDock.svelte';
|
|
11
|
+
|
|
12
|
+
afterEach(() => cleanup());
|
|
13
|
+
|
|
14
|
+
const copy = {
|
|
15
|
+
kicker: 'Creator · diverges',
|
|
16
|
+
title: 'Brief the generator',
|
|
17
|
+
composerId: 'creator-prompt',
|
|
18
|
+
composerLabel: 'Message the creator',
|
|
19
|
+
composerPlaceholder: 'Describe the name you need…',
|
|
20
|
+
sendLabel: 'Send to creator',
|
|
21
|
+
welcomeTitle: 'creator',
|
|
22
|
+
welcomeBody: 'Talk through the problem first.',
|
|
23
|
+
welcomeExample: 'Try: short .ch names.',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function openChat(props: Record<string, unknown> = {}) {
|
|
27
|
+
return render(ChatDock, {
|
|
28
|
+
...copy,
|
|
29
|
+
messages: [],
|
|
30
|
+
draft: '',
|
|
31
|
+
onSend: () => {},
|
|
32
|
+
...props,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function turn(overrides: Record<string, unknown> = {}) {
|
|
37
|
+
return {
|
|
38
|
+
request_id: 'req-1',
|
|
39
|
+
last_sequence: 2,
|
|
40
|
+
text: 'hello',
|
|
41
|
+
user_text: '',
|
|
42
|
+
user_content: [],
|
|
43
|
+
thought_text: '',
|
|
44
|
+
thought_content: [],
|
|
45
|
+
content: [],
|
|
46
|
+
tools: {},
|
|
47
|
+
permissions: [],
|
|
48
|
+
updates: {},
|
|
49
|
+
status: 'completed',
|
|
50
|
+
stop_reason: 'end_turn',
|
|
51
|
+
error: null,
|
|
52
|
+
...overrides,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe('welcome and messages', () => {
|
|
57
|
+
test('empty thread shows the welcome article', () => {
|
|
58
|
+
const { getByText } = openChat();
|
|
59
|
+
expect(getByText('Talk through the problem first.')).toBeTruthy();
|
|
60
|
+
expect(getByText('Try: short .ch names.')).toBeTruthy();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('user and assistant messages render with copy controls', async () => {
|
|
64
|
+
const onCopy = vi.fn();
|
|
65
|
+
const { getByText, getAllByRole } = openChat({
|
|
66
|
+
onCopy,
|
|
67
|
+
messages: [
|
|
68
|
+
{ id: 'u1', role: 'user', content: 'a name' },
|
|
69
|
+
{ id: 'a1', role: 'assistant', content: 'how about x', modelName: 'model-x' },
|
|
70
|
+
],
|
|
71
|
+
});
|
|
72
|
+
expect(getByText('a name')).toBeTruthy();
|
|
73
|
+
expect(getByText('model-x')).toBeTruthy();
|
|
74
|
+
Object.defineProperty(navigator, 'clipboard', {
|
|
75
|
+
value: { writeText: vi.fn().mockResolvedValue(undefined) },
|
|
76
|
+
configurable: true,
|
|
77
|
+
});
|
|
78
|
+
await fireEvent.click(getAllByRole('button', { name: 'Copy' })[0]);
|
|
79
|
+
expect(onCopy).toHaveBeenCalledWith('u1', 'a name');
|
|
80
|
+
expect(getByText('Copied')).toBeTruthy();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('assistant without activity offers Show activity only with a handler', () => {
|
|
84
|
+
const bare = openChat({ messages: [{ id: 'a1', role: 'assistant', content: 'hi', requestId: 'r1' }] });
|
|
85
|
+
expect(bare.queryByRole('button', { name: 'Show activity' })).toBeNull();
|
|
86
|
+
const wired = openChat({
|
|
87
|
+
onShowActivity: () => {},
|
|
88
|
+
messages: [{ id: 'a1', role: 'assistant', content: 'hi', requestId: 'r1' }],
|
|
89
|
+
});
|
|
90
|
+
expect(wired.getByRole('button', { name: 'Show activity' })).toBeTruthy();
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('default activity renderer', () => {
|
|
95
|
+
test('covers reasoning, tools, usage, plan, permissions, error, and status', () => {
|
|
96
|
+
const { getByText } = openChat({
|
|
97
|
+
messages: [
|
|
98
|
+
{
|
|
99
|
+
id: 'a1',
|
|
100
|
+
role: 'assistant',
|
|
101
|
+
content: 'results',
|
|
102
|
+
activity: turn({
|
|
103
|
+
thought_text: 'thinking out loud',
|
|
104
|
+
tools: { t1: { toolCallId: 't1', title: 'Lookup', status: 'done', rawInput: { q: 1 } } },
|
|
105
|
+
updates: { usage_update: { used: 1200, size: 8000 }, plan: { entries: [{ content: 'step', status: 'done' }] } },
|
|
106
|
+
permissions: [{ request_id: 'p1', request: {} }],
|
|
107
|
+
error: { code: 'boom', message: 'went wrong' },
|
|
108
|
+
status: 'failed',
|
|
109
|
+
stop_reason: 'error',
|
|
110
|
+
}),
|
|
111
|
+
},
|
|
112
|
+
],
|
|
113
|
+
});
|
|
114
|
+
expect(getByText('Reasoning')).toBeTruthy();
|
|
115
|
+
expect(getByText('thinking out loud')).toBeTruthy();
|
|
116
|
+
expect(getByText('Lookup · done')).toBeTruthy();
|
|
117
|
+
expect(getByText('Context: 1,200 / 8,000 tokens')).toBeTruthy();
|
|
118
|
+
expect(getByText('Plan')).toBeTruthy();
|
|
119
|
+
expect(getByText(/1 permission request pending: p1/)).toBeTruthy();
|
|
120
|
+
expect(getByText('boom: went wrong')).toBeTruthy();
|
|
121
|
+
expect(getByText('Failed')).toBeTruthy();
|
|
122
|
+
expect(getByText(/· error/)).toBeTruthy();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('status labels cover every turn state', () => {
|
|
126
|
+
const cases: Array<[string, string]> = [
|
|
127
|
+
['streaming', 'Responding…'],
|
|
128
|
+
['awaiting_permission', 'Needs approval'],
|
|
129
|
+
['completed', 'Done'],
|
|
130
|
+
['cancelled', 'Stopped'],
|
|
131
|
+
['refused', 'Declined'],
|
|
132
|
+
['failed', 'Failed'],
|
|
133
|
+
];
|
|
134
|
+
for (const [status, label] of cases) {
|
|
135
|
+
const view = openChat({ messages: [{ id: 'a1', role: 'assistant', content: 'x', activity: turn({ status }) }] });
|
|
136
|
+
expect(view.getByText(label)).toBeTruthy();
|
|
137
|
+
view.unmount();
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe('composer', () => {
|
|
143
|
+
test('send clears the draft and forwards trimmed text', async () => {
|
|
144
|
+
const onSend = vi.fn();
|
|
145
|
+
const { getByLabelText, getByRole } = openChat({ draft: ' hello ', onSend });
|
|
146
|
+
await fireEvent.click(getByRole('button', { name: 'Send to creator' }));
|
|
147
|
+
expect(onSend).toHaveBeenCalledWith('hello');
|
|
148
|
+
expect((getByLabelText('Message the creator') as HTMLTextAreaElement).value).toBe('');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('rejected send restores the draft', async () => {
|
|
152
|
+
const { getByLabelText, getByRole } = openChat({ draft: 'keep me', onSend: () => Promise.reject(new Error('down')) });
|
|
153
|
+
await fireEvent.click(getByRole('button', { name: 'Send to creator' }));
|
|
154
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
155
|
+
expect((getByLabelText('Message the creator') as HTMLTextAreaElement).value).toBe('keep me');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('send is gated on canSend, loading, and blank drafts', () => {
|
|
159
|
+
for (const props of [{ draft: 'x', canSend: false }, { draft: 'x', controlsLoading: true }, { draft: ' ' }, { draft: 'x', submitting: true }]) {
|
|
160
|
+
const view = openChat(props);
|
|
161
|
+
expect(view.getByRole('button', { name: 'Send to creator' }).hasAttribute('disabled')).toBe(true);
|
|
162
|
+
view.unmount();
|
|
163
|
+
}
|
|
164
|
+
const ready = openChat({ draft: 'x' });
|
|
165
|
+
expect(ready.getByRole('button', { name: 'Send to creator' }).hasAttribute('disabled')).toBe(false);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('Enter submits, shift+Enter and composing do not', async () => {
|
|
169
|
+
const onSend = vi.fn();
|
|
170
|
+
const { getByLabelText } = openChat({ draft: 'hi', onSend });
|
|
171
|
+
const box = getByLabelText('Message the creator');
|
|
172
|
+
await fireEvent.keyDown(box, { key: 'Enter', shiftKey: true });
|
|
173
|
+
await fireEvent.keyDown(box, { key: 'Enter', isComposing: true });
|
|
174
|
+
expect(onSend).not.toHaveBeenCalled();
|
|
175
|
+
await fireEvent.keyDown(box, { key: 'Enter' });
|
|
176
|
+
expect(onSend).toHaveBeenCalledWith('hi');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('Stop appears only while submitting with a handler', () => {
|
|
180
|
+
expect(openChat({ submitting: true }).queryByRole('button', { name: 'Stop' })).toBeNull();
|
|
181
|
+
const onStop = vi.fn();
|
|
182
|
+
const view = openChat({ submitting: true, onStop });
|
|
183
|
+
expect(view.getByRole('button', { name: 'Stop' })).toBeTruthy();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('composer honours the disabled flag', () => {
|
|
187
|
+
const { getByLabelText } = openChat({ composerDisabled: true });
|
|
188
|
+
expect((getByLabelText('Message the creator') as HTMLTextAreaElement).disabled).toBe(true);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe('history', () => {
|
|
193
|
+
test('picker lists conversations and selects through the callback', async () => {
|
|
194
|
+
const onSelectHistory = vi.fn();
|
|
195
|
+
const { getByLabelText } = openChat({
|
|
196
|
+
showHistoryPicker: true,
|
|
197
|
+
onSelectHistory,
|
|
198
|
+
activeHistoryId: 'c1',
|
|
199
|
+
history: [
|
|
200
|
+
{ id: 'c1', title: 'First', message_count: 4 },
|
|
201
|
+
{ id: 'c2', title: null, message_count: 1 },
|
|
202
|
+
],
|
|
203
|
+
});
|
|
204
|
+
const select = getByLabelText('Conversation') as HTMLSelectElement;
|
|
205
|
+
expect(select.value).toBe('c1');
|
|
206
|
+
await fireEvent.change(select, { target: { value: 'c2' } });
|
|
207
|
+
expect(onSelectHistory).toHaveBeenCalledWith('c2');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('no picker without the flag', () => {
|
|
211
|
+
expect(openChat().queryByLabelText('Conversation')).toBeNull();
|
|
212
|
+
});
|
|
213
|
+
});
|