@corbet-labs/ccht 0.2.3 → 0.2.4

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 CHANGED
@@ -195,6 +195,76 @@ 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
+ `extraOptions` (`SessionConfigOption[]`), and `extraValues`.
253
+
254
+ Props: `steps: StepData[]`, `refreshing`, `onSelectBackend(id, value)`,
255
+ `onSelectModel(id, value)`,
256
+ `onSelectOption(id, option, value)`, `onRefresh(id)`. Optional snippets:
257
+ `accountSlot({ step })` (rendered for disconnected steps; the app wires
258
+ `AccountConnection` there), `headerCopy`, `footerSlot`.
259
+
260
+ Each step renders a `fieldset`/`legend` group with `{label} assistant`
261
+ and `{label} model` selects (stable ids `ccht-step-{id}-assistant`,
262
+ `-model`, `-option-{opt}`), model-category options filtered out of the
263
+ extras, grouped select choices flattened in order, per-step Refresh with
264
+ loading state, and configuration errors as alerts. The `bottom`/`inline`
265
+ placements have no rail chrome; like `Dock`, this component never fetches,
266
+ spawns, or stores.
267
+
198
268
  ## Styling hooks
199
269
 
200
270
  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",
3
+ "version": "0.2.4",
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
- "gitHead": "baa0b02cd88ad171d8c9a507a1d08c19d51bcc55"
54
+ "devDependencies": {
55
+ "@sveltejs/vite-plugin-svelte": "^6",
56
+ "@testing-library/svelte": "^5",
57
+ "jsdom": "^26",
58
+ "vitest": "^3"
59
+ },
60
+ "gitHead": "4ae2f99d4197758540b76cbeb8e7d7bab1641462"
50
61
  }
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.4 - 2026-09-19
4
+
5
+ - No Rust changes; the Rust API and behavior are unchanged from 0.2.3.
6
+ - ADDED: `web/src/components/ChatDock.svelte` — product-neutral bare chat
7
+ panel (messages, composer, history) over the shared `TurnState`
8
+ vocabulary, with app-owned copy, slots, and callbacks.
9
+ - ADDED: `web/src/components/StepConfig.svelte` — generic per-step
10
+ backend/model/options form (content-only; the app wraps it in `Dock`).
11
+ - ADDED: `web/src/dock.test.ts` + `web/src/auth.test.ts` (`bun test`) and
12
+ component suites (`vitest run`) covering dock state conformance, auth
13
+ contracts, and rail/chat/config behavior.
14
+ - FIXED: focus return prefers the live remounted tab over a stale invoker
15
+ node (`Dock.svelte`).
16
+
3
17
  ## 0.2.3 - 2026-09-19
4
18
 
5
19
  - No code changes from 0.2.2. Repack the npm distribution from the verified
package/source/Cargo.lock CHANGED
@@ -232,7 +232,7 @@ dependencies = [
232
232
 
233
233
  [[package]]
234
234
  name = "ccht"
235
- version = "0.2.3"
235
+ version = "0.2.4"
236
236
  dependencies = [
237
237
  "agent-client-protocol",
238
238
  "agent-client-protocol-schema",
package/source/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "ccht"
3
- version = "0.2.3"
3
+ version = "0.2.4"
4
4
  edition = "2024"
5
5
  rust-version = "1.88"
6
6
  license = "LGPL-3.0-only WITH LGPL-3.0-linking-exception"
@@ -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
+ });