@artooi/ag-ui-web-component 0.18.0 → 0.20.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
@@ -58,7 +58,7 @@ function isSkill(value) {
58
58
  return false;
59
59
  }
60
60
  const record = value;
61
- return typeof record["name"] === "string" && typeof record["title"] === "string" && typeof record["prompt"] === "string";
61
+ return typeof record["name"] === "string" && typeof record["title"] === "string" && (record["prompt"] === void 0 || typeof record["prompt"] === "string");
62
62
  }
63
63
  function parseSkills(value) {
64
64
  if (!Array.isArray(value)) {
@@ -471,7 +471,7 @@ var DEFAULT_UI_STRINGS = {
471
471
  runInterrupted: "The previous response didn\u2019t finish \u2014 the page changed before it arrived.",
472
472
  pageMoved: "The page changed since you last looked at it. Call read_page to see the current page, then retry.",
473
473
  attachmentsStillUploading: "{n} file still uploading \u2014 it was not sent with this message and is still attached.",
474
- skillNeeds: "\u201C{title}\u201D needs: {fields}",
474
+ skillNeeds: "\u201C{title}\u201D needs {fields} \u2014 fill it in below, then send.",
475
475
  message: "Message",
476
476
  inputPlaceholder: "Ask anything\u2026",
477
477
  send: "Send",
@@ -485,6 +485,10 @@ var DEFAULT_UI_STRINGS = {
485
485
  toolDone: "\u2713 done",
486
486
  toolError: "\u26A0 error",
487
487
  toolDeclined: "\u2298 declined",
488
+ resizePanel: "Resize the chat panel",
489
+ decisionApproved: "approved by you",
490
+ decisionDeclined: "declined by you",
491
+ argumentsLabel: "Arguments",
488
492
  resultLabel: "Result",
489
493
  errorLabel: "Error",
490
494
  declinedLabel: "Declined",
@@ -1106,6 +1110,7 @@ function requestConfirmation(host, request, options = {}) {
1106
1110
  args.className = "confirm-args";
1107
1111
  args.setAttribute("part", "confirm-args");
1108
1112
  args.textContent = JSON.stringify(request.args, null, 2);
1113
+ args.hidden = Object.keys(request.args).length === 0;
1109
1114
  const actions = document.createElement("div");
1110
1115
  actions.className = "confirm-actions";
1111
1116
  actions.setAttribute("part", "confirm-actions");
@@ -1117,9 +1122,7 @@ function requestConfirmation(host, request, options = {}) {
1117
1122
  return;
1118
1123
  }
1119
1124
  settled = true;
1120
- cancel.disabled = true;
1121
- confirm2.disabled = true;
1122
- card.setAttribute("data-resolved", accepted ? "confirmed" : "declined");
1125
+ card.remove();
1123
1126
  resolve(accepted);
1124
1127
  };
1125
1128
  cancel.addEventListener("click", () => close(false));
@@ -4119,6 +4122,76 @@ function renderMarkdown(text2, options) {
4119
4122
  return template.innerHTML.trim();
4120
4123
  }
4121
4124
 
4125
+ // src/ui/resize_handle.ts
4126
+ var MIN_WIDTH = 280;
4127
+ var MIN_HEIGHT = 240;
4128
+ function createResizeHandle(options) {
4129
+ const handle = document.createElement("div");
4130
+ handle.className = "resize-handle";
4131
+ handle.setAttribute("part", "resize-handle");
4132
+ handle.setAttribute("role", "separator");
4133
+ handle.setAttribute("aria-label", options.label);
4134
+ handle.tabIndex = 0;
4135
+ const sizeAt = (axis, anchor, rect, x2, y2) => {
4136
+ const width = anchor.x === "right" ? rect.right - x2 : x2 - rect.left;
4137
+ const clamped = { width: Math.max(MIN_WIDTH, width) };
4138
+ if (axis !== "both") {
4139
+ return clamped;
4140
+ }
4141
+ const height = anchor.y === "bottom" ? rect.bottom - y2 : y2 - rect.top;
4142
+ return { ...clamped, height: Math.max(MIN_HEIGHT, height) };
4143
+ };
4144
+ handle.addEventListener("pointerdown", (event) => {
4145
+ const axis = options.axis();
4146
+ if (axis === "none") {
4147
+ return;
4148
+ }
4149
+ const anchor = options.anchor();
4150
+ const rect = options.rect();
4151
+ const onMove = (move) => {
4152
+ options.apply(sizeAt(axis, anchor, rect, move.clientX, move.clientY));
4153
+ };
4154
+ const onUp = (up) => {
4155
+ window.removeEventListener("pointermove", onMove);
4156
+ window.removeEventListener("pointerup", onUp);
4157
+ handle.removeAttribute("data-dragging");
4158
+ options.commit(sizeAt(axis, anchor, rect, up.clientX, up.clientY));
4159
+ };
4160
+ handle.setAttribute("data-dragging", "true");
4161
+ window.addEventListener("pointermove", onMove);
4162
+ window.addEventListener("pointerup", onUp);
4163
+ event.preventDefault();
4164
+ });
4165
+ handle.addEventListener("keydown", (event) => {
4166
+ const axis = options.axis();
4167
+ if (axis === "none") {
4168
+ return;
4169
+ }
4170
+ const anchor = options.anchor();
4171
+ const rect = options.rect();
4172
+ const step = event.shiftKey ? 64 : 16;
4173
+ const outward = anchor.x === "right" ? -1 : 1;
4174
+ const width = rect.right - rect.left;
4175
+ const height = rect.bottom - rect.top;
4176
+ let next = null;
4177
+ if (event.key === "ArrowLeft") {
4178
+ next = { width: Math.max(MIN_WIDTH, width - step * outward) };
4179
+ } else if (event.key === "ArrowRight") {
4180
+ next = { width: Math.max(MIN_WIDTH, width + step * outward) };
4181
+ } else if (axis === "both" && (event.key === "ArrowUp" || event.key === "ArrowDown")) {
4182
+ const grow = event.key === (anchor.y === "bottom" ? "ArrowUp" : "ArrowDown");
4183
+ next = { height: Math.max(MIN_HEIGHT, height + (grow ? step : -step)) };
4184
+ }
4185
+ if (next === null) {
4186
+ return;
4187
+ }
4188
+ event.preventDefault();
4189
+ options.apply(next);
4190
+ options.commit(next);
4191
+ });
4192
+ return handle;
4193
+ }
4194
+
4122
4195
  // src/ui/reveal_words.ts
4123
4196
  function collectTextNodes(root, out) {
4124
4197
  for (const child of Array.from(root.childNodes)) {
@@ -4290,6 +4363,7 @@ var SkillsMenu = class {
4290
4363
  button2.className = "skill-chip";
4291
4364
  button2.setAttribute("part", "skill-chip");
4292
4365
  button2.textContent = skill.title;
4366
+ button2.title = `/${skill.name}`;
4293
4367
  button2.addEventListener("click", () => this.#pick(skill));
4294
4368
  this.chips.appendChild(button2);
4295
4369
  }
@@ -4306,7 +4380,11 @@ var SkillsMenu = class {
4306
4380
  const title = document.createElement("span");
4307
4381
  title.className = "skill-item-title";
4308
4382
  title.setAttribute("part", "skill-item-title");
4309
- title.textContent = skill.title;
4383
+ const token = document.createElement("code");
4384
+ token.className = "skill-item-token";
4385
+ token.setAttribute("part", "skill-item-token");
4386
+ token.textContent = `/${skill.name}`;
4387
+ title.append(token, document.createTextNode(` ${skill.title}`));
4310
4388
  item.appendChild(title);
4311
4389
  if (skill.description !== void 0) {
4312
4390
  const desc = document.createElement("span");
@@ -4488,7 +4566,72 @@ var STYLES = `
4488
4566
  padding-inline: max(var(--ag-ui-pad), calc((100% - var(--ag-ui-content-max-width)) / 2));
4489
4567
  }
4490
4568
 
4491
- :host([placement="page"]) .input-row {
4569
+ :host([placement="page"]) /* Resize handle: a corner grip on a floating panel, an edge grip on a docked
4570
+ one. Absent entirely where the placement is full-bleed, since there is
4571
+ nothing to drag. */
4572
+ .resize-handle {
4573
+ position: absolute;
4574
+ z-index: 2;
4575
+ background: transparent;
4576
+ touch-action: none;
4577
+ }
4578
+
4579
+ .resize-handle:focus-visible {
4580
+ outline: 2px solid var(--ag-ui-accent);
4581
+ outline-offset: -2px;
4582
+ }
4583
+
4584
+ /* The grip sits at the corner the panel grows toward. Which corner that is
4585
+ depends on the host's layout, not on placement, so the element measures it
4586
+ and stamps data-resize-anchor with the edges that stay put.
4587
+
4588
+ Default (bottom-right pinned, the floating case) puts the grip top-left. */
4589
+ .resize-handle {
4590
+ top: 0;
4591
+ left: 0;
4592
+ width: 14px;
4593
+ height: 14px;
4594
+ cursor: nwse-resize;
4595
+ }
4596
+
4597
+ :host([data-resize-anchor~="left"]) .resize-handle {
4598
+ left: auto;
4599
+ right: 0;
4600
+ }
4601
+
4602
+ :host([data-resize-anchor~="top"]) .resize-handle {
4603
+ top: auto;
4604
+ bottom: 0;
4605
+ }
4606
+
4607
+ /* One axis flipped means the drag runs along the other diagonal. */
4608
+ :host([data-resize-anchor="top-right"]) .resize-handle,
4609
+ :host([data-resize-anchor="bottom-left"]) .resize-handle {
4610
+ cursor: nesw-resize;
4611
+ }
4612
+
4613
+ /* Docked: the placement owns the height, so only the inner edge is the user's. */
4614
+ :host([placement="sidebar"]) .resize-handle,
4615
+ :host([placement="side"]) .resize-handle {
4616
+ top: 0;
4617
+ bottom: auto;
4618
+ width: 8px;
4619
+ height: 100%;
4620
+ cursor: ew-resize;
4621
+ }
4622
+
4623
+ /* Full-bleed: nothing to drag. */
4624
+ :host([placement="full"]) .resize-handle,
4625
+ :host([placement="page"]) .resize-handle {
4626
+ display: none;
4627
+ }
4628
+
4629
+ .resize-handle[data-dragging] {
4630
+ background: var(--ag-ui-accent);
4631
+ opacity: 0.35;
4632
+ }
4633
+
4634
+ .input-row {
4492
4635
  padding-inline: max(12px, calc((100% - var(--ag-ui-content-max-width)) / 2));
4493
4636
  }
4494
4637
 
@@ -5092,10 +5235,9 @@ var STYLES = `
5092
5235
  }
5093
5236
  }
5094
5237
 
5095
- /* Inline display mode: the lightest card \u2014 drop the box chrome so the
5096
- status row reads as one line of the answer; the result toggle still expands
5097
- below it. */
5098
- .tool-call[data-display="inline"] {
5238
+ /* Inline display mode: the lightest card. Drop the box chrome so the status row
5239
+ reads as one line of the answer; the result toggle still expands below it. */
5240
+ :host([data-tool-display="inline"]) .tool-call {
5099
5241
  max-width: 100%;
5100
5242
  background: transparent;
5101
5243
  border: none;
@@ -5125,6 +5267,44 @@ var STYLES = `
5125
5267
  color: var(--ag-ui-muted);
5126
5268
  }
5127
5269
 
5270
+ .tool-call-body {
5271
+ display: flex;
5272
+ flex-direction: column;
5273
+ gap: 6px;
5274
+ }
5275
+
5276
+ .tool-call-section {
5277
+ display: flex;
5278
+ flex-direction: column;
5279
+ gap: 3px;
5280
+ }
5281
+
5282
+ /* The heading that tells the two payloads apart. Without it the arguments and
5283
+ the result were one run of text and a reader had to guess the boundary. */
5284
+ .tool-call-section-label {
5285
+ font-size: 10px;
5286
+ font-weight: 700;
5287
+ letter-spacing: 0.06em;
5288
+ text-transform: uppercase;
5289
+ color: var(--ag-ui-muted);
5290
+ }
5291
+
5292
+ /* The record of a human decision on a gated call. An approved call used to
5293
+ look exactly like one that was never gated. */
5294
+ .skill-item-token {
5295
+ font-family: ui-monospace, "SF Mono", Menlo, monospace;
5296
+ font-size: 0.92em;
5297
+ color: var(--ag-ui-accent);
5298
+ margin-right: 6px;
5299
+ }
5300
+
5301
+ .tool-call-decision {
5302
+ flex: none;
5303
+ font-size: 11px;
5304
+ font-style: italic;
5305
+ color: var(--ag-ui-muted);
5306
+ }
5307
+
5128
5308
  .tool-call-args,
5129
5309
  .tool-call-result {
5130
5310
  margin: 0;
@@ -5139,6 +5319,43 @@ var STYLES = `
5139
5319
  color: var(--ag-ui-fg);
5140
5320
  }
5141
5321
 
5322
+ /* Display modes are pure visibility over one DOM shape, selected from the host
5323
+ attribute rather than a value stamped on the card when it was built. That is
5324
+ what lets a host flip data-tool-display and have every card already on screen
5325
+ re-read it, the way data-answer-well behaves. Baking the structure per mode
5326
+ meant the setting only reached cards created afterwards.
5327
+
5328
+ Default (no attribute) is the full mode: arguments always visible, result
5329
+ behind the toggle. */
5330
+ .tool-call[data-expanded="false"] .tool-call-section--result {
5331
+ display: none;
5332
+ }
5333
+
5334
+ /* Compact: one toggle over both regions, so a settled card is a single line
5335
+ until asked. */
5336
+ :host([data-tool-display="compact"]) .tool-call[data-expanded="false"] .tool-call-section {
5337
+ display: none;
5338
+ }
5339
+
5340
+ /* Inline: the result only; the call's arguments are noise at this density. */
5341
+ :host([data-tool-display="inline"]) .tool-call .tool-call-section--args {
5342
+ display: none;
5343
+ }
5344
+
5345
+ /* Minimal: the status row and nothing else, so there is no toggle to press. */
5346
+ :host([data-tool-display="minimal"]) .tool-call .tool-call-toggle,
5347
+ :host([data-tool-display="minimal"]) .tool-call .tool-call-body {
5348
+ display: none;
5349
+ }
5350
+
5351
+ /* A pending card has no result yet, and in the modes where the arguments are
5352
+ hidden too there is nothing behind the toggle. Hide the control rather than
5353
+ offer one that expands onto nothing. */
5354
+ .tool-call[data-status="pending"] .tool-call-toggle,
5355
+ :host([data-tool-display="inline"]) .tool-call[data-status="pending"] .tool-call-toggle {
5356
+ display: none;
5357
+ }
5358
+
5142
5359
  .tool-call-toggle {
5143
5360
  align-self: flex-start;
5144
5361
  border: none;
@@ -5158,6 +5375,75 @@ var STYLES = `
5158
5375
  content: "\u25BE ";
5159
5376
  }
5160
5377
 
5378
+ /* Resize handle: a corner grip on a floating panel, an edge grip on a docked
5379
+ one. Absent entirely where the placement is full-bleed, since there is
5380
+ nothing to drag. */
5381
+ .resize-handle {
5382
+ position: absolute;
5383
+ z-index: 2;
5384
+ background: transparent;
5385
+ touch-action: none;
5386
+ }
5387
+
5388
+ .resize-handle:focus-visible {
5389
+ outline: 2px solid var(--ag-ui-accent);
5390
+ outline-offset: -2px;
5391
+ }
5392
+
5393
+ /* The grip sits on the corner opposite the panel's anchor, because a resize
5394
+ measures from whichever edge is not moving. Selected from the host attribute,
5395
+ so switching placement moves the grip immediately.
5396
+
5397
+ Default is floating: pinned bottom-right, so the grip is top-left. */
5398
+ .resize-handle {
5399
+ top: 0;
5400
+ left: 0;
5401
+ width: 14px;
5402
+ height: 14px;
5403
+ cursor: nwse-resize;
5404
+ }
5405
+
5406
+ /* Embedded sits in normal flow, pinned top-left, so it grows bottom-right. */
5407
+ :host([placement="embedded"]) .resize-handle {
5408
+ top: auto;
5409
+ left: auto;
5410
+ right: 0;
5411
+ bottom: 0;
5412
+ cursor: nwse-resize;
5413
+ }
5414
+
5415
+ /* Pinned bottom-left, so the free corner is top-right. */
5416
+ :host([placement="bottom-left"]) .resize-handle {
5417
+ left: auto;
5418
+ right: 0;
5419
+ cursor: nesw-resize;
5420
+ }
5421
+
5422
+ /* Docked: the placement owns the height, so only the inner edge is the user's. */
5423
+ :host([placement="sidebar"]) .resize-handle,
5424
+ :host([placement="side"]) .resize-handle {
5425
+ width: 8px;
5426
+ height: 100%;
5427
+ cursor: ew-resize;
5428
+ }
5429
+
5430
+ /* Docked to the left, so the inner edge is the right-hand one. */
5431
+ :host([placement="sidebar"][data-side="left"]) .resize-handle {
5432
+ left: auto;
5433
+ right: 0;
5434
+ }
5435
+
5436
+ /* Full-bleed: nothing to drag. */
5437
+ :host([placement="full"]) .resize-handle,
5438
+ :host([placement="page"]) .resize-handle {
5439
+ display: none;
5440
+ }
5441
+
5442
+ .resize-handle[data-dragging] {
5443
+ background: var(--ag-ui-accent);
5444
+ opacity: 0.35;
5445
+ }
5446
+
5161
5447
  .input-row {
5162
5448
  display: flex;
5163
5449
  gap: 8px;
@@ -5635,9 +5921,12 @@ var STYLES = `
5635
5921
  color: var(--ag-ui-muted);
5636
5922
  }
5637
5923
 
5924
+ /* The hint sits directly above the composer's top border, so a zero bottom
5925
+ margin left the text touching the divider. */
5638
5926
  .skill-hint {
5639
- margin: 8px 12px 0;
5927
+ margin: 8px 12px;
5640
5928
  font-size: 0.85em;
5929
+ line-height: 1.4;
5641
5930
  color: var(--ag-ui-danger);
5642
5931
  }
5643
5932
 
@@ -6231,24 +6520,32 @@ function resultLabels(strings) {
6231
6520
  [TOOL_CALL_STATUS.DECLINED]: strings.declinedLabel
6232
6521
  };
6233
6522
  }
6523
+ function formatPayload(text2) {
6524
+ try {
6525
+ return JSON.stringify(JSON.parse(text2), null, 2);
6526
+ } catch {
6527
+ return text2;
6528
+ }
6529
+ }
6234
6530
  var ToolCallCard = class {
6235
6531
  /** The card's root element; append this into the message list. */
6236
6532
  element;
6237
6533
  #status;
6238
- #mode;
6239
- #args;
6534
+ #decision;
6535
+ #toggle;
6536
+ #resultSection;
6537
+ #resultLabel;
6538
+ #resultBody;
6240
6539
  #strings;
6241
6540
  #settled = false;
6242
- constructor(name, args, mode = TOOL_DISPLAY.FULL, summary, strings = DEFAULT_UI_STRINGS) {
6243
- this.#mode = mode;
6244
- this.#args = args;
6541
+ constructor(name, args, summary, strings = DEFAULT_UI_STRINGS) {
6245
6542
  this.#strings = strings;
6246
6543
  this.element = document.createElement("div");
6247
6544
  this.element.className = "tool-call";
6248
6545
  this.element.setAttribute("part", "tool-card");
6249
6546
  this.element.setAttribute("data-tool-name", name);
6250
6547
  this.element.setAttribute("data-status", TOOL_CALL_STATUS.PENDING);
6251
- this.element.setAttribute("data-display", mode);
6548
+ this.element.setAttribute("data-expanded", "false");
6252
6549
  const head = document.createElement("div");
6253
6550
  head.className = "tool-call-head";
6254
6551
  head.setAttribute("part", "tool-card-head");
@@ -6264,24 +6561,52 @@ var ToolCallCard = class {
6264
6561
  this.#status.className = "tool-call-status";
6265
6562
  this.#status.setAttribute("part", "tool-card-status");
6266
6563
  this.#status.textContent = statusLabels(strings)[TOOL_CALL_STATUS.PENDING];
6267
- head.append(icon, label, this.#status);
6268
- this.element.append(head);
6269
- if (mode === TOOL_DISPLAY.FULL) {
6270
- const argsEl = document.createElement("pre");
6271
- argsEl.className = "tool-call-args";
6272
- argsEl.setAttribute("part", "tool-card-args");
6273
- argsEl.textContent = JSON.stringify(args, null, 2);
6274
- this.element.append(argsEl);
6275
- }
6564
+ this.#decision = document.createElement("span");
6565
+ this.#decision.className = "tool-call-decision";
6566
+ this.#decision.setAttribute("part", "tool-card-decision");
6567
+ this.#decision.hidden = true;
6568
+ head.append(icon, label, this.#status, this.#decision);
6569
+ const argsSection = this.#section("args", strings.argumentsLabel);
6570
+ argsSection.body.textContent = JSON.stringify(args, null, 2);
6571
+ argsSection.root.hidden = Object.keys(args).length === 0;
6572
+ const resultSection = this.#section("result", strings.resultLabel);
6573
+ this.#resultSection = resultSection.root;
6574
+ this.#resultLabel = resultSection.label;
6575
+ this.#resultBody = resultSection.body;
6576
+ resultSection.root.hidden = true;
6577
+ this.#toggle = document.createElement("button");
6578
+ this.#toggle.type = "button";
6579
+ this.#toggle.className = "tool-call-toggle";
6580
+ this.#toggle.setAttribute("part", "tool-card-toggle");
6581
+ this.#toggle.setAttribute("aria-expanded", "false");
6582
+ this.#toggle.textContent = strings.details;
6583
+ this.#toggle.addEventListener("click", () => this.#setExpanded(!this.#expanded()));
6584
+ const body = document.createElement("div");
6585
+ body.className = "tool-call-body";
6586
+ body.setAttribute("part", "tool-card-body");
6587
+ body.append(argsSection.root, resultSection.root);
6588
+ this.element.append(head, this.#toggle, body);
6589
+ }
6590
+ /**
6591
+ * Record that a human approved or declined this call, as a line in the card.
6592
+ *
6593
+ * Approval used to leave no trace at all: a declined call became a tool
6594
+ * result saying so, while an approved one simply ran, making the transcript
6595
+ * of a gated call byte-identical to one that was never gated. The prompt is
6596
+ * gone once answered, so this is where the decision lives.
6597
+ */
6598
+ recordDecision(kind) {
6599
+ this.element.setAttribute("data-decision", kind);
6600
+ this.#decision.textContent = kind === "approved" ? this.#strings.decisionApproved : this.#strings.decisionDeclined;
6601
+ this.#decision.hidden = false;
6276
6602
  }
6277
6603
  /** Whether {@link settle} has already run (so a terminal sweep can skip it). */
6278
6604
  get settled() {
6279
6605
  return this.#settled;
6280
6606
  }
6281
6607
  /**
6282
- * Flip the status pill to ``status`` and, unless in `minimal` mode, append a
6283
- * collapsed body behind a click-to-expand toggle: the result alone (`full` /
6284
- * `inline`), or the args + result together (`compact`).
6608
+ * Flip the status pill to `status` and fill in the result region, whose
6609
+ * heading names the outcome (result / error / declined).
6285
6610
  */
6286
6611
  settle(status, text2) {
6287
6612
  if (this.#settled) {
@@ -6290,33 +6615,31 @@ var ToolCallCard = class {
6290
6615
  this.#settled = true;
6291
6616
  this.element.setAttribute("data-status", status);
6292
6617
  this.#status.textContent = statusLabels(this.#strings)[status];
6293
- if (this.#mode === TOOL_DISPLAY.MINIMAL) {
6294
- return;
6295
- }
6296
- const toggle = document.createElement("button");
6297
- toggle.type = "button";
6298
- toggle.className = "tool-call-toggle";
6299
- toggle.setAttribute("part", "tool-card-toggle");
6300
- toggle.setAttribute("aria-expanded", "false");
6301
- const output = document.createElement("pre");
6302
- output.className = "tool-call-result";
6303
- output.setAttribute("part", "tool-card-result");
6304
- output.hidden = true;
6305
- if (this.#mode === TOOL_DISPLAY.COMPACT) {
6306
- toggle.textContent = this.#strings.details;
6307
- output.textContent = `args: ${JSON.stringify(this.#args)}
6308
-
6309
- ${text2}`;
6310
- } else {
6311
- toggle.textContent = resultLabels(this.#strings)[status];
6312
- output.textContent = text2;
6313
- }
6314
- toggle.addEventListener("click", () => {
6315
- const expand = output.hidden;
6316
- output.hidden = !expand;
6317
- toggle.setAttribute("aria-expanded", String(expand));
6318
- });
6319
- this.element.append(toggle, output);
6618
+ this.#resultLabel.textContent = resultLabels(this.#strings)[status];
6619
+ this.#resultBody.textContent = formatPayload(text2);
6620
+ this.#resultSection.hidden = false;
6621
+ }
6622
+ /** Build one labelled region of the body: a heading plus a payload block. */
6623
+ #section(kind, labelText) {
6624
+ const root = document.createElement("div");
6625
+ root.className = `tool-call-section tool-call-section--${kind}`;
6626
+ root.setAttribute("part", `tool-card-section tool-card-${kind}-section`);
6627
+ const label = document.createElement("span");
6628
+ label.className = "tool-call-section-label";
6629
+ label.setAttribute("part", `tool-card-section-label tool-card-${kind}-label`);
6630
+ label.textContent = labelText;
6631
+ const body = document.createElement("pre");
6632
+ body.className = `tool-call-${kind}`;
6633
+ body.setAttribute("part", `tool-card-${kind}`);
6634
+ root.append(label, body);
6635
+ return { root, label, body };
6636
+ }
6637
+ #expanded() {
6638
+ return this.element.getAttribute("data-expanded") === "true";
6639
+ }
6640
+ #setExpanded(expand) {
6641
+ this.element.setAttribute("data-expanded", String(expand));
6642
+ this.#toggle.setAttribute("aria-expanded", String(expand));
6320
6643
  }
6321
6644
  };
6322
6645
 
@@ -6467,6 +6790,13 @@ var AgUiClient = class {
6467
6790
  #executeTool;
6468
6791
  #resolveInterrupts;
6469
6792
  #onPersist;
6793
+ /**
6794
+ * Message ids the server has already closed, so a reuse can be reported.
6795
+ *
6796
+ * Per client rather than per run: the merge happens across runs, which is the
6797
+ * case a per-run set would miss entirely.
6798
+ */
6799
+ #closedMessageIds = /* @__PURE__ */ new Set();
6470
6800
  #connectionLostMessage;
6471
6801
  // Set by cancel(); reset at the top of each #run(). Checked by the loop so
6472
6802
  // a cancel between frontend-tool rounds doesn't start another round.
@@ -6638,14 +6968,23 @@ var AgUiClient = class {
6638
6968
  }
6639
6969
  #buildSubscriber(pending, runState) {
6640
6970
  const h = this.#handlers;
6971
+ const closed = this.#closedMessageIds;
6641
6972
  return {
6642
6973
  onRunInitialized() {
6643
6974
  h.onRunStart();
6644
6975
  },
6976
+ onTextMessageStartEvent({ event }) {
6977
+ if (closed.has(event.messageId)) {
6978
+ console.warn(
6979
+ `<ag-ui-chat>: the server reused message id "${event.messageId}", which was already closed. Its content will be appended to that earlier message rather than starting a new one, and the merged result is what gets persisted. Issue a fresh id per message.`
6980
+ );
6981
+ }
6982
+ },
6645
6983
  onTextMessageContentEvent({ textMessageBuffer }) {
6646
6984
  h.onTextDelta(textMessageBuffer);
6647
6985
  },
6648
- onTextMessageEndEvent({ textMessageBuffer }) {
6986
+ onTextMessageEndEvent({ event, textMessageBuffer }) {
6987
+ closed.add(event.messageId);
6649
6988
  h.onTextEnd(textMessageBuffer);
6650
6989
  },
6651
6990
  onToolCallEndEvent({ event, toolCallName, toolCallArgs }) {
@@ -7193,6 +7532,7 @@ var CONNECT_TIME_ATTRIBUTES = [
7193
7532
  "data-icon-url"
7194
7533
  ];
7195
7534
  var COLLAPSED_KEY = "ag-ui-chat:collapsed";
7535
+ var SIZE_KEY = "ag-ui-chat:size";
7196
7536
  var THEME_KEY = "ag-ui-chat:theme";
7197
7537
  var AgUiChat = class extends HTMLElement {
7198
7538
  /** Agent factory; override to inject a custom or fake agent (tests). */
@@ -7541,9 +7881,13 @@ var AgUiChat = class extends HTMLElement {
7541
7881
  }
7542
7882
  /** Attributes the element reacts to after it has been connected. */
7543
7883
  static get observedAttributes() {
7544
- return ["title-text", ...CONNECT_TIME_ATTRIBUTES];
7884
+ return ["title-text", "placement", ...CONNECT_TIME_ATTRIBUTES];
7545
7885
  }
7546
7886
  attributeChangedCallback(name, previous, value) {
7887
+ if (name === "placement") {
7888
+ requestAnimationFrame(() => this.#syncResizeAnchor());
7889
+ return;
7890
+ }
7547
7891
  if (name === "title-text") {
7548
7892
  this.#title.textContent = value ?? this.#strings.title;
7549
7893
  return;
@@ -7731,7 +8075,11 @@ var AgUiChat = class extends HTMLElement {
7731
8075
  }
7732
8076
  /**
7733
8077
  * How much detail tool-call cards show, from the `data-tool-display`
7734
- * attribute (`minimal` / `compact` / `full`). Defaults to `full`.
8078
+ * attribute (`minimal` / `inline` / `compact` / `full`). Defaults to `full`.
8079
+ *
8080
+ * Applied by the shadow CSS from the attribute itself, so changing it
8081
+ * restyles every card already in the transcript rather than only the ones
8082
+ * built afterwards.
7735
8083
  */
7736
8084
  get toolDisplay() {
7737
8085
  const attr = this.getAttribute("data-tool-display");
@@ -7745,6 +8093,8 @@ var AgUiChat = class extends HTMLElement {
7745
8093
  }
7746
8094
  connectedCallback() {
7747
8095
  this.#storageNs = this.id !== "" ? this.id : this.endpoint;
8096
+ this.#applySize(this.#readSize());
8097
+ requestAnimationFrame(() => this.#syncResizeAnchor());
7748
8098
  this.#strings = mergeUiStrings({ ...this.#readStringOverrides(), ...this.strings });
7749
8099
  if (this.getAttribute("data-theme-toggle") !== null) {
7750
8100
  const saved = this.#readScopedItem(THEME_KEY);
@@ -8020,21 +8370,57 @@ Use the read_attachment tool with an id to read a file's contents.`
8020
8370
  }
8021
8371
  this.#skillsMenu.setSkills([...merged.values()]);
8022
8372
  }
8023
- /** Pre-fill (or send) a picked skill's prompt, filling its placeholders. */
8373
+ /**
8374
+ * Act on a picked skill.
8375
+ *
8376
+ * A skill that ships no `prompt` is **server-resolved**: the catalog carries
8377
+ * only its name and label, and picking it sends the bare `/name` token for
8378
+ * the agent to expand — from the harness `Skills` capability, or from the
8379
+ * server's own instructions. That is the shape to prefer, because the prompt
8380
+ * then never reaches the browser at all: a skill is often where a project's
8381
+ * internal workflow is written down most plainly, and a catalog endpoint is a
8382
+ * plain GET.
8383
+ *
8384
+ * A skill that does carry a `prompt` keeps the older behaviour — the client
8385
+ * fills its `{placeholder}`s from the page and sends (or pre-fills) the text.
8386
+ * Right for a user-facing convenience, and for placeholders only the page can
8387
+ * supply.
8388
+ *
8389
+ * Either way a pick now **sends**, rather than parking text in the composer
8390
+ * for a second click; `sendImmediately: false` opts back into pre-filling.
8391
+ */
8024
8392
  #applySkill(skill) {
8393
+ if (skill.prompt === void 0) {
8394
+ this.#skillHint.hidden = true;
8395
+ void this.sendMessage(`/${skill.name}`);
8396
+ return;
8397
+ }
8025
8398
  const { text: text2, missing } = fillTemplate(skill.prompt, this.skillContext());
8026
8399
  if (missing.length > 0) {
8027
8400
  this.#skillHint.textContent = this.#strings.skillNeeds.replace("{title}", skill.title).replace("{fields}", missing.join(", "));
8028
8401
  this.#skillHint.hidden = false;
8402
+ this.#input.value = text2;
8403
+ this.#input.focus();
8404
+ this.#selectFirstPlaceholder(text2);
8029
8405
  return;
8030
8406
  }
8031
8407
  this.#skillHint.hidden = true;
8032
8408
  this.#input.value = text2;
8033
- if (skill.sendImmediately === true) {
8034
- void this.#submit();
8035
- } else {
8409
+ if (skill.sendImmediately === false) {
8036
8410
  this.#input.focus();
8411
+ return;
8037
8412
  }
8413
+ void this.#submit();
8414
+ }
8415
+ /**
8416
+ * Put the caret on the first unresolved placeholder, selected.
8417
+ *
8418
+ * Typing then replaces it, which is the shortest path from "this skill needs
8419
+ * a topic" to a sendable prompt.
8420
+ */
8421
+ #selectFirstPlaceholder(text2) {
8422
+ const start = text2.indexOf("{");
8423
+ this.#input.setSelectionRange(start, text2.indexOf("}", start) + 1);
8038
8424
  }
8039
8425
  /** Whether the widget is collapsed (reflected as the `collapsed` attribute). */
8040
8426
  get collapsed() {
@@ -8082,6 +8468,102 @@ Use the read_attachment tool with an id to read a file's contents.`
8082
8468
  sessionStorage.setItem(this.#storageKey(THEME_KEY), next);
8083
8469
  this.#syncThemeGlyph();
8084
8470
  }
8471
+ /**
8472
+ * Which axes the current placement allows.
8473
+ *
8474
+ * A full-bleed layout is `100vw`/`100vh` by definition and cannot be resized
8475
+ * at all; a docked panel owns its height, leaving only its inner edge. Read
8476
+ * per interaction, because `placement` is a live attribute.
8477
+ */
8478
+ #resizeAxis() {
8479
+ switch (this.getAttribute("placement")) {
8480
+ case "full":
8481
+ case "page":
8482
+ return "none";
8483
+ case "sidebar":
8484
+ case "side":
8485
+ return "width";
8486
+ default:
8487
+ return "both";
8488
+ }
8489
+ }
8490
+ /**
8491
+ * Which edges the layout is holding still, by measuring rather than guessing.
8492
+ *
8493
+ * A resize has to be computed from the edge that does not move, and which
8494
+ * edge that is belongs to the **host's layout**, not to `placement`: a
8495
+ * floating panel is pinned bottom-right, while an embedded one goes wherever
8496
+ * the page's own CSS puts it — flex-start, flex-end, a grid cell. Mapping
8497
+ * placement to a corner got this wrong for any host that right-aligns the
8498
+ * element, and the symptom is bad enough to read as a broken control: the
8499
+ * panel shrinks when dragged outward, travelling by its opposite corner.
8500
+ *
8501
+ * So: nudge the size by a pixel, see which edges stayed put, and undo. One
8502
+ * forced reflow per drag, which is cheap next to being wrong.
8503
+ */
8504
+ #measureAnchor() {
8505
+ const before = this.getBoundingClientRect();
8506
+ const width = this.style.getPropertyValue("--ag-ui-width");
8507
+ const height = this.style.getPropertyValue("--ag-ui-height");
8508
+ this.#applySize({ width: before.width + 1, height: before.height + 1 });
8509
+ const after = this.getBoundingClientRect();
8510
+ this.#restoreProperty("--ag-ui-width", width);
8511
+ this.#restoreProperty("--ag-ui-height", height);
8512
+ return {
8513
+ x: Math.abs(after.left - before.left) < 0.5 ? "left" : "right",
8514
+ y: Math.abs(after.top - before.top) < 0.5 ? "top" : "bottom"
8515
+ };
8516
+ }
8517
+ /** Stamp the measured anchor so the shadow CSS can place the grip. */
8518
+ #syncResizeAnchor() {
8519
+ if (!this.#connected) {
8520
+ return;
8521
+ }
8522
+ const anchor = this.#measureAnchor();
8523
+ this.setAttribute("data-resize-anchor", `${anchor.y}-${anchor.x}`);
8524
+ }
8525
+ /** Put a custom property back to a previous value, or remove it if there was none. */
8526
+ #restoreProperty(name, value) {
8527
+ if (value === "") {
8528
+ this.style.removeProperty(name);
8529
+ return;
8530
+ }
8531
+ this.style.setProperty(name, value);
8532
+ }
8533
+ /**
8534
+ * Write a dragged size onto the host as custom properties.
8535
+ *
8536
+ * Properties rather than inline `width` / `height`: the placement rules set
8537
+ * those same properties, so an inline dimension would outrank them and a
8538
+ * panel dragged while floating would keep that width after switching to
8539
+ * fullscreen.
8540
+ */
8541
+ #applySize(size) {
8542
+ if (size.width !== void 0) {
8543
+ this.style.setProperty("--ag-ui-width", `${size.width}px`);
8544
+ }
8545
+ if (size.height !== void 0) {
8546
+ this.style.setProperty("--ag-ui-height", `${size.height}px`);
8547
+ }
8548
+ }
8549
+ /** Persist a dragged size per tab, alongside the collapsed/theme preferences. */
8550
+ #persistSize(size) {
8551
+ const stored = { ...this.#readSize(), ...size };
8552
+ sessionStorage.setItem(this.#storageKey(SIZE_KEY), JSON.stringify(stored));
8553
+ }
8554
+ /** The persisted size for this instance, or an empty record. */
8555
+ #readSize() {
8556
+ const raw = this.#readScopedItem(SIZE_KEY);
8557
+ if (raw === null) {
8558
+ return {};
8559
+ }
8560
+ try {
8561
+ const parsed = JSON.parse(raw);
8562
+ return typeof parsed === "object" && parsed !== null ? parsed : {};
8563
+ } catch {
8564
+ return {};
8565
+ }
8566
+ }
8085
8567
  /** This instance's namespaced form of an origin-scoped storage key. */
8086
8568
  #storageKey(base) {
8087
8569
  return this.#storageNs === "" ? base : `${base}:${this.#storageNs}`;
@@ -8476,9 +8958,32 @@ Use the read_attachment tool with an id to read a file's contents.`
8476
8958
  this.#rail.setAttribute("aria-label", this.#strings.expand);
8477
8959
  this.#rail.append(this.#iconElement("launcher", "launcher-icon", "\u{1F4AC}"));
8478
8960
  this.#rail.addEventListener("click", () => this.setCollapsed(false));
8961
+ this.#chat.append(
8962
+ createResizeHandle({
8963
+ axis: () => this.#resizeAxis(),
8964
+ anchor: () => this.#measureAnchor(),
8965
+ rect: () => this.getBoundingClientRect(),
8966
+ apply: (size) => this.#applySize(size),
8967
+ commit: (size) => {
8968
+ this.#persistSize(size);
8969
+ this.#syncResizeAnchor();
8970
+ },
8971
+ label: this.#strings.resizePanel
8972
+ })
8973
+ );
8479
8974
  this.#root.append(style, this.#chat, this.#rail);
8480
8975
  }
8481
- /** Build a header control button (icon glyph + localized title/aria). */
8976
+ /**
8977
+ * Build a header control button: a named slot a host can project markup into,
8978
+ * with the built-in glyph as the slot's fallback.
8979
+ *
8980
+ * The glyph used to be the button's own `textContent`, which left a host able
8981
+ * to restyle the control through its `part` but unable to replace it — a CSS
8982
+ * `content` override could swap one character for another, and nothing could
8983
+ * supply a brand `<img>` or `<svg>`. This is the same slot-with-fallback
8984
+ * idiom the header icon already uses, so existing embeds render exactly as
8985
+ * before.
8986
+ */
8482
8987
  #headerButton(modifier, label, glyph) {
8483
8988
  const button2 = document.createElement("button");
8484
8989
  button2.type = "button";
@@ -8486,7 +8991,10 @@ Use the read_attachment tool with an id to read a file's contents.`
8486
8991
  button2.setAttribute("part", `header-button ${modifier}-button`);
8487
8992
  button2.title = label;
8488
8993
  button2.setAttribute("aria-label", label);
8489
- button2.textContent = glyph;
8994
+ const slot = document.createElement("slot");
8995
+ slot.name = `icon-${modifier}`;
8996
+ slot.append(document.createTextNode(glyph));
8997
+ button2.append(slot);
8490
8998
  return button2;
8491
8999
  }
8492
9000
  /**
@@ -8726,7 +9234,7 @@ Use the read_attachment tool with an id to read a file's contents.`
8726
9234
  request.message = confirmText;
8727
9235
  }
8728
9236
  this.#confirmAbort = new AbortController();
8729
- const decision = requestConfirmation(this.#messages, request, {
9237
+ const decision = requestConfirmation(this.#ensureGroup(), request, {
8730
9238
  signal: this.#confirmAbort.signal,
8731
9239
  strings: this.#strings
8732
9240
  });
@@ -8734,6 +9242,7 @@ Use the read_attachment tool with an id to read a file's contents.`
8734
9242
  this.#messages.scrollTop = this.#messages.scrollHeight;
8735
9243
  const accepted = await decision;
8736
9244
  this.#confirmAbort = null;
9245
+ card.recordDecision(accepted ? "approved" : "declined");
8737
9246
  if (!accepted) {
8738
9247
  const message = this.#strings.declinedAction;
8739
9248
  card.settle(TOOL_CALL_STATUS.DECLINED, message);
@@ -8794,6 +9303,7 @@ Use the read_attachment tool with an id to read a file's contents.`
8794
9303
  const approved = this.approvalRenderer !== null ? await this.approvalRenderer(request, { signal }) : await requestApproval(this.#ensureGroup(), request, { signal, strings: this.#strings });
8795
9304
  this.#updateEmptyState();
8796
9305
  this.#messages.scrollTop = this.#messages.scrollHeight;
9306
+ card?.recordDecision(approved ? "approved" : "declined");
8797
9307
  if (approved) {
8798
9308
  responses[interrupt.id] = { status: "resolved", payload: { approved: true } };
8799
9309
  } else {
@@ -8999,7 +9509,7 @@ Use the read_attachment tool with an id to read a file's contents.`
8999
9509
  }
9000
9510
  const labelled = this.#resolveTool(call.name)?.parameters[X_SUMMARY_KEY];
9001
9511
  const summary = typeof labelled === "string" ? labelled : this.toolSummaries[call.name] ?? this.#toolCatalog[call.name] ?? prettifyToolName(call.name);
9002
- const card = new ToolCallCard(call.name, call.args, this.toolDisplay, summary, this.#strings);
9512
+ const card = new ToolCallCard(call.name, call.args, summary, this.#strings);
9003
9513
  this.#toolCards.set(call.id, card);
9004
9514
  this.#ensureGroup().appendChild(card.element);
9005
9515
  this.#updateEmptyState();
@@ -9059,7 +9569,7 @@ function setControlValue(el, value) {
9059
9569
  }
9060
9570
 
9061
9571
  // src/version.ts
9062
- var VERSION = "0.18.0";
9572
+ var VERSION = "0.20.0";
9063
9573
  export {
9064
9574
  ATTACHMENT_EVENT,
9065
9575
  AgUiChat,