@artooi/ag-ui-web-component 0.42.0 → 0.44.0

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.
Files changed (39) hide show
  1. package/CHANGELOG.md +161 -1
  2. package/README.md +88 -22
  3. package/dist/ag-ui-web-component.bundle.js +74 -74
  4. package/dist/ag-ui-web-component.bundle.js.map +4 -4
  5. package/dist/constants.d.ts +12 -4
  6. package/dist/constants.d.ts.map +1 -1
  7. package/dist/core/ag_ui_chat.d.ts +36 -8
  8. package/dist/core/ag_ui_chat.d.ts.map +1 -1
  9. package/dist/core/agui_client.d.ts +27 -2
  10. package/dist/core/agui_client.d.ts.map +1 -1
  11. package/dist/core/events/feedback_detail.d.ts +21 -0
  12. package/dist/core/events/feedback_detail.d.ts.map +1 -1
  13. package/dist/index.d.ts +1 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +317 -139
  16. package/dist/index.js.map +4 -4
  17. package/dist/tools/tool_dispatch.d.ts +11 -1
  18. package/dist/tools/tool_dispatch.d.ts.map +1 -1
  19. package/dist/ui/composer/voice_input.d.ts +2 -2
  20. package/dist/ui/composer/voice_input.d.ts.map +1 -1
  21. package/dist/ui/history/conversation_history.d.ts +40 -24
  22. package/dist/ui/history/conversation_history.d.ts.map +1 -1
  23. package/dist/ui/transcript/answer_actions.d.ts +12 -3
  24. package/dist/ui/transcript/answer_actions.d.ts.map +1 -1
  25. package/dist/ui/ui_strings.d.ts +5 -0
  26. package/dist/ui/ui_strings.d.ts.map +1 -1
  27. package/package.json +1 -1
  28. package/src/constants.ts +12 -4
  29. package/src/core/ag_ui_chat.ts +127 -35
  30. package/src/core/agui_client.ts +43 -3
  31. package/src/core/events/feedback_detail.ts +21 -0
  32. package/src/core/run_handlers.ts +2 -2
  33. package/src/index.ts +1 -0
  34. package/src/tools/tool_dispatch.ts +40 -4
  35. package/src/ui/composer/voice_input.ts +57 -23
  36. package/src/ui/history/conversation_history.ts +117 -35
  37. package/src/ui/transcript/answer_actions.ts +31 -3
  38. package/src/ui/ui_strings.ts +6 -0
  39. package/src/version.ts +1 -1
@@ -3,4 +3,25 @@ export interface FeedbackDetail {
3
3
  /** The rated message's text, as rendered. */
4
4
  readonly content: string;
5
5
  readonly rating: "up" | "down";
6
+ /**
7
+ * The AG-UI id of the rated assistant message: the id it streamed under, or
8
+ * the stored message's `id` when it was restored from history.
9
+ *
10
+ * What a host stores a rating against. `content` cannot be: two answers can
11
+ * read the same, and the text says nothing about which stored message it
12
+ * was. A restored message carries whatever id its store returns: the
13
+ * built-in browser store keeps the streamed ids, and a server-backed one
14
+ * keeps them only where the server persists the ids it streamed.
15
+ */
16
+ readonly messageId: string;
17
+ /** The conversation the message belongs to: the element's thread when it was drawn. */
18
+ readonly threadId: string;
19
+ /**
20
+ * The run that streamed the message, as its `RUN_STARTED` named it.
21
+ *
22
+ * Present only on a message that arrived live. A restored one has none to
23
+ * give -- an AG-UI message carries no run id -- so the key is absent there
24
+ * rather than holding a value that only looks like one.
25
+ */
26
+ readonly runId?: string;
6
27
  }
@@ -235,7 +235,7 @@ export class RunHandlers {
235
235
  this.#host.stream.queue(buffer);
236
236
  this.#host.stream.countDelta();
237
237
  },
