@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 +70 -0
- package/package.json +13 -2
- package/source/CHANGELOG.md +14 -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 +185 -0
- package/src/components/StepConfig.svelte +247 -0
- package/src/dock.test.ts +226 -0
- package/wasm/ccht_bg.wasm +0 -0
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
<!-- Product-neutral bare chat panel (messages + composer + history). The
|
|
2
|
+
application owns all effects (sending, stopping, history switching,
|
|
3
|
+
clipboard feedback, error surfacing, configuration); this component only
|
|
4
|
+
renders state and forwards user intent through app-supplied callbacks. It
|
|
5
|
+
never fetches, spawns, or stores anything. -->
|
|
6
|
+
<script module lang="ts">
|
|
7
|
+
import type { TurnState } from '../../index.js';
|
|
8
|
+
|
|
9
|
+
/** One renderable chat message. `activity` carries the live turn detail. */
|
|
10
|
+
export interface ChatDockMessage {
|
|
11
|
+
id: string;
|
|
12
|
+
role: 'user' | 'assistant';
|
|
13
|
+
content: string;
|
|
14
|
+
modelName?: string;
|
|
15
|
+
requestId?: string;
|
|
16
|
+
activity?: TurnState;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** One entry in the conversation history picker. */
|
|
20
|
+
export interface ChatHistoryItem {
|
|
21
|
+
id: string;
|
|
22
|
+
title?: string | null;
|
|
23
|
+
message_count?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Default status text for a turn. The application may override it. */
|
|
27
|
+
export function defaultChatStatusLabel(activity: TurnState): string {
|
|
28
|
+
switch (activity.status) {
|
|
29
|
+
case 'streaming':
|
|
30
|
+
return 'Responding…';
|
|
31
|
+
case 'awaiting_permission':
|
|
32
|
+
return 'Needs approval';
|
|
33
|
+
case 'completed':
|
|
34
|
+
return 'Done';
|
|
35
|
+
case 'cancelled':
|
|
36
|
+
return 'Stopped';
|
|
37
|
+
case 'refused':
|
|
38
|
+
return 'Declined';
|
|
39
|
+
case 'failed':
|
|
40
|
+
return 'Failed';
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
</script>
|
|
44
|
+
|
|
45
|
+
<script lang="ts">
|
|
46
|
+
import type { Snippet } from 'svelte';
|
|
47
|
+
import type { TurnState } from '../../index.js';
|
|
48
|
+
|
|
49
|
+
let {
|
|
50
|
+
kicker,
|
|
51
|
+
title,
|
|
52
|
+
composerId,
|
|
53
|
+
composerLabel,
|
|
54
|
+
composerPlaceholder,
|
|
55
|
+
sendLabel,
|
|
56
|
+
welcomeTitle,
|
|
57
|
+
welcomeBody,
|
|
58
|
+
welcomeExample,
|
|
59
|
+
messages,
|
|
60
|
+
draft = $bindable(''),
|
|
61
|
+
history = [],
|
|
62
|
+
activeHistoryId = null,
|
|
63
|
+
showHistoryPicker = false,
|
|
64
|
+
submitting = false,
|
|
65
|
+
canSend = true,
|
|
66
|
+
controlsLoading = false,
|
|
67
|
+
composerDisabled = false,
|
|
68
|
+
onSend,
|
|
69
|
+
onStop,
|
|
70
|
+
onSelectHistory,
|
|
71
|
+
onCopy,
|
|
72
|
+
onShowActivity,
|
|
73
|
+
statusLabel = defaultChatStatusLabel,
|
|
74
|
+
headerExtra,
|
|
75
|
+
statusNote,
|
|
76
|
+
messageBody,
|
|
77
|
+
activityExtra
|
|
78
|
+
}: {
|
|
79
|
+
kicker: string;
|
|
80
|
+
title: string;
|
|
81
|
+
composerId: string;
|
|
82
|
+
composerLabel: string;
|
|
83
|
+
composerPlaceholder: string;
|
|
84
|
+
sendLabel: string;
|
|
85
|
+
welcomeTitle: string;
|
|
86
|
+
welcomeBody: string;
|
|
87
|
+
welcomeExample: string;
|
|
88
|
+
messages: ChatDockMessage[];
|
|
89
|
+
draft: string;
|
|
90
|
+
history?: ChatHistoryItem[];
|
|
91
|
+
activeHistoryId?: string | null;
|
|
92
|
+
showHistoryPicker?: boolean;
|
|
93
|
+
submitting?: boolean;
|
|
94
|
+
canSend?: boolean;
|
|
95
|
+
controlsLoading?: boolean;
|
|
96
|
+
composerDisabled?: boolean;
|
|
97
|
+
onSend: (text: string) => void | Promise<void>;
|
|
98
|
+
onStop?: () => void;
|
|
99
|
+
onSelectHistory?: (id: string) => void;
|
|
100
|
+
onCopy?: (id: string, content: string) => void;
|
|
101
|
+
onShowActivity?: (msg: ChatDockMessage) => void;
|
|
102
|
+
statusLabel?: (activity: TurnState) => string;
|
|
103
|
+
headerExtra?: Snippet;
|
|
104
|
+
statusNote?: Snippet;
|
|
105
|
+
messageBody?: Snippet<[ChatDockMessage]>;
|
|
106
|
+
activityExtra?: Snippet<[TurnState]>;
|
|
107
|
+
} = $props();
|
|
108
|
+
|
|
109
|
+
const sendDisabled: boolean = $derived(
|
|
110
|
+
submitting || !canSend || controlsLoading || draft.trim().length === 0
|
|
111
|
+
);
|
|
112
|
+
let copiedId: string | null = $state(null);
|
|
113
|
+
|
|
114
|
+
function invoke(action: () => void | Promise<void>): void {
|
|
115
|
+
try {
|
|
116
|
+
const result = action();
|
|
117
|
+
if (result instanceof Promise) {
|
|
118
|
+
result.catch(() => {});
|
|
119
|
+
}
|
|
120
|
+
} catch {
|
|
121
|
+
// Handled by the application through its own operation state.
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// The draft is cleared optimistically and restored when the application
|
|
126
|
+
// rejects, so no typed text is lost. The application surfaces the failure
|
|
127
|
+
// through its own operation state.
|
|
128
|
+
async function submit(event: SubmitEvent): Promise<void> {
|
|
129
|
+
event.preventDefault();
|
|
130
|
+
const prompt = draft.trim();
|
|
131
|
+
if (!prompt || submitting || !canSend || controlsLoading) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
draft = '';
|
|
135
|
+
try {
|
|
136
|
+
await onSend(prompt);
|
|
137
|
+
} catch {
|
|
138
|
+
if (!draft) {
|
|
139
|
+
draft = prompt;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function handleComposerKeydown(event: KeyboardEvent): void {
|
|
145
|
+
if (event.key !== 'Enter' || event.shiftKey || event.isComposing) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
event.preventDefault();
|
|
149
|
+
if (event.currentTarget instanceof HTMLTextAreaElement) {
|
|
150
|
+
event.currentTarget.form?.requestSubmit();
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// The panel keeps its own Copied feedback; the application may add its own
|
|
155
|
+
// handling through onCopy.
|
|
156
|
+
async function copyMessage(id: string, content: string): Promise<void> {
|
|
157
|
+
try {
|
|
158
|
+
await navigator.clipboard.writeText(content);
|
|
159
|
+
copiedId = id;
|
|
160
|
+
} catch {
|
|
161
|
+
// Clipboard unavailable; the application decides how to report it.
|
|
162
|
+
}
|
|
163
|
+
invoke(() => onCopy?.(id, content));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function historyTitle(item: ChatHistoryItem): string {
|
|
167
|
+
const label = item.title ?? 'Untitled conversation';
|
|
168
|
+
return typeof item.message_count === 'number'
|
|
169
|
+
? `${label} · ${item.message_count}`
|
|
170
|
+
: label;
|
|
171
|
+
}
|
|
172
|
+
</script>
|
|
173
|
+
|
|
174
|
+
<div class="ccht-chat-heading">
|
|
175
|
+
<div>
|
|
176
|
+
<p class="ccht-chat-kicker">{kicker}</p>
|
|
177
|
+
<h2 class="ccht-chat-title">{title}</h2>
|
|
178
|
+
</div>
|
|
179
|
+
<div class="ccht-chat-actions">
|
|
180
|
+
{#if messages.length > 0 && onSelectHistory}
|
|
181
|
+
<button type="button" class="ccht-chat-new-chat" onclick={() => invoke(() => onSelectHistory(''))}>
|
|
182
|
+
New conversation
|
|
183
|
+
</button>
|
|
184
|
+
{/if}
|
|
185
|
+
{#if headerExtra}{@render headerExtra()}{/if}
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
|
|
189
|
+
{#if statusNote}
|
|
190
|
+
<div class="ccht-chat-status-note">{@render statusNote()}</div>
|
|
191
|
+
{/if}
|
|
192
|
+
|
|
193
|
+
{#if showHistoryPicker}
|
|
194
|
+
<div class="ccht-chat-picker">
|
|
195
|
+
<label for="{composerId}-history">Conversation</label>
|
|
196
|
+
<select
|
|
197
|
+
id="{composerId}-history"
|
|
198
|
+
value={activeHistoryId ?? ''}
|
|
199
|
+
onchange={(event) => invoke(() => onSelectHistory?.(event.currentTarget.value))}
|
|
200
|
+
>
|
|
201
|
+
<option value="">New conversation</option>
|
|
202
|
+
{#each history as item (item.id)}
|
|
203
|
+
<option value={item.id}>{historyTitle(item)}</option>
|
|
204
|
+
{/each}
|
|
205
|
+
</select>
|
|
206
|
+
</div>
|
|
207
|
+
{/if}
|
|
208
|
+
|
|
209
|
+
<div class="ccht-chat-messages" aria-live="polite" aria-label="{title} messages">
|
|
210
|
+
{#if messages.length === 0}
|
|
211
|
+
<article class="ccht-chat-message ccht-chat-message-assistant ccht-chat-welcome">
|
|
212
|
+
<strong>{welcomeTitle}</strong>
|
|
213
|
+
<p>{welcomeBody}</p>
|
|
214
|
+
<p class="ccht-chat-example">{welcomeExample}</p>
|
|
215
|
+
</article>
|
|
216
|
+
{/if}
|
|
217
|
+
{#each messages as message (message.id)}
|
|
218
|
+
<article
|
|
219
|
+
class="ccht-chat-message"
|
|
220
|
+
class:ccht-chat-message-user={message.role === 'user'}
|
|
221
|
+
class:ccht-chat-message-assistant={message.role === 'assistant'}
|
|
222
|
+
>
|
|
223
|
+
<strong>{message.role === 'user' ? 'You' : welcomeTitle}</strong>
|
|
224
|
+
{#if messageBody}
|
|
225
|
+
{@render messageBody(message)}
|
|
226
|
+
{:else}
|
|
227
|
+
<p>{message.content}</p>
|
|
228
|
+
{/if}
|
|
229
|
+
{#if message.content}
|
|
230
|
+
<button type="button" class="ccht-chat-copy" onclick={() => void copyMessage(message.id, message.content)}>
|
|
231
|
+
{copiedId === message.id ? 'Copied' : 'Copy'}
|
|
232
|
+
</button>
|
|
233
|
+
{/if}
|
|
234
|
+
{#if message.modelName}<small class="ccht-chat-model">{message.modelName}</small>{/if}
|
|
235
|
+
{#if message.role === 'assistant' && message.requestId && !message.activity && onShowActivity}
|
|
236
|
+
<button type="button" class="ccht-chat-copy" onclick={() => invoke(() => onShowActivity(message))}>
|
|
237
|
+
Show activity
|
|
238
|
+
</button>
|
|
239
|
+
{/if}
|
|
240
|
+
{#if message.activity}
|
|
241
|
+
{@const activity = message.activity}
|
|
242
|
+
{#if activityExtra}
|
|
243
|
+
{@render activityExtra(activity)}
|
|
244
|
+
{:else}
|
|
245
|
+
{#if activity.thought_text}
|
|
246
|
+
<details class="ccht-chat-reasoning">
|
|
247
|
+
<summary>Reasoning</summary>
|
|
248
|
+
<p>{activity.thought_text}</p>
|
|
249
|
+
</details>
|
|
250
|
+
{/if}
|
|
251
|
+
{#each Object.values(activity.tools) as tool (tool.toolCallId)}
|
|
252
|
+
<details class="ccht-chat-tool">
|
|
253
|
+
<summary>{tool.title} · {tool.status ?? 'pending'}</summary>
|
|
254
|
+
<pre>{JSON.stringify(
|
|
255
|
+
{ input: tool.rawInput, output: tool.rawOutput, content: tool.content },
|
|
256
|
+
null,
|
|
257
|
+
2
|
|
258
|
+
)}</pre>
|
|
259
|
+
</details>
|
|
260
|
+
{/each}
|
|
261
|
+
{@const usage = activity.updates.usage_update as { used?: unknown; size?: unknown } | undefined}
|
|
262
|
+
{#if typeof usage?.used === 'number'}
|
|
263
|
+
<p class="ccht-chat-usage">
|
|
264
|
+
Context: {usage.used.toLocaleString()} / {typeof usage.size === 'number'
|
|
265
|
+
? usage.size.toLocaleString()
|
|
266
|
+
: '?'} tokens
|
|
267
|
+
</p>
|
|
268
|
+
{/if}
|
|
269
|
+
{@const plan = activity.updates.plan as
|
|
270
|
+
| { entries?: Array<{ content?: unknown; status?: unknown }> }
|
|
271
|
+
| undefined}
|
|
272
|
+
{#if Array.isArray(plan?.entries) && plan.entries.length > 0}
|
|
273
|
+
<details class="ccht-chat-plan">
|
|
274
|
+
<summary>Plan</summary>
|
|
275
|
+
<ol>
|
|
276
|
+
{#each plan.entries as entry}
|
|
277
|
+
<li>{String(entry.content ?? '')} · {String(entry.status ?? '')}</li>
|
|
278
|
+
{/each}
|
|
279
|
+
</ol>
|
|
280
|
+
</details>
|
|
281
|
+
{/if}
|
|
282
|
+
{#if activity.permissions.length > 0}
|
|
283
|
+
<p class="ccht-chat-permissions">
|
|
284
|
+
{activity.permissions.length}
|
|
285
|
+
{activity.permissions.length === 1 ? 'permission request' : 'permission requests'}
|
|
286
|
+
pending: {activity.permissions.map((permission) => permission.request_id).join(', ')}
|
|
287
|
+
</p>
|
|
288
|
+
{/if}
|
|
289
|
+
{#if activity.error}
|
|
290
|
+
<p class="ccht-chat-error" role="alert">{activity.error.code}: {activity.error.message}</p>
|
|
291
|
+
{/if}
|
|
292
|
+
<p class="ccht-chat-status">
|
|
293
|
+
<span role="status">{statusLabel(activity)}</span>{#if activity.stop_reason}<span>
|
|
294
|
+
· {activity.stop_reason}</span>{/if}
|
|
295
|
+
</p>
|
|
296
|
+
{/if}
|
|
297
|
+
{/if}
|
|
298
|
+
</article>
|
|
299
|
+
{/each}
|
|
300
|
+
</div>
|
|
301
|
+
|
|
302
|
+
<form class="ccht-chat-composer" onsubmit={submit} aria-busy={submitting}>
|
|
303
|
+
<label class="ccht-chat-sr-only" for={composerId}>{composerLabel}</label>
|
|
304
|
+
<textarea
|
|
305
|
+
id={composerId}
|
|
306
|
+
bind:value={draft}
|
|
307
|
+
onkeydown={handleComposerKeydown}
|
|
308
|
+
rows="3"
|
|
309
|
+
placeholder={composerPlaceholder}
|
|
310
|
+
disabled={composerDisabled}
|
|
311
|
+
required
|
|
312
|
+
></textarea>
|
|
313
|
+
<div class="ccht-chat-actions">
|
|
314
|
+
{#if submitting && onStop}
|
|
315
|
+
<button type="button" class="ccht-chat-stop" onclick={() => invoke(onStop)}>Stop</button>
|
|
316
|
+
{/if}
|
|
317
|
+
<button type="submit" class="ccht-chat-primary" aria-label={sendLabel} disabled={sendDisabled}>
|
|
318
|
+
{sendLabel}
|
|
319
|
+
</button>
|
|
320
|
+
</div>
|
|
321
|
+
</form>
|
|
322
|
+
|
|
323
|
+
<style>
|
|
324
|
+
.ccht-chat-heading {
|
|
325
|
+
flex: 0 0 auto; display: flex; justify-content: space-between; align-items: start; gap: 0.8rem;
|
|
326
|
+
padding: 0.1rem 0.1rem 0.9rem; border-bottom: 1px solid var(--ccht-border, #223049);
|
|
327
|
+
}
|
|
328
|
+
.ccht-chat-kicker {
|
|
329
|
+
margin: 0; font-size: 0.7rem; font-weight: 850; letter-spacing: 0.12em; text-transform: uppercase;
|
|
330
|
+
color: var(--ccht-kicker, var(--ccht-muted, #8fa0b7));
|
|
331
|
+
}
|
|
332
|
+
.ccht-chat-title { margin: 0.2rem 0 0; font-size: 1.02rem; overflow-wrap: anywhere; }
|
|
333
|
+
.ccht-chat-heading .ccht-chat-actions { display: flex; align-items: center; gap: 0.45rem; }
|
|
334
|
+
.ccht-chat-new-chat {
|
|
335
|
+
min-height: 2.4rem; padding: 0 0.55rem; border: 1px solid var(--ccht-border, #30405c);
|
|
336
|
+
border-radius: 0.5rem; color: var(--ccht-fg, #c2cede); background: var(--ccht-tab-bg, #111c2d);
|
|
337
|
+
font-size: 0.62rem; font-weight: 850; cursor: pointer;
|
|
338
|
+
}
|
|
339
|
+
.ccht-chat-new-chat:hover {
|
|
340
|
+
border-color: var(--ccht-accent, #53708f);
|
|
341
|
+
background: var(--ccht-tab-bg-hover, #17243a);
|
|
342
|
+
}
|
|
343
|
+
.ccht-chat-status-note { flex: 0 0 auto; padding-top: 0.7rem; color: var(--ccht-muted, #8fa0b7); }
|
|
344
|
+
.ccht-chat-picker {
|
|
345
|
+
flex: 0 0 auto; display: grid; grid-template-columns: auto minmax(0, 1fr);
|
|
346
|
+
align-items: center; gap: 0.55rem; padding: 0.7rem 0.1rem 0;
|
|
347
|
+
}
|
|
348
|
+
.ccht-chat-picker label {
|
|
349
|
+
color: var(--ccht-muted, #8fa0b7); font-size: 0.58rem; text-transform: uppercase; font-weight: 750;
|
|
350
|
+
}
|
|
351
|
+
.ccht-chat-picker select {
|
|
352
|
+
min-width: 0; min-height: 2.2rem; padding: 0.4rem 0.5rem;
|
|
353
|
+
border: 1px solid var(--ccht-border, #30405c); border-radius: 0.45rem;
|
|
354
|
+
color: var(--ccht-fg, #c2cede); background: var(--ccht-input-bg, #111c2d);
|
|
355
|
+
font-size: 0.68rem; font-family: inherit;
|
|
356
|
+
}
|
|
357
|
+
.ccht-chat-messages {
|
|
358
|
+
flex: 1 1 auto; min-height: 0; margin: 0.8rem -0.25rem 0; padding: 0 0.25rem 0.5rem;
|
|
359
|
+
overflow: auto; overscroll-behavior: contain;
|
|
360
|
+
}
|
|
361
|
+
.ccht-chat-message {
|
|
362
|
+
margin-bottom: 0.65rem; padding: 0.78rem;
|
|
363
|
+
border: 1px solid var(--ccht-border, #223049); border-radius: 0.65rem;
|
|
364
|
+
background: var(--ccht-message-bg, #111c2d);
|
|
365
|
+
}
|
|
366
|
+
.ccht-chat-message-assistant { background: var(--ccht-message-assistant-bg, #0d1625); }
|
|
367
|
+
.ccht-chat-message-user {
|
|
368
|
+
margin-left: 1.35rem; border-color: var(--ccht-accent, #53708f);
|
|
369
|
+
background: var(--ccht-message-user-bg, #17243a);
|
|
370
|
+
}
|
|
371
|
+
.ccht-chat-message strong { font-size: 0.72rem; letter-spacing: 0.06em; text-transform: uppercase; }
|
|
372
|
+
.ccht-chat-message p {
|
|
373
|
+
margin: 0.35rem 0 0; color: var(--ccht-fg, #c2cede); line-height: 1.55; overflow-wrap: anywhere;
|
|
374
|
+
}
|
|
375
|
+
.ccht-chat-example { color: var(--ccht-muted, #8fa0b7); font-size: 0.75rem; }
|
|
376
|
+
.ccht-chat-copy {
|
|
377
|
+
border: 1px solid var(--ccht-border, #30405c); border-radius: 0.4rem;
|
|
378
|
+
background: var(--ccht-tab-bg, #111c2d); color: var(--ccht-fg, #c2cede);
|
|
379
|
+
padding: 0.3rem 0.5rem; margin-top: 0.4rem; cursor: pointer; font-size: 0.65rem;
|
|
380
|
+
}
|
|
381
|
+
.ccht-chat-model { display: block; color: var(--ccht-muted, #8fa0b7); margin-top: 0.4rem; overflow-wrap: anywhere; }
|
|
382
|
+
.ccht-chat-reasoning, .ccht-chat-plan { margin-top: 0.5rem; color: var(--ccht-muted, #8fa0b7); }
|
|
383
|
+
.ccht-chat-reasoning summary, .ccht-chat-plan summary, .ccht-chat-tool summary {
|
|
384
|
+
cursor: pointer; font-size: 0.75rem;
|
|
385
|
+
}
|
|
386
|
+
.ccht-chat-tool { margin-top: 0.5rem; color: var(--ccht-muted, #8fa0b7); }
|
|
387
|
+
.ccht-chat-tool pre { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 0.7rem; }
|
|
388
|
+
.ccht-chat-usage, .ccht-chat-permissions, .ccht-chat-status {
|
|
389
|
+
color: var(--ccht-muted, #8fa0b7); font-size: 0.75rem;
|
|
390
|
+
}
|
|
391
|
+
.ccht-chat-error { color: var(--ccht-error, #b91c1c); }
|
|
392
|
+
.ccht-chat-composer {
|
|
393
|
+
flex: 0 0 auto; margin: 0 -0.1rem -0.1rem; padding: 0.8rem 0.1rem 0.1rem;
|
|
394
|
+
border-top: 1px solid var(--ccht-border, #223049); display: grid; gap: 0.72rem;
|
|
395
|
+
}
|
|
396
|
+
.ccht-chat-composer textarea {
|
|
397
|
+
width: 100%; min-height: 5rem; max-height: 12rem; resize: vertical;
|
|
398
|
+
padding: 0.75rem 0.82rem; border: 1px solid var(--ccht-border, #30405c); border-radius: 0.5rem;
|
|
399
|
+
color: var(--ccht-fg, inherit); background: var(--ccht-input-bg, #111c2d);
|
|
400
|
+
outline: 0; font: inherit; line-height: 1.5;
|
|
401
|
+
}
|
|
402
|
+
.ccht-chat-composer .ccht-chat-actions { display: grid; gap: 0.55rem; }
|
|
403
|
+
.ccht-chat-primary {
|
|
404
|
+
min-height: 2.75rem; padding: 0.78rem 1rem; border: 0; border-radius: 0.5rem;
|
|
405
|
+
color: var(--ccht-primary-fg, #e2eaf5); background: var(--ccht-primary-bg, #53708f);
|
|
406
|
+
font-weight: 850; cursor: pointer; width: 100%;
|
|
407
|
+
}
|
|
408
|
+
.ccht-chat-primary:hover { background: var(--ccht-primary-bg-hover, #17243a); }
|
|
409
|
+
.ccht-chat-primary:disabled { cursor: not-allowed; opacity: 0.48; }
|
|
410
|
+
.ccht-chat-stop {
|
|
411
|
+
min-height: 2.4rem; padding: 0.55rem 0.85rem; border: 1px solid var(--ccht-border, #30405c);
|
|
412
|
+
border-radius: 0.5rem; color: var(--ccht-fg, #c2cede); background: var(--ccht-tab-bg, #111c2d);
|
|
413
|
+
font-size: 0.72rem; font-weight: 850; cursor: pointer;
|
|
414
|
+
}
|
|
415
|
+
.ccht-chat-sr-only {
|
|
416
|
+
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
|
|
417
|
+
overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
|
|
418
|
+
}
|
|
419
|
+
@media (max-width: 640px) {
|
|
420
|
+
.ccht-chat-message-user { margin-left: 0.75rem; }
|
|
421
|
+
}
|
|
422
|
+
</style>
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/** Browser behavior of the shared edge rail (Dock.svelte).
|
|
2
|
+
*
|
|
3
|
+
* jsdom exercises tab/panel switching, Esc handling, focus move and
|
|
4
|
+
* restore, and the inert contract. Fixtures only; no network or storage.
|
|
5
|
+
*/
|
|
6
|
+
import { render, fireEvent, cleanup } from '@testing-library/svelte';
|
|
7
|
+
import { afterEach, describe, expect, test, vi } from 'vitest';
|
|
8
|
+
import Dock from './Dock.svelte';
|
|
9
|
+
|
|
10
|
+
afterEach(() => cleanup());
|
|
11
|
+
|
|
12
|
+
function openDock(props: Record<string, unknown> = {}) {
|
|
13
|
+
return render(Dock, {
|
|
14
|
+
side: 'left',
|
|
15
|
+
title: 'Scope',
|
|
16
|
+
open: false,
|
|
17
|
+
onClose: () => {},
|
|
18
|
+
onOpen: () => {},
|
|
19
|
+
tabLabel: 'Scope',
|
|
20
|
+
tabSummary: '3 checks',
|
|
21
|
+
panelId: 'scope-panel',
|
|
22
|
+
...props,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('closed rail', () => {
|
|
27
|
+
test('shows the edge tab with summary and hides the panel', () => {
|
|
28
|
+
const { getByRole, container } = openDock();
|
|
29
|
+
const tab = getByRole('button', { name: 'Open Scope: 3 checks' });
|
|
30
|
+
expect(tab.getAttribute('aria-expanded')).toBe('false');
|
|
31
|
+
expect(tab.getAttribute('aria-controls')).toBe('scope-panel');
|
|
32
|
+
const panel = container.querySelector('#scope-panel');
|
|
33
|
+
expect(panel?.getAttribute('aria-hidden')).toBe('true');
|
|
34
|
+
expect(panel?.getAttribute('data-open')).toBe('false');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('tab click calls onOpen', async () => {
|
|
38
|
+
const onOpen = vi.fn();
|
|
39
|
+
const { getByRole } = openDock({ onOpen });
|
|
40
|
+
await fireEvent.click(getByRole('button', { name: 'Open Scope: 3 checks' }));
|
|
41
|
+
expect(onOpen).toHaveBeenCalledTimes(1);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('open rail', () => {
|
|
46
|
+
test('renders dialog content and Done/Close affordances', () => {
|
|
47
|
+
const { getByRole } = openDock({ open: true });
|
|
48
|
+
const dialog = getByRole('dialog', { name: 'Scope' });
|
|
49
|
+
expect(dialog.getAttribute('data-open')).toBe('true');
|
|
50
|
+
expect(getByRole('button', { name: 'Close Scope' })).toBeTruthy();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('Escape calls onClose only when open', async () => {
|
|
54
|
+
const onClose = vi.fn();
|
|
55
|
+
const closed = openDock({ onClose });
|
|
56
|
+
await fireEvent.keyDown(closed.container, { key: 'Escape' });
|
|
57
|
+
expect(onClose).not.toHaveBeenCalled();
|
|
58
|
+
const opened = openDock({ open: true, onClose });
|
|
59
|
+
await fireEvent.keyDown(opened.container.ownerDocument, { key: 'Escape' });
|
|
60
|
+
expect(onClose).toHaveBeenCalledTimes(1);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('opening moves focus into the panel, closing returns it', async () => {
|
|
64
|
+
// jsdom has no layout: every getClientRects() is empty, so the filter
|
|
65
|
+
// correctly falls back to the panel itself. Stub rects to exercise the
|
|
66
|
+
// real-browser path where the first control wins.
|
|
67
|
+
const rects = vi.spyOn(window.Element.prototype, 'getClientRects').mockReturnValue([{} as DOMRect]);
|
|
68
|
+
try {
|
|
69
|
+
const { getByRole, rerender } = openDock();
|
|
70
|
+
getByRole('button', { name: 'Open Scope: 3 checks' }).focus();
|
|
71
|
+
await rerender({ open: true });
|
|
72
|
+
await new Promise((resolve) => requestAnimationFrame(() => resolve(undefined)));
|
|
73
|
+
expect(document.activeElement?.getAttribute('aria-label')).toBe('Close Scope');
|
|
74
|
+
await rerender({ open: false });
|
|
75
|
+
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve(undefined))));
|
|
76
|
+
// The tab remounts on close; focus must land on the live node.
|
|
77
|
+
const liveTab = getByRole('button', { name: 'Open Scope: 3 checks' });
|
|
78
|
+
expect(liveTab.isConnected).toBe(true);
|
|
79
|
+
expect(document.activeElement).toBe(liveTab);
|
|
80
|
+
} finally {
|
|
81
|
+
rects.mockRestore();
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('opening falls back to the panel when no control is visible', async () => {
|
|
86
|
+
const { getByRole, rerender, container } = openDock();
|
|
87
|
+
await rerender({ open: true });
|
|
88
|
+
await new Promise((resolve) => requestAnimationFrame(() => resolve(undefined)));
|
|
89
|
+
expect(document.activeElement).toBe(container.querySelector('#scope-panel'));
|
|
90
|
+
expect(getByRole('dialog', { name: 'Scope' })).toBeTruthy();
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -55,10 +55,13 @@
|
|
|
55
55
|
});
|
|
56
56
|
} else if (!open && wasOpen) {
|
|
57
57
|
wasOpen = false;
|
|
58
|
-
|
|
58
|
+
// Prefer the live remounted tab over the recorded invoker: the tab
|
|
59
|
+
// unmounts while the panel is open, so a stored node may be stale.
|
|
60
|
+
const invoker = prevFocus?.isConnected ? prevFocus : null;
|
|
59
61
|
prevFocus = null;
|
|
60
62
|
requestAnimationFrame(() => {
|
|
61
|
-
|
|
63
|
+
const liveTab = tabEl?.isConnected ? tabEl : null;
|
|
64
|
+
(liveTab ?? invoker)?.focus?.();
|
|
62
65
|
});
|
|
63
66
|
}
|
|
64
67
|
});
|
|
@@ -102,7 +105,7 @@
|
|
|
102
105
|
<div class="ccht-dock-title"><h2>{title}</h2>{#if tabSummary}<p>{tabSummary}</p>{/if}</div>
|
|
103
106
|
<button type="button" class="ccht-dock-close" aria-label={dismissLabel} onclick={onClose}>Close</button>
|
|
104
107
|
</div>
|
|
105
|
-
<div class="ccht-dock-body">{@render children()}</div>
|
|
108
|
+
<div class="ccht-dock-body">{#if children}{@render children()}{/if}</div>
|
|
106
109
|
</div>
|
|
107
110
|
|
|
108
111
|
<style>
|