@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usereq/widget",
3
- "version": "0.2.19",
3
+ "version": "0.2.21",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -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
- sendDisabled:
282
- this.status === "sending" || !this.sessionState?.conversationId,
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 runVersion = ++this.sendVersion;
382
- const optimisticMessageId = `optimistic:${Date.now()}`;
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: trimmedContent,
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) => window.setTimeout(resolve, step.delayMs));
422
- if (runVersion !== this.sendVersion) {
423
- return;
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 color / font-family /
9
- * font-size / line-height from the host page's <body>, and form
10
- * controls inside (textarea, input, button) re-inherit via Tailwind's
11
- * preflight rule \`color: inherit\`. If the host page sets
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
- * Setting these here makes :host the canonical source so every
15
- * descendant inside the shadow tree uses our tokens regardless of
16
- * what the host page is doing.
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;