@usereq/widget 0.2.19 → 0.2.21
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/dist/widget.js +38 -26
- package/package.json +1 -1
- package/src/custom-element/agent-widget-element.tsx +93 -21
- package/src/shared/shadow-theme.ts +20 -8
package/package.json
CHANGED
|
@@ -68,6 +68,12 @@ export class AgentWidgetElement extends HTMLElement {
|
|
|
68
68
|
private confirmationVersion = 0;
|
|
69
69
|
private bootstrapPhase: "idle" | "booting" | "ready" | "blocked" = "idle";
|
|
70
70
|
private autoStartTriggered = false;
|
|
71
|
+
// Back-to-back send support. The composer stays enabled while the agent
|
|
72
|
+
// is replying; new submissions land here and are drained sequentially by
|
|
73
|
+
// `sendMessage`. Each entry carries the optimistic bubble id so failures
|
|
74
|
+
// can yank exactly the right row.
|
|
75
|
+
private pendingQueue: Array<{ content: string; optimisticId: string }> = [];
|
|
76
|
+
private draining = false;
|
|
71
77
|
|
|
72
78
|
constructor() {
|
|
73
79
|
super();
|
|
@@ -207,6 +213,10 @@ export class AgentWidgetElement extends HTMLElement {
|
|
|
207
213
|
this.welcomeMessage = null;
|
|
208
214
|
this.errorMessage = null;
|
|
209
215
|
this.status = "idle";
|
|
216
|
+
// Bumping sendVersion above invalidates the in-flight drain; reset
|
|
217
|
+
// the queue + flag so the next session starts fresh.
|
|
218
|
+
this.pendingQueue = [];
|
|
219
|
+
this.draining = false;
|
|
210
220
|
}
|
|
211
221
|
|
|
212
222
|
private renderWidget() {
|
|
@@ -278,8 +288,10 @@ export class AgentWidgetElement extends HTMLElement {
|
|
|
278
288
|
this.sessionState?.conversationId != null
|
|
279
289
|
? () => void this.handleStopConversation()
|
|
280
290
|
: undefined,
|
|
281
|
-
|
|
282
|
-
|
|
291
|
+
// Allow the user to keep typing while the agent is mid-reply —
|
|
292
|
+
// submissions queue inside `sendMessage` and drain sequentially.
|
|
293
|
+
// Only block when there's no conversation to send to yet.
|
|
294
|
+
sendDisabled: !this.sessionState?.conversationId,
|
|
283
295
|
onOpenChange: (nextOpen: boolean) => void this.handleOpenChange(nextOpen),
|
|
284
296
|
onSendMessage: (content: string) => void this.sendMessage(content),
|
|
285
297
|
onConfirmationDecision: (confirmationId: string, accepted: boolean) =>
|
|
@@ -374,12 +386,22 @@ export class AgentWidgetElement extends HTMLElement {
|
|
|
374
386
|
return this.bootstrapPromise;
|
|
375
387
|
}
|
|
376
388
|
|
|
389
|
+
/**
|
|
390
|
+
* Entry point for user-typed sends. Drops the optimistic bubble in
|
|
391
|
+
* immediately so the user sees their own backlog, then either kicks off
|
|
392
|
+
* the drain loop (if idle) or appends to `pendingQueue` (if a prior send
|
|
393
|
+
* is still mid-stream). The drain loop runs at most one POST in flight
|
|
394
|
+
* at a time — the server-side session memory expects sequential turns,
|
|
395
|
+
* and the AI engineer confirmed parallel requests are *possible* but
|
|
396
|
+
* the answers would interleave out-of-order on the wire.
|
|
397
|
+
*/
|
|
377
398
|
private async sendMessage(content: string) {
|
|
378
399
|
const trimmedContent = content.trim();
|
|
379
400
|
if (!trimmedContent || !this.sessionState?.conversationId) return;
|
|
380
401
|
|
|
381
|
-
const
|
|
382
|
-
|
|
402
|
+
const optimisticMessageId = `optimistic:${Date.now()}:${Math.random()
|
|
403
|
+
.toString(36)
|
|
404
|
+
.slice(2, 8)}`;
|
|
383
405
|
const optimisticMessage: WidgetMessage = {
|
|
384
406
|
id: optimisticMessageId,
|
|
385
407
|
conversationId: this.sessionState.conversationId,
|
|
@@ -388,19 +410,74 @@ export class AgentWidgetElement extends HTMLElement {
|
|
|
388
410
|
createdAt: new Date().toISOString(),
|
|
389
411
|
};
|
|
390
412
|
|
|
391
|
-
this.status = "sending";
|
|
392
413
|
this.errorMessage = null;
|
|
393
414
|
this.messages = [...this.messages, optimisticMessage];
|
|
394
415
|
this.renderWidget();
|
|
395
416
|
|
|
417
|
+
// Drain already running → just join the queue. The drainer will
|
|
418
|
+
// consume this entry as soon as the in-flight POST + reveal settle.
|
|
419
|
+
if (this.draining) {
|
|
420
|
+
this.pendingQueue.push({
|
|
421
|
+
content: trimmedContent,
|
|
422
|
+
optimisticId: optimisticMessageId,
|
|
423
|
+
});
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
this.draining = true;
|
|
428
|
+
this.status = "sending";
|
|
429
|
+
this.renderWidget();
|
|
430
|
+
|
|
431
|
+
const runVersion = this.sendVersion;
|
|
432
|
+
let succeeded = true;
|
|
433
|
+
|
|
434
|
+
try {
|
|
435
|
+
await this.processSend(trimmedContent, optimisticMessageId, runVersion);
|
|
436
|
+
while (this.pendingQueue.length > 0 && runVersion === this.sendVersion) {
|
|
437
|
+
const next = this.pendingQueue.shift()!;
|
|
438
|
+
await this.processSend(next.content, next.optimisticId, runVersion);
|
|
439
|
+
}
|
|
440
|
+
} catch {
|
|
441
|
+
succeeded = false;
|
|
442
|
+
if (runVersion === this.sendVersion) {
|
|
443
|
+
// Drop the rest of the backlog — each queued message expects the
|
|
444
|
+
// server to have already consumed the prior turn, which didn't
|
|
445
|
+
// happen. Leaving them queued would silently fire mid-failure.
|
|
446
|
+
const failedIds = new Set(
|
|
447
|
+
this.pendingQueue.map((entry) => entry.optimisticId),
|
|
448
|
+
);
|
|
449
|
+
this.pendingQueue = [];
|
|
450
|
+
this.messages = this.messages.filter((m) => !failedIds.has(m.id));
|
|
451
|
+
// `processSend` already set status="error" + errorMessage; render
|
|
452
|
+
// once more so the cleared queue is visible.
|
|
453
|
+
this.renderWidget();
|
|
454
|
+
}
|
|
455
|
+
} finally {
|
|
456
|
+
this.draining = false;
|
|
457
|
+
if (succeeded && runVersion === this.sendVersion) {
|
|
458
|
+
this.status = "ready";
|
|
459
|
+
this.renderWidget();
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* One round-trip: POST → swap optimistic → stagger assistant reveal.
|
|
466
|
+
* Throws on failure so the outer drainer can clear the backlog. The
|
|
467
|
+
* `runVersion` guard short-circuits late state writes if a hard reset
|
|
468
|
+
* (agent-id change, conversation reset) bumped `sendVersion` mid-drain.
|
|
469
|
+
*/
|
|
470
|
+
private async processSend(
|
|
471
|
+
content: string,
|
|
472
|
+
optimisticId: string,
|
|
473
|
+
runVersion: number,
|
|
474
|
+
) {
|
|
396
475
|
try {
|
|
397
476
|
const result = await submitWidgetMessage({
|
|
398
477
|
agentId: this.agentId,
|
|
399
|
-
content
|
|
478
|
+
content,
|
|
400
479
|
});
|
|
401
|
-
if (runVersion !== this.sendVersion)
|
|
402
|
-
return;
|
|
403
|
-
}
|
|
480
|
+
if (runVersion !== this.sendVersion) return;
|
|
404
481
|
this.sessionState = result.session;
|
|
405
482
|
|
|
406
483
|
const revealSteps = buildAssistantMessageRevealSteps(
|
|
@@ -418,10 +495,10 @@ export class AgentWidgetElement extends HTMLElement {
|
|
|
418
495
|
);
|
|
419
496
|
|
|
420
497
|
for (const step of revealSteps) {
|
|
421
|
-
await new Promise((resolve) =>
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
498
|
+
await new Promise((resolve) =>
|
|
499
|
+
window.setTimeout(resolve, step.delayMs),
|
|
500
|
+
);
|
|
501
|
+
if (runVersion !== this.sendVersion) return;
|
|
425
502
|
const dto = step.message;
|
|
426
503
|
this.messages = [
|
|
427
504
|
...this.messages,
|
|
@@ -435,22 +512,17 @@ export class AgentWidgetElement extends HTMLElement {
|
|
|
435
512
|
];
|
|
436
513
|
this.renderWidget();
|
|
437
514
|
}
|
|
438
|
-
|
|
439
|
-
if (runVersion === this.sendVersion) {
|
|
440
|
-
this.status = "ready";
|
|
441
|
-
this.renderWidget();
|
|
442
|
-
}
|
|
443
515
|
} catch (error) {
|
|
444
516
|
if (runVersion !== this.sendVersion) {
|
|
517
|
+
// The hard reset path already cleared state — swallow.
|
|
445
518
|
return;
|
|
446
519
|
}
|
|
447
|
-
this.messages = this.messages.filter(
|
|
448
|
-
(message) => message.id !== optimisticMessageId,
|
|
449
|
-
);
|
|
520
|
+
this.messages = this.messages.filter((m) => m.id !== optimisticId);
|
|
450
521
|
this.status = "error";
|
|
451
522
|
this.errorMessage =
|
|
452
523
|
error instanceof Error ? error.message : "Failed to send message";
|
|
453
524
|
this.renderWidget();
|
|
525
|
+
throw error;
|
|
454
526
|
}
|
|
455
527
|
}
|
|
456
528
|
|
|
@@ -5,20 +5,32 @@ export const WIDGET_SHADOW_THEME_CSS = `
|
|
|
5
5
|
* Anchor inherited typography to the design system.
|
|
6
6
|
*
|
|
7
7
|
* Shadow DOM isolates SELECTORS but NOT inherited properties — the
|
|
8
|
-
* <usereq-agent-widget> custom element inherits
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* \`body { color: #fff }\` the widget's textarea becomes invisible.
|
|
8
|
+
* <usereq-agent-widget> custom element inherits a long list of text
|
|
9
|
+
* properties from the host page's <body>, and form controls inside
|
|
10
|
+
* (textarea, input, button) re-inherit via Tailwind's preflight rule
|
|
11
|
+
* \`color: inherit\`. Host pages set things like:
|
|
13
12
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
13
|
+
* body { color: #fff; } → textarea text invisible
|
|
14
|
+
* body { text-align: center; } → every message bubble centered
|
|
15
|
+
* body { font-family: cursive; } → widget renders in cursive
|
|
16
|
+
* body { text-transform: uppercase; } → ALL CAPS UI
|
|
17
|
+
*
|
|
18
|
+
* Setting these on :host makes the widget the canonical source so
|
|
19
|
+
* every descendant inside the shadow tree uses our tokens regardless
|
|
20
|
+
* of what the host page is doing. Each line below addresses a
|
|
21
|
+
* commonly-reported customer bleed.
|
|
17
22
|
*/
|
|
18
23
|
color: var(--foreground);
|
|
19
24
|
font-family: var(--font-geist-sans), system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
20
25
|
font-size: 16px;
|
|
26
|
+
font-style: normal;
|
|
27
|
+
font-weight: 400;
|
|
21
28
|
line-height: 1.5;
|
|
29
|
+
text-align: left;
|
|
30
|
+
text-decoration: none;
|
|
31
|
+
text-transform: none;
|
|
32
|
+
letter-spacing: normal;
|
|
33
|
+
word-spacing: normal;
|
|
22
34
|
--tw-border-style: solid;
|
|
23
35
|
--radius: 0.625rem;
|
|
24
36
|
--font-geist-sans: Geist, sans-serif;
|