238
- onTextEnd: (buffer) => {
238
+ onTextEnd: (buffer, message) => {
239
239
  // A text message that carried no content is a declaration, not an
240
240
  // answer, and drawing one puts an empty bubble above every tool call.
241
241
  //
@@ -265,7 +265,7 @@ export class RunHandlers {
265
265
  this.#host.transcript.revealWords(bubble);
266
266
  }
267
267
  attachCopyButtons(bubble, this.#host.strings());
268
- this.#host.actions.attach(bubble);
268
+ this.#host.actions.attach(bubble, message);
269
269
  this.#host.stream.end();
270
270
  this.#host.noteUnread();
271
271
  },
package/src/index.ts CHANGED
@@ -34,6 +34,7 @@ export {
34
34
  AgUiClient,
35
35
  type AgUiClientConfig,
36
36
  type AgUiClientHandlers,
37
+ type AgUiMessageIds,
37
38
  type AgUiRunInputs,
38
39
  type AgUiToolCall,
39
40
  ConnectionLostError,
@@ -64,6 +64,13 @@ export interface ToolDispatchHost {
64
64
  * one the element serves.
65
65
  */
66
66
  readonly tenure: () => object;
67
+ /**
68
+ * The conversation on screen: an identity the element replaces every time it
69
+ * clears the transcript for another conversation, a handover included. Only
70
+ * ever compared, so a call can tell whether the conversation it was
71
+ * dispatched in is still the one it would draw into.
72
+ */
73
+ readonly conversation: () => object;
67
74
  }
68
75
 
69
76
  /**
@@ -131,7 +138,10 @@ export class ToolDispatch {
131
138
  * check that it has not. A Stop while the host's predicate is pending ends
132
139
  * the call there, and one while the card is open declines it. A handover
133
140
  * during any of the three leaves the call with no transcript to draw in and
134
- * no store to write to, so it does neither.
141
+ * no store to write to, so it does neither. Any other reset of the
142
+ * conversation -- New chat, a drawer switch, `reload()` -- leaves the call
143
+ * its store and its result but not the transcript, so it still settles and
144
+ * returns, and does not show the run waiting in a conversation it is not in.
135
145
  */
136
146
  async execute(call: AgUiToolCall): Promise<ToolExecution | null> {
137
147
  // A skill load already rendered as a notice on the stream; it is never a
@@ -148,6 +158,7 @@ export class ToolDispatch {
148
158
  // chat had just moved to, leaving its own behind, and across a handover
149
159
  // it wrote into the next principal's store.
150
160
  const tenure = this.#host.tenure();
161
+ const conversation = this.#host.conversation();
151
162
  const threadId = this.#host.threadId();
152
163
  const card = this.#host.transcript.cardFor(call);
153
164
  this.#host.transcript.forgetCard(call.id);
@@ -270,7 +281,16 @@ export class ToolDispatch {
270
281
  if (!accepted) {
271
282
  const message = this.#host.strings().declinedAction;
272
283
  card.settle(TOOL_CALL_STATUS.DECLINED, message);
273
- this.#host.transcript.showPending();
284
+ // New chat or a drawer switch with the card open declines it too, and
285
+ // the indicator would land in the conversation that replaced this one,
286
+ // whose run is not this one and which nothing would take it down from.
287
+ // Only the drawing is skipped: the refusal is still this run's to send.
288
+ //
289
+ // Held by "draws nothing into the new chat when New chat declines the
290
+ // open card" in tool_dispatch.test.ts.
291
+ if (this.#host.conversation() === conversation) {
292
+ this.#host.transcript.showPending();
293
+ }
274
294
  // The one outcome with no error text and no server involvement at all:
275
295
  // a person said no in this browser. Nothing else records that, so
276
296
  // without the annotation the reload showed a green card for an action
@@ -332,7 +352,16 @@ export class ToolDispatch {
332
352
  }
333
353
  const content = JSON.stringify(result ?? null);
334
354
  card.settle(TOOL_CALL_STATUS.DONE, content);
335
- this.#host.transcript.showPending();
355
+ // The same check for a reset that is not a handover. The result is kept,
356
+ // as after a Stop, but the conversation on screen is not the one it was
357
+ // dispatched in, so the run is not shown waiting there.
358
+ //
359
+ // Held by "draws nothing into the new chat when a handler resolves after
360
+ // New chat" in tool_dispatch.test.ts, and for the other resets by the
361
+ // tests beside it.
362
+ if (this.#host.conversation() === conversation) {
363
+ this.#host.transcript.showPending();
364
+ }
336
365
  return { content };
337
366
  } catch (error) {
338
367
  // The same handover check as on success, and here it also guards a
@@ -358,7 +387,14 @@ export class ToolDispatch {
358
387
  // invisible from the host's side and is not one it can take back.
359
388
  const message = error instanceof Error ? error.message : String(error);
360
389
  card.settle(TOOL_CALL_STATUS.ERROR, message);
361
- this.#host.transcript.showPending();
390
+ // As on success: the failure is still reported, and not drawn as a run
391
+ // waiting in a conversation the call is no longer in.
392
+ //
393
+ // Held by "draws nothing into the new chat when a handler fails after
394
+ // New chat" in tool_dispatch.test.ts.
395
+ if (this.#host.conversation() === conversation) {
396
+ this.#host.transcript.showPending();
397
+ }
362
398
  return { content: `Error: ${message}`, error: message, outcome: TOOL_OUTCOME.FAILED };
363
399
  }
364
400
  }
@@ -58,6 +58,8 @@ export class VoiceInput {
58
58
  #capTimer: ReturnType<typeof setTimeout> | null = null;
59
59
  #hitCap = false;
60
60
  #disposed = false;
61
+ /** Identifies the newest start; an older one that settles is stale. */
62
+ #startToken: object = {};
61
63
 
62
64
  constructor(options: VoiceInputOptions) {
63
65
  this.#transcribe = options.transcribe;
@@ -93,35 +95,66 @@ export class VoiceInput {
93
95
  }
94
96
 
95
97
  async #start(): Promise<void> {
96
- let stream: MediaStream;
98
+ // The button reads idle for the whole of the permission prompt, so a second
99
+ // click during it is a second start. The latest click wins: this token is
100
+ // replaced by every start, and whichever one settles while it is not the
101
+ // newest is stale. It is a token and not an in-flight flag on purpose:
102
+ // getUserMedia may never settle (a dismissed prompt), and a flag waiting
103
+ // for it would leave the button dead until reload.
104
+ const token = {};
105
+ this.#startToken = token;
106
+ let stream: MediaStream | null;
97
107
  try {
98
108
  stream = await navigator.mediaDevices.getUserMedia({ audio: true });
99
109
  } catch {
100
- this.#fail(this.#strings.transcriptionFailed);
101
- return;
110
+ stream = null;
102
111
  }
103
- if (this.#disposed) {
104
- // The permission prompt outlived the control: the element left, or
105
- // user-key changed, while it was open. Recording now would light the
106
- // browser's indicator for a button nobody can see or stop, and dispose
107
- // has already run, so nothing else would ever release these tracks.
108
- for (const track of stream.getTracks()) {
112
+ if (token !== this.#startToken || this.#disposed) {
113
+ // Superseded by a later click, or the prompt outlived the control: the
114
+ // element left, or user-key changed, while it was open. Whatever this
115
+ // call was granted is released and nothing shared is touched: #stream,
116
+ // #recorder and the cap timer belong to the newer start, and recording
117
+ // now on a disposed control would light the browser's indicator for a
118
+ // button nobody can see or stop (dispose has already run, so nothing
119
+ // else would ever release these tracks). A refusal is dropped the same
120
+ // way, so a click nobody is waiting on draws no error over the newer one.
121
+ for (const track of stream?.getTracks() ?? []) {
109
122
  track.stop();
110
123
  }
111
124
  return;
112
125
  }
126
+ if (stream === null) {
127
+ this.#fail(this.#strings.transcriptionFailed);
128
+ return;
129
+ }
113
130
  this.#stream = stream;
114
131
  this.#chunks = [];
115
132
  this.#hitCap = false;
116
- const recorder = new MediaRecorder(stream);
117
- recorder.addEventListener("dataavailable", (event) => {
118
- this.#chunks.push(event.data);
119
- });
120
- recorder.addEventListener("stop", () => {
121
- void this.#finish(recorder.mimeType);
122
- });
123
- this.#recorder = recorder;
124
- recorder.start();
133
+ let recorder: MediaRecorder;
134
+ try {
135
+ recorder = new MediaRecorder(stream);
136
+ recorder.addEventListener("dataavailable", (event) => {
137
+ this.#chunks.push(event.data);
138
+ });
139
+ recorder.addEventListener("stop", () => {
140
+ void this.#finish(recorder.mimeType);
141
+ });
142
+ this.#recorder = recorder;
143
+ recorder.start();
144
+ } catch {
145
+ // A granted stream the browser will not record: one whose tracks have
146
+ // already ended (Chromium throws NotSupportedError from start), a device
147
+ // that went away between the grant and the start, or no MediaRecorder at
148
+ // all. It is a refused mic by another route, so it ends the same way: the
149
+ // tracks are released (the stream is already stored, and nothing else
150
+ // would stop it), the button shows the failure, and nothing reaches the
151
+ // click handler, which discards the promise. A recorder whose start threw
152
+ // can still report "recording" and still holds its listeners, but #fail
153
+ // clears #recorder, so neither #stop nor dispose can reach it and its stop
154
+ // listener never runs #finish.
155
+ this.#fail(this.#strings.transcriptionFailed);
156
+ return;
157
+ }
125
158
  // Nothing else ends a recording: `MediaRecorder` runs until it is told to
126
159
  // stop, so without this the only exits are a second click and `dispose()`.
127
160
  this.#capTimer = setTimeout(() => {
@@ -153,8 +186,8 @@ export class VoiceInput {
153
186
  * a detached element.
154
187
  *
155
188
  * Disposal is final: nothing un-disposes an instance, and the host's next
156
- * wiring builds a new one. So the disposed flag alone tells a stale
157
- * continuation from a live one, and a clip already posted when this runs is
189
+ * wiring builds a new one. So the disposed flag alone tells a continuation
190
+ * that outlived the control from a live one, and a clip already posted when this runs is
158
191
  * dropped whenever its answer arrives.
159
192
  */
160
193
  dispose(): void {
@@ -213,9 +246,10 @@ export class VoiceInput {
213
246
  }
214
247
 
215
248
  #fail(message: string): void {
216
- // Both callers reach here after an await, a permission prompt or a
217
- // transcription, either of which can settle after dispose. A disposed
218
- // control has nothing to release and no tooltip to draw.
249
+ // A transcription that fails can settle after dispose. (A refused prompt
250
+ // cannot reach here that way: #start drops a stale or disposed refusal
251
+ // before it calls this.) A disposed control has nothing to release and no
252
+ // tooltip to draw.
219
253
  if (this.#disposed) {
220
254
  return;
221
255
  }
@@ -98,16 +98,21 @@ export interface ConversationHistoryHost {
98
98
  readonly releaseClient: () => void;
99
99
  /**
100
100
  * Whether an interaction is in flight, which the composer owns: from the
101
- * moment a send or a retry is taken, or a restore that will resume a run
102
- * starts loading, not only once its run has started.
101
+ * moment a send or a retry is taken, or a restore starts loading, not only
102
+ * once its run has started.
103
103
  */
104
104
  readonly running: () => boolean;
105
105
  /**
106
- * Hold the composer as a send holds it from the moment it is taken: a send
107
- * refuses, the built-in Send parks its turn, a Retry and a pick refuse. Let
108
- * go of when the run starts, by a Stop, or by {@link RunHold.release}.
106
+ * Hold the composer as a send holds it from the moment it is taken: the
107
+ * built-in Send parks its turn, a Retry and a pick refuse. Let go of when the
108
+ * run starts, by a Stop, or by {@link RunHold.release}.
109
+ *
110
+ * A host's send refuses too, unless `restored` is given: then the hold is a
111
+ * restore's with no run to resume, and a host's send waits for `restored` to
112
+ * settle and sends after it, with the conversation drawn. `restored` settles
113
+ * once that restore has ended, or has been stopped, and never rejects.
109
114
  */
110
- readonly holdRun: () => RunHold;
115
+ readonly holdRun: (restored: Promise<void> | null) => RunHold;
111
116
  /** Stop the in-flight run. */
112
117
  readonly cancelRun: () => void;
113
118
  /** Drop the in-memory run and transcript, leaving the thread untouched. */
@@ -120,8 +125,9 @@ export interface ConversationHistoryHost {
120
125
  * A hold {@link ConversationHistoryHost.holdRun} took.
121
126
  *
122
127
  * It says nothing about whether the restore that took it was stopped. A
123
- * restore asks its generation that, because a restore with no run to resume
124
- * holds nothing and can be stopped all the same -- see
128
+ * restore asks its generation that, because a host's send that waited on a
129
+ * restore with no run to resume takes the hold over from it, and a restore
130
+ * stopped by a newer one finds that one's hold in its place -- see
125
131
  * {@link ConversationHistory.rehydrate}.
126
132
  */
127
133
  interface RunHold {
@@ -132,14 +138,6 @@ interface RunHold {
132
138
  readonly release: () => void;
133
139
  }
134
140
 
135
- /**
136
- * What a restore with no run to resume holds: nothing, so it has nothing to
137
- * let go of. Every restore awaits the store, the built-in one included, and
138
- * holding all of them would refuse a host's send made as the element connects.
139
- * Only a restore that is about to start a run has one to collide with.
140
- */
141
- const UNHELD: RunHold = Object.freeze({ release: () => {} });
142
-
143
141
  /**
144
142
  * The conversation a restore set out to restore: which restore, by its
145
143
  * generation, and the store and thread it reads.
@@ -201,6 +199,17 @@ export class ConversationHistory {
201
199
  * reaches it. `null` when none is running.
202
200
  */
203
201
  #continuation: AgUiClient | null = null;
202
+ /**
203
+ * Settles what a host's send waits on while the restore now current, one
204
+ * with no run to resume, loads and replays; `null` when no such restore is
205
+ * under way. Called when that restore ends, and as soon as it is stopped by
206
+ * anything that moves the generation, so a send waiting on a conversation
207
+ * that is no longer on screen hears so at once rather than when an
208
+ * abandoned load answers.
209
+ *
210
+ * Also what tells a pick refused for a load from one refused for a run.
211
+ */
212
+ #loading: (() => void) | null = null;
204
213
 
205
214
  constructor(host: ConversationHistoryHost) {
206
215
  this.#host = host;
@@ -256,6 +265,19 @@ export class ConversationHistory {
256
265
  // being left into the new one once the store answered, and handed it to the
257
266
  // new chat's client as the history its first turn would carry.
258
267
  this.#generation += 1;
268
+ // A host's send waiting on that restore is addressed to the conversation
269
+ // just cleared. The element's Stop has let go of it already; this wakes it
270
+ // to find so, rather than leaving it waiting on a load nobody wants -- for
271
+ // good, if that load never answers. The drop tests in
272
+ // `ag_ui_chat_send_during_restore.test.ts` hang without it.
273
+ this.#endWait();
274
+ }
275
+
276
+ /** Settle what waits on the restore now loading, if anything does, and forget it. */
277
+ #endWait(): void {
278
+ const end = this.#loading;
279
+ this.#loading = null;
280
+ end?.();
259
281
  }
260
282
 
261
283
  /** Delete the active thread if nothing was ever sent in it. */
@@ -401,7 +423,15 @@ export class ConversationHistory {
401
423
  // closed the panel before this ran. The typed turn stays where it is --
402
424
  // it is what the user wants sent once the run is done -- and the caret
403
425
  // goes back to it, where Escape stops the run.
404
- this.#refuse(this.#host.strings().continueWhileRunning);
426
+ //
427
+ // A restore with no run to resume holds the composer too, and there is
428
+ // no answer to wait for and nothing Escape can stop, so it says the
429
+ // conversation is still loading instead. A restore that will resume a
430
+ // run keeps the running wording: what it is loading is that run.
431
+ const strings = this.#host.strings();
432
+ this.#refuse(
433
+ this.#loading === null ? strings.continueWhileRunning : strings.continueWhileLoading,
434
+ );
405
435
  return;
406
436
  }
407
437
  const content = this.#host.input.value.trim();
@@ -553,16 +583,30 @@ export class ConversationHistory {
553
583
  * exclusion is held by "resumes with the landed page's result, and its card says
554
584
  * so" in `ag_ui_chat_reload_mid_run.test.ts`.
555
585
  *
556
- * **A restore that resumes holds the composer from before the load.** The
557
- * resume is a run, and it starts only once the store has answered. With a
558
- * remote store that is a real request, and a send, a Retry or a pick in it
559
- * was refused by nothing: each built the conversation's client before the
560
- * conversation had loaded, so a send went out carrying none of it and its
561
- * first save replaced the stored one, and the resume then ran on that same
562
- * client as a second run, answering a call the request no longer held. So
563
- * the composer is held as a send holds it, and the resume gives way to
564
- * nothing: what was typed parks behind the resumed run and goes out after its
565
- * answer, and a host's send or Retry is refused as it is behind any run.
586
+ * **Every restore holds the composer from before the load.** Until the
587
+ * store answers, the conversation's client can only be seeded from nothing.
588
+ * With a remote store that is a real request, and a send, a Retry or a pick
589
+ * in it was refused by nothing: each built the conversation's client before
590
+ * the conversation had loaded, so a send went out carrying none of it, its
591
+ * first save replaced the stored one, and the replay then drew the stored
592
+ * conversation under the new turn. The client stayed cached without it, so
593
+ * every later turn went out without it too. So the composer is held as a
594
+ * send holds it: what was typed parks and goes out after the replay, and a
595
+ * Retry and a pick are refused -- a Retry has nothing on screen to retry, and
596
+ * after the load it would re-ask the previous visit's last question.
597
+ *
598
+ * **A host's send waits for a restore with no run to resume.** It is
599
+ * usually a prompt fired as the element mounts, which no host could time
600
+ * against a load nothing announces, and refusing it would lose the turn.
601
+ * So it waits for this restore to end, taking the hold over as it does,
602
+ * and then sends with the conversation drawn above it; what the built-in
603
+ * Send parked goes out after its run. Stopped while it waits, it is dropped,
604
+ * as a send stopped by its own submit listener is.
605
+ *
606
+ * **A restore that resumes refuses a host's send instead.** The resume is a
607
+ * run, and the restore ends by starting it, so a send that waited would only
608
+ * reach that run and be refused there. The resume gives way to nothing: what
609
+ * was typed parks behind the resumed run and goes out after its answer.
566
610
  *
567
611
  * The other way round, the resume standing down for the send, was rejected.
568
612
  * The send that got in first had already gone out without the conversation,
@@ -575,8 +619,8 @@ export class ConversationHistory {
575
619
  * page can each come while the store answers, and from host code the restore
576
620
  * runs itself: the activity and tool renderers each replayed message draws
577
621
  * through, and `navigationResult`. From then on it draws nothing more and
578
- * resumes nothing. The hold cannot say so, because a restore with no run to
579
- * resume holds nothing. So the restore asks its generation, which clearing
622
+ * resumes nothing. The hold cannot say so, because a host's send that
623
+ * waited may hold it by then. So the restore asks its generation, which clearing
580
624
  * the conversation moves on as a newer restore does, and asks it after the
581
625
  * load, after each replayed message -- as a Retry asks after each one -- and
582
626
  * once more before the resume. Stopped by anything but the element leaving
@@ -584,10 +628,6 @@ export class ConversationHistory {
584
628
  * for the run it would have resumed -- unless what replaced it is a restore
585
629
  * of the same conversation, still current, which answers for that run
586
630
  * instead; see `#forgetStopped`.
587
- *
588
- * What a restore with no run to resume still does not do is refuse a send
589
- * made while it loads. That send goes out without the stored conversation,
590
- * and its save replaces it.
591
631
  */
592
632
  async rehydrate(): Promise<void> {
593
633
  // Claimed before awaiting, and checked by `#restoring` wherever the restore
@@ -618,7 +658,22 @@ export class ConversationHistory {
618
658
  // `#claimedByCurrent`.
619
659
  this.#claim = { generation, store, threadId };
620
660
  const checkpoint = store.loadCheckpoint(threadId);
621
- const hold = checkpoint === null ? UNHELD : this.#host.holdRun();
661
+ // What a host's send waits on, for a restore with no run to resume, and
662
+ // the hold either kind takes. Taken before the load for the same reason
663
+ // the checkpoint is read before it: the load is the window.
664
+ //
665
+ // No earlier wait is left to settle here: every restore but the first
666
+ // follows a clear of the conversation on screen -- a reload, a thread
667
+ // switch, a handover, the element coming back -- and `forgetRestored`
668
+ // settled and forgot it then. So a restore that will resume finds none.
669
+ const restored =
670
+ checkpoint === null
671
+ ? new Promise<void>((resolve) => {
672
+ this.#loading = resolve;
673
+ })
674
+ : null;
675
+ const waiting = this.#loading;
676
+ const hold = this.#host.holdRun(restored);
622
677
  try {
623
678
  // Held while the store answers. A remote store answers after first paint,
624
679
  // and a conversation it is still fetching is more likely to have messages
@@ -684,7 +739,21 @@ export class ConversationHistory {
684
739
  // the store rejected the load, or the store or the host threw on the way
685
740
  // to the resume -- and then this is what sends a turn the composer parked
686
741
  // meanwhile, since no run is going to settle and send it.
742
+ //
743
+ // A host's send that waited on this restore has taken the hold over, so
744
+ // the release does nothing and the send goes first; what was parked goes
745
+ // out after its run. Woken only after the replay, here, rather than when
746
+ // the store answers, or its turn would be drawn above the conversation.
747
+ // Only this restore's wait: one that was stopped was woken by what
748
+ // stopped it, and the wait now current, if any, is a newer restore's.
749
+ // Waking that one here sent its turn before its conversation was drawn,
750
+ // which "drops the waiting send on a drawer switch, and a send then
751
+ // carries the picked thread" in `ag_ui_chat_send_during_restore.test.ts`
752
+ // holds.
687
753
  hold.release();
754
+ if (this.#loading === waiting) {
755
+ this.#endWait();
756
+ }
688
757
  }
689
758
  }
690
759
 
@@ -847,7 +916,20 @@ export class ConversationHistory {
847
916
  // wrap words.
848
917
  const restoredBubble = this.#host.appendMessage(MESSAGE_ROLE.ASSISTANT, text);
849
918
  restoredBubble.classList.add("message--restored");
850
- this.#host.actions.attach(restoredBubble);
919
+ // The stored id, which is the streamed one wherever the store kept
920
+ // it. A Retry re-renders the turns it keeps through here too, so a
921
+ // kept answer stays the message it was. No run id: a stored message
922
+ // does not carry one.
923
+ //
924
+ // Narrowed rather than trusted, like the fields below: a host store or
925
+ // a `data-threads-url` server can return a message with no id, and a
926
+ // rating that names nothing is a pair of buttons that lead nowhere.
927
+ // Such an answer keeps its copy and loses only the rating.
928
+ const id: unknown = message.id;
929
+ this.#host.actions.attach(
930
+ restoredBubble,
931
+ typeof id === "string" && id !== "" ? { messageId: id } : { rateable: false },
932
+ );
851
933
  }
852
934
  // Narrowed rather than trusted, for the same reason `messageAttachments`
853
935
  // narrows the neighbouring field: anything that throws in this loop aborts
@@ -1,4 +1,5 @@
1
1
  import { FEEDBACK_EVENT, ICON_RETRY, MESSAGE_ACTIONS } from "../../constants.js";
2
+ import type { AgUiMessageIds } from "../../core/agui_client.js";
2
3
  import type { FeedbackDetail } from "../../core/events/feedback_detail.js";
3
4
  import { commaTokens } from "../../core/utils.js";
4
5
  import { copyPayload } from "../excerpts/copy_payload.js";
@@ -13,6 +14,13 @@ export interface AnswerActionsHost {
13
14
  readonly strings: () => UiStrings;
14
15
  /** Retry the last turn, as the element's public `retryLastTurn` does. */
15
16
  readonly retry: () => void;
17
+ /** The element's current conversation thread. */
18
+ readonly threadId: () => string;
19
+ }
20
+
21
+ /** A finished bubble that is not an answer, and so carries no rating. */
22
+ interface Unrateable {
23
+ readonly rateable: false;
16
24
  }
17
25
 
18
26
  /**
@@ -55,15 +63,26 @@ export class AnswerActions {
55
63
  * something survives to go in it: an empty row still takes its margin, still
56
64
  * answers to the `message-actions` part, and still reads to a screen reader
57
65
  * as a group of actions with none in it.
66
+ *
67
+ * `message` names the answer, which is what a rating is filed under; a bubble
68
+ * that is not an answer says so instead, and has no id to give.
58
69
  */
59
- attach(bubble: HTMLDivElement, options: { rateable?: boolean } = {}): void {
70
+ attach(bubble: HTMLDivElement, message: AgUiMessageIds | Unrateable): void {
60
71
  const enabled = this.#enabled();
61
72
  const copyable = enabled.has(MESSAGE_ACTIONS.COPY);
62
73
  // A failed run is copyable -- error text is what people paste into a bug
63
74
  // report -- but not rateable: a rating is a statement about an *answer*,
64
75
  // and mixing "the connection dropped" into that signal makes the host's
65
76
  // feedback data say less than it did before.
66
- const rateable = options.rateable !== false && enabled.has(MESSAGE_ACTIONS.FEEDBACK);
77
+ const answer = "messageId" in message ? message : null;
78
+ const rateable = answer !== null && enabled.has(MESSAGE_ACTIONS.FEEDBACK);
79
+ // Read now rather than at click time. Every path that moves the element to
80
+ // another thread -- New chat, a switch, a change of principal -- clears
81
+ // the transcript, so today the two agree; the bubble can only be rated
82
+ // while the thread it was drawn in is current. Were a later path to change
83
+ // the thread and keep the bubbles, the thread the message was drawn from is
84
+ // still the one it is stored in, and that is what a host files it under.
85
+ const threadId = this.#host.threadId();
67
86
  if (copyable || rateable) {
68
87
  attachMessageActions(bubble, {
69
88
  strings: this.#host.strings(),
@@ -83,7 +102,16 @@ export class AnswerActions {
83
102
  onFeedback: (rating: "up" | "down") => {
84
103
  this.#host.element.dispatchEvent(
85
104
  new CustomEvent<FeedbackDetail>(FEEDBACK_EVENT, {
86
- detail: { content: copyPayload(bubble).text, rating },
105
+ detail: {
106
+ content: copyPayload(bubble).text,
107
+ rating,
108
+ messageId: answer.messageId,
109
+ threadId,
110
+ // Omitted rather than undefined: a restored message has
111
+ // no run, and a key holding nothing reads as one that
112
+ // was looked up and came back empty.
113
+ ...(answer.runId === undefined ? {} : { runId: answer.runId }),
114
+ },
87
115
  bubbles: true,
88
116
  composed: true,
89
117
  }),
@@ -120,6 +120,11 @@ export interface UiStrings {
120
120
  continueNeedsTurn: string;
121
121
  /** Composer hint when a run continuation was picked while a run is in flight. */
122
122
  continueWhileRunning: string;
123
+ /**
124
+ * Composer hint when a run continuation was picked while the stored
125
+ * conversation is still loading.
126
+ */
127
+ continueWhileLoading: string;
123
128
 
124
129
  // ── Composer ────────────────────────────────────────────────────────────────
125
130
  /** `aria-label` of the message textarea. */
@@ -388,6 +393,7 @@ export const DEFAULT_UI_STRINGS: UiStrings = {
388
393
  notConnected: "This chat isn’t connected to an agent, so the message wasn’t sent.",
389
394
  continueNeedsTurn: "Type the next turn in the composer first, then pick a run to continue.",
390
395
  continueWhileRunning: "Wait for the current answer or stop it, then pick a run to continue.",
396
+ continueWhileLoading: "Wait for the conversation to finish loading, then pick a run to continue.",
391
397
  skillNeeds: "“{title}” needs {fields} — fill it in below, then send.",
392
398
 
393
399
  message: "Message",
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION: string = "0.42.0";
1
+ export const VERSION: string = "0.44.0";