@artooi/ag-ui-web-component 0.3.1 → 0.5.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.
package/dist/index.js CHANGED
@@ -239,7 +239,7 @@ function actionButton(modifier, label) {
239
239
  button.textContent = label;
240
240
  return button;
241
241
  }
242
- function requestConfirmation(host, request) {
242
+ function requestConfirmation(host, request, options = {}) {
243
243
  return new Promise((resolve) => {
244
244
  const card = document.createElement("div");
245
245
  card.className = "confirm";
@@ -256,7 +256,12 @@ function requestConfirmation(host, request) {
256
256
  actions.className = "confirm-actions";
257
257
  const cancel = actionButton("cancel", "Cancel");
258
258
  const confirm = actionButton("confirm", "Confirm");
259
+ let settled = false;
259
260
  const close = (accepted) => {
261
+ if (settled) {
262
+ return;
263
+ }
264
+ settled = true;
260
265
  cancel.disabled = true;
261
266
  confirm.disabled = true;
262
267
  card.setAttribute("data-resolved", accepted ? "confirmed" : "declined");
@@ -264,9 +269,14 @@ function requestConfirmation(host, request) {
264
269
  };
265
270
  cancel.addEventListener("click", () => close(false));
266
271
  confirm.addEventListener("click", () => close(true));
272
+ options.signal?.addEventListener("abort", () => close(false), { once: true });
267
273
  actions.append(cancel, confirm);
268
274
  card.append(body, args, actions);
269
275
  host.appendChild(card);
276
+ if (options.signal?.aborted === true) {
277
+ close(false);
278
+ return;
279
+ }
270
280
  confirm.focus();
271
281
  });
272
282
  }
@@ -3449,6 +3459,19 @@ var STYLES = `
3449
3459
  cursor: default;
3450
3460
  }
3451
3461
 
3462
+ /* The composer button doubles as the Stop control while a run is in flight. */
3463
+ .send[data-state="running"] {
3464
+ background: var(--ag-ui-muted);
3465
+ }
3466
+
3467
+ /* Muted "\u23F9 Stopped" line after a cancelled run \u2014 a note, not an error bubble. */
3468
+ .stopped-note {
3469
+ align-self: flex-start;
3470
+ color: var(--ag-ui-muted);
3471
+ font-size: 12px;
3472
+ padding: 2px 4px;
3473
+ }
3474
+
3452
3475
  /* Inline confirmation card \u2014 lives in the transcript, no focus-stealing overlay. */
3453
3476
  .confirm {
3454
3477
  align-self: stretch;
@@ -3579,8 +3602,370 @@ var STYLES = `
3579
3602
  font-size: 0.85em;
3580
3603
  color: var(--ag-ui-danger);
3581
3604
  }
3605
+
3606
+ /* Chat-history drawer \u2014 a slide-over within the chat panel. */
3607
+ .drawer {
3608
+ position: absolute;
3609
+ inset: 0;
3610
+ z-index: 5;
3611
+ display: flex;
3612
+ }
3613
+
3614
+ .drawer[hidden] {
3615
+ display: none;
3616
+ }
3617
+
3618
+ .drawer-backdrop {
3619
+ position: absolute;
3620
+ inset: 0;
3621
+ background: rgba(20, 20, 50, 0.32);
3622
+ }
3623
+
3624
+ .drawer-panel {
3625
+ position: relative;
3626
+ display: flex;
3627
+ flex-direction: column;
3628
+ width: min(300px, 85%);
3629
+ height: 100%;
3630
+ background: var(--ag-ui-bg);
3631
+ border-right: 1px solid var(--ag-ui-border);
3632
+ box-shadow: var(--ag-ui-shadow);
3633
+ overflow: hidden;
3634
+ }
3635
+
3636
+ .drawer-header {
3637
+ display: flex;
3638
+ align-items: center;
3639
+ justify-content: space-between;
3640
+ gap: var(--ag-ui-space);
3641
+ padding: var(--ag-ui-pad);
3642
+ border-bottom: 1px solid var(--ag-ui-border);
3643
+ }
3644
+
3645
+ .drawer-title {
3646
+ font-weight: 600;
3647
+ }
3648
+
3649
+ .drawer-new {
3650
+ border: 1px solid var(--ag-ui-border);
3651
+ border-radius: var(--ag-ui-radius);
3652
+ background: var(--ag-ui-bg);
3653
+ color: var(--ag-ui-accent);
3654
+ padding: 4px 10px;
3655
+ font: inherit;
3656
+ font-size: 0.85em;
3657
+ cursor: pointer;
3658
+ }
3659
+
3660
+ .drawer-list {
3661
+ flex: 1;
3662
+ min-height: 0;
3663
+ overflow-y: auto;
3664
+ }
3665
+
3666
+ .drawer-empty {
3667
+ padding: var(--ag-ui-pad);
3668
+ font-size: 0.9em;
3669
+ color: var(--ag-ui-muted);
3670
+ }
3671
+
3672
+ .drawer-row {
3673
+ display: flex;
3674
+ align-items: stretch;
3675
+ border-bottom: 1px solid var(--ag-ui-border);
3676
+ }
3677
+
3678
+ .drawer-row--active {
3679
+ background: var(--ag-ui-assistant-bg);
3680
+ }
3681
+
3682
+ .drawer-row-select {
3683
+ flex: 1;
3684
+ min-width: 0;
3685
+ display: flex;
3686
+ flex-direction: column;
3687
+ gap: 2px;
3688
+ padding: 8px 12px;
3689
+ border: none;
3690
+ background: none;
3691
+ color: inherit;
3692
+ font: inherit;
3693
+ text-align: left;
3694
+ cursor: pointer;
3695
+ }
3696
+
3697
+ .drawer-row-title {
3698
+ font-weight: 600;
3699
+ overflow: hidden;
3700
+ white-space: nowrap;
3701
+ text-overflow: ellipsis;
3702
+ }
3703
+
3704
+ .drawer-row-time {
3705
+ font-size: 0.72em;
3706
+ color: var(--ag-ui-muted);
3707
+ }
3708
+
3709
+ .drawer-row-preview {
3710
+ font-size: 0.8em;
3711
+ color: var(--ag-ui-muted);
3712
+ overflow: hidden;
3713
+ white-space: nowrap;
3714
+ text-overflow: ellipsis;
3715
+ }
3716
+
3717
+ .drawer-row-actions {
3718
+ display: flex;
3719
+ align-items: center;
3720
+ gap: 2px;
3721
+ padding: 0 6px;
3722
+ }
3723
+
3724
+ .drawer-row-rename,
3725
+ .drawer-row-delete {
3726
+ border: none;
3727
+ background: none;
3728
+ color: var(--ag-ui-muted);
3729
+ font-size: 0.9em;
3730
+ padding: 4px;
3731
+ cursor: pointer;
3732
+ }
3733
+
3734
+ .drawer-rename-input {
3735
+ flex: 1;
3736
+ min-width: 0;
3737
+ margin: 6px 10px;
3738
+ padding: 4px 8px;
3739
+ border: 1px solid var(--ag-ui-accent);
3740
+ border-radius: 6px;
3741
+ background: var(--ag-ui-input-bg);
3742
+ color: var(--ag-ui-fg);
3743
+ font: inherit;
3744
+ }
3745
+
3746
+ .drawer-confirm {
3747
+ display: flex;
3748
+ align-items: center;
3749
+ gap: 8px;
3750
+ padding: 8px 12px;
3751
+ font-size: 0.85em;
3752
+ }
3753
+
3754
+ .drawer-confirm-label {
3755
+ color: var(--ag-ui-danger);
3756
+ }
3757
+
3758
+ .drawer-confirm-yes {
3759
+ border: none;
3760
+ border-radius: 6px;
3761
+ background: var(--ag-ui-danger);
3762
+ color: #ffffff;
3763
+ padding: 3px 10px;
3764
+ font: inherit;
3765
+ cursor: pointer;
3766
+ }
3767
+
3768
+ .drawer-confirm-no {
3769
+ border: 1px solid var(--ag-ui-border);
3770
+ border-radius: 6px;
3771
+ background: none;
3772
+ color: inherit;
3773
+ padding: 3px 10px;
3774
+ font: inherit;
3775
+ cursor: pointer;
3776
+ }
3777
+
3778
+ /* Embedded placement: an inline, flush side panel rather than a dimmed,
3779
+ floating slide-over. */
3780
+ :host([placement="embedded"]) .drawer-backdrop {
3781
+ background: none;
3782
+ }
3783
+
3784
+ :host([placement="embedded"]) .drawer-panel {
3785
+ width: 100%;
3786
+ border-right: none;
3787
+ box-shadow: none;
3788
+ }
3582
3789
  `;
3583
3790
 
3791
+ // src/ui/relative_time.ts
3792
+ function relativeTime(timestamp, now = Date.now()) {
3793
+ const seconds = Math.round((now - timestamp) / 1e3);
3794
+ if (seconds < 60) {
3795
+ return "just now";
3796
+ }
3797
+ const minutes = Math.round(seconds / 60);
3798
+ if (minutes < 60) {
3799
+ return `${minutes}m ago`;
3800
+ }
3801
+ const hours = Math.round(minutes / 60);
3802
+ if (hours < 24) {
3803
+ return `${hours}h ago`;
3804
+ }
3805
+ const days = Math.round(hours / 24);
3806
+ if (days < 7) {
3807
+ return `${days}d ago`;
3808
+ }
3809
+ return `${Math.round(days / 7)}w ago`;
3810
+ }
3811
+
3812
+ // src/ui/thread_drawer.ts
3813
+ var ThreadDrawer = class {
3814
+ /** The drawer root (backdrop + panel). Append to the chat shell; hidden until opened. */
3815
+ element;
3816
+ #callbacks;
3817
+ #list;
3818
+ #threads = [];
3819
+ #activeId = "";
3820
+ constructor(callbacks) {
3821
+ this.#callbacks = callbacks;
3822
+ this.element = document.createElement("div");
3823
+ this.element.className = "drawer";
3824
+ this.element.hidden = true;
3825
+ const backdrop = document.createElement("div");
3826
+ backdrop.className = "drawer-backdrop";
3827
+ backdrop.addEventListener("click", () => this.close());
3828
+ const panel = document.createElement("div");
3829
+ panel.className = "drawer-panel";
3830
+ panel.setAttribute("role", "dialog");
3831
+ panel.setAttribute("aria-label", "Chat history");
3832
+ const header = document.createElement("div");
3833
+ header.className = "drawer-header";
3834
+ const heading = document.createElement("span");
3835
+ heading.className = "drawer-title";
3836
+ heading.textContent = "Chats";
3837
+ const newButton = document.createElement("button");
3838
+ newButton.type = "button";
3839
+ newButton.className = "drawer-new";
3840
+ newButton.textContent = "New chat";
3841
+ newButton.addEventListener("click", () => {
3842
+ this.close();
3843
+ this.#callbacks.onNew();
3844
+ });
3845
+ header.append(heading, newButton);
3846
+ this.#list = document.createElement("div");
3847
+ this.#list.className = "drawer-list";
3848
+ panel.append(header, this.#list);
3849
+ this.element.append(backdrop, panel);
3850
+ }
3851
+ isOpen() {
3852
+ return !this.element.hidden;
3853
+ }
3854
+ open() {
3855
+ this.element.hidden = false;
3856
+ }
3857
+ close() {
3858
+ this.element.hidden = true;
3859
+ }
3860
+ toggle() {
3861
+ this.element.hidden = !this.element.hidden;
3862
+ }
3863
+ /** Render the rows (or the empty state), highlighting the active thread. */
3864
+ setThreads(threads, activeId) {
3865
+ this.#threads = threads;
3866
+ this.#activeId = activeId;
3867
+ this.#renderList();
3868
+ }
3869
+ #renderList() {
3870
+ this.#list.replaceChildren();
3871
+ if (this.#threads.length === 0) {
3872
+ const empty = document.createElement("div");
3873
+ empty.className = "drawer-empty";
3874
+ empty.textContent = "No conversations yet.";
3875
+ this.#list.appendChild(empty);
3876
+ return;
3877
+ }
3878
+ for (const meta of this.#threads) {
3879
+ this.#list.appendChild(this.#renderRow(meta));
3880
+ }
3881
+ }
3882
+ #renderRow(meta) {
3883
+ const row = document.createElement("div");
3884
+ row.className = "drawer-row";
3885
+ if (meta.threadId === this.#activeId) {
3886
+ row.classList.add("drawer-row--active");
3887
+ }
3888
+ const select = document.createElement("button");
3889
+ select.type = "button";
3890
+ select.className = "drawer-row-select";
3891
+ const title = document.createElement("span");
3892
+ title.className = "drawer-row-title";
3893
+ title.textContent = meta.title;
3894
+ const time = document.createElement("span");
3895
+ time.className = "drawer-row-time";
3896
+ time.textContent = relativeTime(meta.updatedAt);
3897
+ const preview = document.createElement("span");
3898
+ preview.className = "drawer-row-preview";
3899
+ preview.textContent = meta.preview;
3900
+ select.append(title, time, preview);
3901
+ select.addEventListener("click", () => {
3902
+ this.close();
3903
+ this.#callbacks.onSelect(meta.threadId);
3904
+ });
3905
+ const rename = document.createElement("button");
3906
+ rename.type = "button";
3907
+ rename.className = "drawer-row-rename";
3908
+ rename.title = "Rename";
3909
+ rename.setAttribute("aria-label", "Rename conversation");
3910
+ rename.textContent = "\u270E";
3911
+ rename.addEventListener("click", () => this.#startRename(row, meta));
3912
+ const remove = document.createElement("button");
3913
+ remove.type = "button";
3914
+ remove.className = "drawer-row-delete";
3915
+ remove.title = "Delete";
3916
+ remove.setAttribute("aria-label", "Delete conversation");
3917
+ remove.textContent = "\u{1F5D1}";
3918
+ remove.addEventListener("click", () => this.#confirmDelete(row, meta));
3919
+ const actions = document.createElement("div");
3920
+ actions.className = "drawer-row-actions";
3921
+ actions.append(rename, remove);
3922
+ row.append(select, actions);
3923
+ return row;
3924
+ }
3925
+ /** Swap a row for an inline rename input; Enter commits, Escape cancels. */
3926
+ #startRename(row, meta) {
3927
+ const input = document.createElement("input");
3928
+ input.type = "text";
3929
+ input.className = "drawer-rename-input";
3930
+ input.value = meta.title;
3931
+ input.addEventListener("keydown", (event) => {
3932
+ if (event.key === "Enter") {
3933
+ const value = input.value.trim();
3934
+ if (value === "") {
3935
+ this.#renderList();
3936
+ } else {
3937
+ this.#callbacks.onRename(meta.threadId, value);
3938
+ }
3939
+ } else if (event.key === "Escape") {
3940
+ this.#renderList();
3941
+ }
3942
+ });
3943
+ row.replaceChildren(input);
3944
+ input.focus();
3945
+ input.select();
3946
+ }
3947
+ /** Swap a row for an inline "Delete? [Delete] [Cancel]" confirm. */
3948
+ #confirmDelete(row, meta) {
3949
+ const confirm = document.createElement("div");
3950
+ confirm.className = "drawer-confirm";
3951
+ const label = document.createElement("span");
3952
+ label.className = "drawer-confirm-label";
3953
+ label.textContent = "Delete?";
3954
+ const yes = document.createElement("button");
3955
+ yes.type = "button";
3956
+ yes.className = "drawer-confirm-yes";
3957
+ yes.textContent = "Delete";
3958
+ yes.addEventListener("click", () => this.#callbacks.onDelete(meta.threadId));
3959
+ const no = document.createElement("button");
3960
+ no.type = "button";
3961
+ no.className = "drawer-confirm-no";
3962
+ no.textContent = "Cancel";
3963
+ no.addEventListener("click", () => this.#renderList());
3964
+ confirm.append(label, yes, no);
3965
+ row.replaceChildren(confirm);
3966
+ }
3967
+ };
3968
+
3584
3969
  // src/ui/tool_call_card.ts
3585
3970
  var STATUS_LABEL = {
3586
3971
  [TOOL_CALL_STATUS.PENDING]: "running\u2026",
@@ -3669,6 +4054,9 @@ var AgUiClient = class {
3669
4054
  #getContext;
3670
4055
  #executeTool;
3671
4056
  #onPersist;
4057
+ // Set by cancel(); reset at the top of each #run(). Checked by the loop so
4058
+ // a cancel between frontend-tool rounds doesn't start another round.
4059
+ #cancelled = false;
3672
4060
  constructor(config) {
3673
4061
  this.#agent = config.agent;
3674
4062
  this.#handlers = config.handlers;
@@ -3711,23 +4099,52 @@ var AgUiClient = class {
3711
4099
  this.#agent.addMessage({ id: randomUUID(), role: "tool", content, toolCallId });
3712
4100
  this.#onPersist(this.#agent.messages);
3713
4101
  }
4102
+ /**
4103
+ * Cancel the in-flight run (AG-UI has no server cancel route — aborting the
4104
+ * streaming request is the protocol's cancel; the server observes the
4105
+ * disconnect). Safe to call with no run in flight. Partial text already
4106
+ * streamed stays in history; {@link AgUiClientHandlers.onCancelled} fires
4107
+ * instead of `onError`, and `onSettled` still follows.
4108
+ */
4109
+ cancel() {
4110
+ this.#cancelled = true;
4111
+ this.#agent.abortRun();
4112
+ }
3714
4113
  async #run() {
4114
+ this.#cancelled = false;
3715
4115
  try {
3716
4116
  await this.#runLoop();
4117
+ if (this.#cancelled) {
4118
+ this.#onCancelled();
4119
+ }
3717
4120
  } catch (error) {
3718
- this.#handlers.onError(error instanceof Error ? error.message : String(error));
4121
+ if (this.#cancelled || isAbortError(error)) {
4122
+ this.#onCancelled();
4123
+ } else {
4124
+ this.#handlers.onError(error instanceof Error ? error.message : String(error));
4125
+ }
3719
4126
  } finally {
3720
4127
  this.#handlers.onSettled();
3721
4128
  }
3722
4129
  }
4130
+ #onCancelled() {
4131
+ this.#onPersist(this.#agent.messages);
4132
+ this.#handlers.onCancelled();
4133
+ }
3723
4134
  async #runLoop() {
3724
4135
  for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
4136
+ if (this.#cancelled) {
4137
+ return;
4138
+ }
3725
4139
  const pending = [];
3726
4140
  await this.#agent.runAgent(
3727
4141
  { tools: this.#getTools(), context: this.#getContext() },
3728
4142
  this.#buildSubscriber(pending)
3729
4143
  );
3730
4144
  this.#onPersist(this.#agent.messages);
4145
+ if (this.#cancelled) {
4146
+ return;
4147
+ }
3731
4148
  if (this.#executeTool === null || pending.length === 0) {
3732
4149
  return;
3733
4150
  }
@@ -3787,12 +4204,19 @@ var AgUiClient = class {
3787
4204
  };
3788
4205
  }
3789
4206
  };
4207
+ function isAbortError(error) {
4208
+ return error instanceof Error && error.name === "AbortError";
4209
+ }
3790
4210
 
3791
4211
  // src/core/conversation_store.ts
3792
4212
  import { randomUUID as randomUUID2 } from "@ag-ui/client";
3793
4213
  var THREAD_KEY = "ag-ui-chat:thread";
4214
+ var THREADS_KEY = "ag-ui-chat:threads";
3794
4215
  var MESSAGES_PREFIX = "ag-ui-chat:messages:";
3795
4216
  var CHECKPOINT_PREFIX = "ag-ui-chat:checkpoint:";
4217
+ var TITLE_LIMIT = 60;
4218
+ var PREVIEW_LIMIT = 100;
4219
+ var DEFAULT_TITLE = "New conversation";
3796
4220
  var SessionStorageStore = class {
3797
4221
  threadId() {
3798
4222
  const existing = sessionStorage.getItem(THREAD_KEY);
@@ -3808,6 +4232,7 @@ var SessionStorageStore = class {
3808
4232
  }
3809
4233
  saveMessages(threadId, messages) {
3810
4234
  sessionStorage.setItem(MESSAGES_PREFIX + threadId, JSON.stringify(messages));
4235
+ this.#touchThread(threadId, messages);
3811
4236
  }
3812
4237
  loadCheckpoint(threadId) {
3813
4238
  return this.#readJson(CHECKPOINT_PREFIX + threadId);
@@ -3823,7 +4248,60 @@ var SessionStorageStore = class {
3823
4248
  clear(threadId) {
3824
4249
  sessionStorage.removeItem(MESSAGES_PREFIX + threadId);
3825
4250
  sessionStorage.removeItem(CHECKPOINT_PREFIX + threadId);
3826
- sessionStorage.removeItem(THREAD_KEY);
4251
+ this.#writeThreads(this.#readThreads().filter((thread) => thread.threadId !== threadId));
4252
+ if (sessionStorage.getItem(THREAD_KEY) === threadId) {
4253
+ sessionStorage.removeItem(THREAD_KEY);
4254
+ }
4255
+ }
4256
+ listThreads() {
4257
+ const metas = this.#readThreads().sort((a, b2) => b2.updatedAt - a.updatedAt).map(({ threadId, title, updatedAt, preview }) => ({ threadId, title, updatedAt, preview }));
4258
+ return Promise.resolve(metas);
4259
+ }
4260
+ setActiveThread(threadId) {
4261
+ sessionStorage.setItem(THREAD_KEY, threadId);
4262
+ }
4263
+ renameThread(threadId, title) {
4264
+ const threads = this.#readThreads();
4265
+ const entry = threads.find((thread) => thread.threadId === threadId);
4266
+ if (entry === void 0) {
4267
+ return;
4268
+ }
4269
+ entry.title = title;
4270
+ entry.titleCustom = true;
4271
+ this.#writeThreads(threads);
4272
+ }
4273
+ /** Add or refresh a thread's drawer metadata from its latest messages. */
4274
+ #touchThread(threadId, messages) {
4275
+ const threads = this.#readThreads();
4276
+ const entry = threads.find((thread) => thread.threadId === threadId);
4277
+ const preview = derivePreview(messages);
4278
+ const updatedAt = Date.now();
4279
+ if (entry === void 0) {
4280
+ threads.push({
4281
+ threadId,
4282
+ title: deriveTitle(messages),
4283
+ titleCustom: false,
4284
+ preview,
4285
+ updatedAt
4286
+ });
4287
+ } else {
4288
+ entry.preview = preview;
4289
+ entry.updatedAt = updatedAt;
4290
+ if (!entry.titleCustom) {
4291
+ entry.title = deriveTitle(messages);
4292
+ }
4293
+ }
4294
+ this.#writeThreads(threads);
4295
+ }
4296
+ #readThreads() {
4297
+ return this.#readJson(THREADS_KEY) ?? [];
4298
+ }
4299
+ #writeThreads(threads) {
4300
+ if (threads.length === 0) {
4301
+ sessionStorage.removeItem(THREADS_KEY);
4302
+ return;
4303
+ }
4304
+ sessionStorage.setItem(THREADS_KEY, JSON.stringify(threads));
3827
4305
  }
3828
4306
  /** Parse a stored JSON value, returning `null` when absent or corrupt. */
3829
4307
  #readJson(key) {
@@ -3838,6 +4316,32 @@ var SessionStorageStore = class {
3838
4316
  }
3839
4317
  }
3840
4318
  };
4319
+ function deriveTitle(messages) {
4320
+ for (const message of messages) {
4321
+ if (message.role === "user") {
4322
+ const text2 = cleanText(message.content);
4323
+ if (text2 !== "") {
4324
+ return truncate(text2, TITLE_LIMIT);
4325
+ }
4326
+ }
4327
+ }
4328
+ return DEFAULT_TITLE;
4329
+ }
4330
+ function derivePreview(messages) {
4331
+ for (const message of [...messages].reverse()) {
4332
+ const text2 = cleanText(message.content);
4333
+ if (text2 !== "") {
4334
+ return truncate(text2, PREVIEW_LIMIT);
4335
+ }
4336
+ }
4337
+ return "";
4338
+ }
4339
+ function cleanText(content) {
4340
+ return typeof content === "string" ? content.replace(/\s+/g, " ").trim() : "";
4341
+ }
4342
+ function truncate(text2, limit) {
4343
+ return text2.length <= limit ? text2 : `${text2.slice(0, limit - 1).trimEnd()}\u2026`;
4344
+ }
3841
4345
 
3842
4346
  // src/core/create_http_agent.ts
3843
4347
  import { HttpAgent } from "@ag-ui/client";
@@ -3869,6 +4373,96 @@ function createHttpAgent(options) {
3869
4373
  });
3870
4374
  }
3871
4375
 
4376
+ // src/core/remote_conversation_store.ts
4377
+ var RemoteConversationStore = class {
4378
+ #url;
4379
+ #headers;
4380
+ #local;
4381
+ #dropped = /* @__PURE__ */ new Set();
4382
+ #renamed = /* @__PURE__ */ new Map();
4383
+ constructor(url, headers = () => ({}), local = new SessionStorageStore()) {
4384
+ this.#url = url.endsWith("/") ? url : `${url}/`;
4385
+ this.#headers = headers;
4386
+ this.#local = local;
4387
+ }
4388
+ threadId() {
4389
+ return this.#local.threadId();
4390
+ }
4391
+ setActiveThread(threadId) {
4392
+ this.#local.setActiveThread(threadId);
4393
+ }
4394
+ saveMessages(threadId, messages) {
4395
+ this.#local.saveMessages(threadId, messages);
4396
+ }
4397
+ loadCheckpoint(threadId) {
4398
+ return this.#local.loadCheckpoint(threadId);
4399
+ }
4400
+ saveCheckpoint(threadId, checkpoint) {
4401
+ this.#local.saveCheckpoint(threadId, checkpoint);
4402
+ }
4403
+ renameThread(threadId, title) {
4404
+ this.#local.renameThread(threadId, title);
4405
+ this.#renamed.set(threadId, title);
4406
+ void this.#mutate(threadId, "PATCH", { title });
4407
+ }
4408
+ clear(threadId) {
4409
+ this.#local.clear(threadId);
4410
+ this.#dropped.add(threadId);
4411
+ void this.#mutate(threadId, "DELETE");
4412
+ }
4413
+ async listThreads() {
4414
+ const rows = await this.#fetchThreads();
4415
+ if (rows === null) {
4416
+ return this.#local.listThreads();
4417
+ }
4418
+ return rows.filter((row) => !this.#dropped.has(row.thread_id)).map((row) => this.#toMeta(row));
4419
+ }
4420
+ async loadMessages(threadId) {
4421
+ const response = await this.#get(this.#url + encodeURIComponent(threadId) + "/");
4422
+ if (response === null || !response.ok) {
4423
+ return this.#local.loadMessages(threadId);
4424
+ }
4425
+ const body = await response.json();
4426
+ return body.messages ?? null;
4427
+ }
4428
+ async #fetchThreads() {
4429
+ const response = await this.#get(this.#url);
4430
+ if (response === null || !response.ok) {
4431
+ return null;
4432
+ }
4433
+ const body = await response.json();
4434
+ return body.threads ?? [];
4435
+ }
4436
+ #toMeta(row) {
4437
+ return {
4438
+ threadId: row.thread_id,
4439
+ title: this.#renamed.get(row.thread_id) ?? row.title,
4440
+ updatedAt: row.updated_at === null ? 0 : Date.parse(row.updated_at),
4441
+ preview: row.preview
4442
+ };
4443
+ }
4444
+ /** GET that resolves to the `Response`, or `null` on a network error. */
4445
+ async #get(url) {
4446
+ try {
4447
+ return await fetch(url, { headers: this.#headers() });
4448
+ } catch {
4449
+ return null;
4450
+ }
4451
+ }
4452
+ /** Fire a best-effort write to the thread endpoint; failures are tolerated. */
4453
+ async #mutate(threadId, method, body) {
4454
+ const headers = this.#headers();
4455
+ try {
4456
+ await fetch(this.#url + encodeURIComponent(threadId) + "/", {
4457
+ method,
4458
+ headers: body === void 0 ? headers : { ...headers, "content-type": "application/json" },
4459
+ body: body === void 0 ? null : JSON.stringify(body)
4460
+ });
4461
+ } catch {
4462
+ }
4463
+ }
4464
+ };
4465
+
3872
4466
  // src/core/ag_ui_chat.ts
3873
4467
  var COLLAPSED_KEY = "ag-ui-chat:collapsed";
3874
4468
  var AgUiChat = class extends HTMLElement {
@@ -3979,8 +4573,16 @@ var AgUiChat = class extends HTMLElement {
3979
4573
  #send;
3980
4574
  #title;
3981
4575
  #skillsMenu;
4576
+ #drawer;
3982
4577
  #skillHint;
3983
4578
  #client = null;
4579
+ // Whether an interaction is in flight (first onRunStart → onSettled). Drives
4580
+ // the Send⇄Stop button: `agent.isRunning` is false between frontend-tool
4581
+ // rounds, but the user must still be able to stop there.
4582
+ #running = false;
4583
+ // Aborting this dismisses (declines) an open confirmation card when the run
4584
+ // is cancelled while the card awaits a decision. One controller per card.
4585
+ #confirmAbort = null;
3984
4586
  #streamingBubble = null;
3985
4587
  // Text deltas applied to the current streaming bubble. >1 ⇒ the message
3986
4588
  // revealed progressively as it streamed, so the word reveal must not re-animate
@@ -4003,6 +4605,22 @@ var AgUiChat = class extends HTMLElement {
4003
4605
  this.#title = document.createElement("span");
4004
4606
  this.#skillHint = document.createElement("div");
4005
4607
  this.#skillsMenu = new SkillsMenu((skill) => this.#applySkill(skill));
4608
+ this.#drawer = new ThreadDrawer({
4609
+ onSelect: (threadId) => {
4610
+ void this.#switchThread(threadId);
4611
+ },
4612
+ onNew: () => {
4613
+ this.newChat();
4614
+ void this.#refreshDrawer();
4615
+ },
4616
+ onRename: (threadId, title) => {
4617
+ this.conversationStore.renameThread(threadId, title);
4618
+ void this.#refreshDrawer();
4619
+ },
4620
+ onDelete: (threadId) => {
4621
+ this.#deleteThread(threadId);
4622
+ }
4623
+ });
4006
4624
  }
4007
4625
  /** Attributes whose late changes must reflect in already-rendered chrome. */
4008
4626
  static get observedAttributes() {
@@ -4098,9 +4716,26 @@ var AgUiChat = class extends HTMLElement {
4098
4716
  }
4099
4717
  this.#initSkills();
4100
4718
  void this.#fetchToolCatalog();
4719
+ this.#wireThreadStore();
4101
4720
  this.#threadId = this.conversationStore.threadId();
4102
4721
  void this.#rehydrate();
4103
4722
  }
4723
+ /**
4724
+ * When `data-threads-url` is set, route thread enumeration / load / rename /
4725
+ * delete through that server endpoint (wrapping the current store as the
4726
+ * client-only fallback), so the history drawer shows durable, cross-device
4727
+ * threads. Without it, the client store's per-tab threads are used.
4728
+ */
4729
+ #wireThreadStore() {
4730
+ const url = this.getAttribute("data-threads-url");
4731
+ if (url !== null) {
4732
+ this.conversationStore = new RemoteConversationStore(
4733
+ url,
4734
+ () => this.headers,
4735
+ this.conversationStore
4736
+ );
4737
+ }
4738
+ }
4104
4739
  /** Fetch the server tool-label catalog from `data-tools-url`, if set. */
4105
4740
  async #fetchToolCatalog() {
4106
4741
  const url = this.getAttribute("data-tools-url");
@@ -4216,7 +4851,14 @@ var AgUiChat = class extends HTMLElement {
4216
4851
  * in-memory run state, clear the transcript, and mint a new thread id.
4217
4852
  */
4218
4853
  newChat() {
4854
+ this.#cancelRun();
4219
4855
  this.conversationStore.clear(this.#threadId);
4856
+ this.#resetState();
4857
+ this.#threadId = this.conversationStore.threadId();
4858
+ this.#setRunning(false);
4859
+ }
4860
+ /** Drop the in-memory run + transcript, leaving the thread id untouched. */
4861
+ #resetState() {
4220
4862
  this.#client = null;
4221
4863
  this.#streamingBubble = null;
4222
4864
  this.#hidePending();
@@ -4224,8 +4866,36 @@ var AgUiChat = class extends HTMLElement {
4224
4866
  this.#serverSettled.clear();
4225
4867
  this.#initialMessages = [];
4226
4868
  this.#messages.replaceChildren();
4227
- this.#threadId = this.conversationStore.threadId();
4228
- this.#send.disabled = false;
4869
+ }
4870
+ /** Switch the active conversation to an existing thread and replay it. */
4871
+ async #switchThread(threadId) {
4872
+ if (threadId === this.#threadId) {
4873
+ return;
4874
+ }
4875
+ this.#cancelRun();
4876
+ this.#resetState();
4877
+ this.conversationStore.setActiveThread(threadId);
4878
+ this.#threadId = threadId;
4879
+ this.#setRunning(false);
4880
+ await this.#rehydrate();
4881
+ }
4882
+ /** Delete a thread; if it was the active one, fall back to a fresh chat. */
4883
+ #deleteThread(threadId) {
4884
+ const wasActive = threadId === this.#threadId;
4885
+ if (wasActive) {
4886
+ this.#cancelRun();
4887
+ }
4888
+ this.conversationStore.clear(threadId);
4889
+ if (wasActive) {
4890
+ this.#resetState();
4891
+ this.#threadId = this.conversationStore.threadId();
4892
+ this.#setRunning(false);
4893
+ }
4894
+ void this.#refreshDrawer();
4895
+ }
4896
+ /** Reload the drawer's thread list, marking the active thread. */
4897
+ async #refreshDrawer() {
4898
+ this.#drawer.setThreads(await this.conversationStore.listThreads(), this.#threadId);
4229
4899
  }
4230
4900
  /**
4231
4901
  * Restore the conversation from the store on mount, then — if a navigating
@@ -4339,6 +5009,16 @@ var AgUiChat = class extends HTMLElement {
4339
5009
  title.textContent = this.getAttribute("title-text") ?? "Assistant";
4340
5010
  const controls = document.createElement("div");
4341
5011
  controls.className = "header-controls";
5012
+ const history = document.createElement("button");
5013
+ history.type = "button";
5014
+ history.className = "header-btn header-btn--history";
5015
+ history.title = "Chat history";
5016
+ history.setAttribute("aria-label", "Chat history");
5017
+ history.textContent = "\u2630";
5018
+ history.addEventListener("click", () => {
5019
+ void this.#refreshDrawer();
5020
+ this.#drawer.open();
5021
+ });
4342
5022
  const newChat = document.createElement("button");
4343
5023
  newChat.type = "button";
4344
5024
  newChat.className = "header-btn header-btn--new";
@@ -4353,7 +5033,7 @@ var AgUiChat = class extends HTMLElement {
4353
5033
  collapse.setAttribute("aria-label", "Collapse");
4354
5034
  collapse.textContent = "\u2014";
4355
5035
  collapse.addEventListener("click", () => this.toggleCollapsed());
4356
- controls.append(newChat, collapse);
5036
+ controls.append(history, newChat, collapse);
4357
5037
  header.append(title, controls);
4358
5038
  this.#messages.className = "messages";
4359
5039
  this.#messages.setAttribute("role", "log");
@@ -4370,7 +5050,13 @@ var AgUiChat = class extends HTMLElement {
4370
5050
  this.#send.className = "send";
4371
5051
  this.#send.type = "button";
4372
5052
  this.#send.textContent = "Send";
5053
+ this.#send.setAttribute("aria-label", "Send");
5054
+ this.#send.dataset["state"] = "idle";
4373
5055
  this.#send.addEventListener("click", () => {
5056
+ if (this.#running) {
5057
+ this.#cancelRun();
5058
+ return;
5059
+ }
4374
5060
  void this.#submit();
4375
5061
  });
4376
5062
  this.#skillHint.className = "skill-hint";
@@ -4382,7 +5068,8 @@ var AgUiChat = class extends HTMLElement {
4382
5068
  this.#skillsMenu.palette,
4383
5069
  this.#skillsMenu.chips,
4384
5070
  this.#skillHint,
4385
- inputRow
5071
+ inputRow,
5072
+ this.#drawer.element
4386
5073
  );
4387
5074
  this.#root.append(style, this.#chat);
4388
5075
  }
@@ -4396,11 +5083,34 @@ var AgUiChat = class extends HTMLElement {
4396
5083
  event.preventDefault();
4397
5084
  return;
4398
5085
  }
5086
+ if (event.key === "Escape" && this.#running) {
5087
+ event.preventDefault();
5088
+ this.#cancelRun();
5089
+ return;
5090
+ }
4399
5091
  if (event.key === "Enter" && !event.shiftKey) {
4400
5092
  event.preventDefault();
4401
5093
  void this.#submit();
4402
5094
  }
4403
5095
  }
5096
+ /**
5097
+ * Stop the in-flight run: decline any confirmation card awaiting a decision
5098
+ * (the loop is suspended on it), then cancel the client run — the abort
5099
+ * closes the streaming request, which is AG-UI's cancel (the server
5100
+ * observes the disconnect).
5101
+ */
5102
+ #cancelRun() {
5103
+ this.#confirmAbort?.abort();
5104
+ this.#client?.cancel();
5105
+ }
5106
+ /** Swap the composer button between Send (idle) and Stop (running). */
5107
+ #setRunning(running) {
5108
+ this.#running = running;
5109
+ const label = running ? "Stop" : "Send";
5110
+ this.#send.textContent = label;
5111
+ this.#send.setAttribute("aria-label", label);
5112
+ this.#send.dataset["state"] = running ? "running" : "idle";
5113
+ }
4404
5114
  async #submit() {
4405
5115
  const content = this.#input.value.trim();
4406
5116
  if (content === "") {
@@ -4472,9 +5182,13 @@ var AgUiChat = class extends HTMLElement {
4472
5182
  if (typeof confirmText === "string") {
4473
5183
  request.message = confirmText;
4474
5184
  }
4475
- const decision = requestConfirmation(this.#messages, request);
5185
+ this.#confirmAbort = new AbortController();
5186
+ const decision = requestConfirmation(this.#messages, request, {
5187
+ signal: this.#confirmAbort.signal
5188
+ });
4476
5189
  this.#messages.scrollTop = this.#messages.scrollHeight;
4477
5190
  const accepted = await decision;
5191
+ this.#confirmAbort = null;
4478
5192
  if (!accepted) {
4479
5193
  const message = "User declined the action.";
4480
5194
  card.settle(TOOL_CALL_STATUS.DECLINED, message);
@@ -4509,7 +5223,7 @@ var AgUiChat = class extends HTMLElement {
4509
5223
  #handlers() {
4510
5224
  return {
4511
5225
  onRunStart: () => {
4512
- this.#send.disabled = true;
5226
+ this.#setRunning(true);
4513
5227
  this.#showPending();
4514
5228
  },
4515
5229
  onTextDelta: (buffer) => {
@@ -4538,22 +5252,34 @@ var AgUiChat = class extends HTMLElement {
4538
5252
  },
4539
5253
  onRunEnd: () => {
4540
5254
  this.#hidePending();
4541
- this.#send.disabled = false;
4542
5255
  this.#streamingBubble = null;
4543
5256
  },
4544
5257
  onError: (message) => {
4545
5258
  this.#hidePending();
4546
5259
  this.#revealWords(this.appendMessage(MESSAGE_ROLE.ASSISTANT, `\u26A0\uFE0F ${message}`));
4547
- this.#send.disabled = false;
5260
+ this.#streamingBubble = null;
5261
+ },
5262
+ onCancelled: () => {
5263
+ this.#hidePending();
5264
+ this.#appendStoppedNote();
4548
5265
  this.#streamingBubble = null;
4549
5266
  },
4550
5267
  onSettled: () => {
4551
5268
  this.#hidePending();
4552
- this.#send.disabled = false;
5269
+ this.#setRunning(false);
4553
5270
  this.#streamingBubble = null;
4554
5271
  }
4555
5272
  };
4556
5273
  }
5274
+ /** A muted "⏹ Stopped" line in the transcript (distinct from the ⚠️ error bubble). */
5275
+ #appendStoppedNote() {
5276
+ const note = document.createElement("div");
5277
+ note.className = "stopped-note";
5278
+ note.setAttribute("role", "status");
5279
+ note.textContent = "\u23F9 Stopped";
5280
+ this.#messages.appendChild(note);
5281
+ this.#messages.scrollTop = this.#messages.scrollHeight;
5282
+ }
4557
5283
  /**
4558
5284
  * Show a "thinking" indicator while the agent is being awaited — both the
4559
5285
  * silent stretch before the first token and the gap after a tool result
@@ -4775,7 +5501,7 @@ function setControlValue(el, value) {
4775
5501
  }
4776
5502
 
4777
5503
  // src/version.ts
4778
- var VERSION = "0.3.1";
5504
+ var VERSION = "0.5.0";
4779
5505
  export {
4780
5506
  AgUiChat,
4781
5507
  AgUiClient,
@@ -4783,6 +5509,7 @@ export {
4783
5509
  ELEMENT_TAG,
4784
5510
  MAX_TOOL_ROUNDS,
4785
5511
  MESSAGE_ROLE,
5512
+ RemoteConversationStore,
4786
5513
  SUBMIT_EVENT,
4787
5514
  SessionStorageStore,
4788
5515
  TOGGLE_EVENT,