@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/CHANGELOG.md +165 -1
- package/README.md +86 -4
- package/dist/ag-ui-web-component.bundle.js +256 -47
- package/dist/ag-ui-web-component.bundle.js.map +4 -4
- package/dist/core/ag_ui_chat.d.ts +5 -1
- package/dist/core/ag_ui_chat.d.ts.map +1 -1
- package/dist/core/agui_client.d.ts.map +1 -1
- package/dist/index.js +579 -69
- package/dist/index.js.map +4 -4
- package/dist/skills/parse_skills.d.ts.map +1 -1
- package/dist/skills/skill.d.ts +14 -4
- package/dist/skills/skill.d.ts.map +1 -1
- package/dist/ui/confirmation_card.d.ts +8 -2
- package/dist/ui/confirmation_card.d.ts.map +1 -1
- package/dist/ui/resize_handle.d.ts +81 -0
- package/dist/ui/resize_handle.d.ts.map +1 -0
- package/dist/ui/skills_menu.d.ts.map +1 -1
- package/dist/ui/styles.d.ts +1 -1
- package/dist/ui/styles.d.ts.map +1 -1
- package/dist/ui/tool_call_card.d.ts +26 -14
- package/dist/ui/tool_call_card.d.ts.map +1 -1
- package/dist/ui/ui_strings.d.ts +12 -4
- package/dist/ui/ui_strings.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/core/ag_ui_chat.ts +230 -10
- package/src/core/agui_client.ts +27 -1
- package/src/skills/parse_skills.ts +8 -2
- package/src/skills/skill.ts +14 -4
- package/src/ui/confirmation_card.ts +15 -5
- package/src/ui/resize_handle.ts +176 -0
- package/src/ui/skills_menu.ts +13 -1
- package/src/ui/styles.ts +217 -6
- package/src/ui/tool_call_card.ts +116 -58
- package/src/ui/ui_strings.ts +17 -5
- package/src/version.ts +1 -1
package/src/core/ag_ui_chat.ts
CHANGED
|
@@ -42,6 +42,12 @@ import {
|
|
|
42
42
|
requestQuestion,
|
|
43
43
|
} from "../ui/question_card.js";
|
|
44
44
|
import { renderMarkdown } from "../ui/render_markdown.js";
|
|
45
|
+
import {
|
|
46
|
+
createResizeHandle,
|
|
47
|
+
type ResizeAnchor,
|
|
48
|
+
type ResizeAxis,
|
|
49
|
+
type ResizeSize,
|
|
50
|
+
} from "../ui/resize_handle.js";
|
|
45
51
|
import { wrapWords } from "../ui/reveal_words.js";
|
|
46
52
|
import { renderRunNotice } from "../ui/run_notice.js";
|
|
47
53
|
import { SkillsMenu } from "../ui/skills_menu.js";
|
|
@@ -130,6 +136,9 @@ const CONNECT_TIME_ATTRIBUTES = [
|
|
|
130
136
|
/** Per-tab persistence key for the collapsed state (survives MPA reloads). */
|
|
131
137
|
const COLLAPSED_KEY = "ag-ui-chat:collapsed";
|
|
132
138
|
|
|
139
|
+
/** Per-tab persistence key for a dragged panel size. */
|
|
140
|
+
const SIZE_KEY = "ag-ui-chat:size";
|
|
141
|
+
|
|
133
142
|
/** Per-tab persistence key for the built-in theme toggle. */
|
|
134
143
|
const THEME_KEY = "ag-ui-chat:theme";
|
|
135
144
|
|
|
@@ -524,10 +533,16 @@ export class AgUiChat extends HTMLElement {
|
|
|
524
533
|
|
|
525
534
|
/** Attributes the element reacts to after it has been connected. */
|
|
526
535
|
static get observedAttributes(): string[] {
|
|
527
|
-
return ["title-text", ...CONNECT_TIME_ATTRIBUTES];
|
|
536
|
+
return ["title-text", "placement", ...CONNECT_TIME_ATTRIBUTES];
|
|
528
537
|
}
|
|
529
538
|
|
|
530
539
|
attributeChangedCallback(name: string, previous: string | null, value: string | null): void {
|
|
540
|
+
if (name === "placement") {
|
|
541
|
+
// Placement moves the panel, so the edges its layout holds still change
|
|
542
|
+
// with it. Deferred a frame so the new rules have applied.
|
|
543
|
+
requestAnimationFrame(() => this.#syncResizeAnchor());
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
531
546
|
if (name === "title-text") {
|
|
532
547
|
// `#strings` is the resolved table once connected, the English defaults
|
|
533
548
|
// before then.
|
|
@@ -760,7 +775,11 @@ export class AgUiChat extends HTMLElement {
|
|
|
760
775
|
|
|
761
776
|
/**
|
|
762
777
|
* How much detail tool-call cards show, from the `data-tool-display`
|
|
763
|
-
* attribute (`minimal` / `compact` / `full`). Defaults to `full`.
|
|
778
|
+
* attribute (`minimal` / `inline` / `compact` / `full`). Defaults to `full`.
|
|
779
|
+
*
|
|
780
|
+
* Applied by the shadow CSS from the attribute itself, so changing it
|
|
781
|
+
* restyles every card already in the transcript rather than only the ones
|
|
782
|
+
* built afterwards.
|
|
764
783
|
*/
|
|
765
784
|
get toolDisplay(): ToolDisplayMode {
|
|
766
785
|
const attr = this.getAttribute("data-tool-display");
|
|
@@ -783,6 +802,14 @@ export class AgUiChat extends HTMLElement {
|
|
|
783
802
|
// key read/write, so this instance doesn't share collapsed/theme/thread
|
|
784
803
|
// state with another on the same origin.
|
|
785
804
|
this.#storageNs = this.id !== "" ? this.id : this.endpoint;
|
|
805
|
+
// Restore a dragged size before the panel paints, so it does not snap from
|
|
806
|
+
// the placement default to the user's width on the first frame.
|
|
807
|
+
this.#applySize(this.#readSize());
|
|
808
|
+
// Position the grip at the corner this layout grows toward. Deferred to a
|
|
809
|
+
// frame so the host's own stylesheet has applied; re-measured on every drag
|
|
810
|
+
// anyway, so a wrong first guess costs a grip in the wrong corner and never
|
|
811
|
+
// a wrong resize.
|
|
812
|
+
requestAnimationFrame(() => this.#syncResizeAnchor());
|
|
786
813
|
// Resolve the string table before rendering any chrome (defaults are the
|
|
787
814
|
// floor; `data-strings` then the `strings` property layer over them).
|
|
788
815
|
this.#strings = mergeUiStrings({ ...this.#readStringOverrides(), ...this.strings });
|
|
@@ -1094,23 +1121,69 @@ export class AgUiChat extends HTMLElement {
|
|
|
1094
1121
|
this.#skillsMenu.setSkills([...merged.values()]);
|
|
1095
1122
|
}
|
|
1096
1123
|
|
|
1097
|
-
/**
|
|
1124
|
+
/**
|
|
1125
|
+
* Act on a picked skill.
|
|
1126
|
+
*
|
|
1127
|
+
* A skill that ships no `prompt` is **server-resolved**: the catalog carries
|
|
1128
|
+
* only its name and label, and picking it sends the bare `/name` token for
|
|
1129
|
+
* the agent to expand — from the harness `Skills` capability, or from the
|
|
1130
|
+
* server's own instructions. That is the shape to prefer, because the prompt
|
|
1131
|
+
* then never reaches the browser at all: a skill is often where a project's
|
|
1132
|
+
* internal workflow is written down most plainly, and a catalog endpoint is a
|
|
1133
|
+
* plain GET.
|
|
1134
|
+
*
|
|
1135
|
+
* A skill that does carry a `prompt` keeps the older behaviour — the client
|
|
1136
|
+
* fills its `{placeholder}`s from the page and sends (or pre-fills) the text.
|
|
1137
|
+
* Right for a user-facing convenience, and for placeholders only the page can
|
|
1138
|
+
* supply.
|
|
1139
|
+
*
|
|
1140
|
+
* Either way a pick now **sends**, rather than parking text in the composer
|
|
1141
|
+
* for a second click; `sendImmediately: false` opts back into pre-filling.
|
|
1142
|
+
*/
|
|
1098
1143
|
#applySkill(skill: Skill): void {
|
|
1144
|
+
if (skill.prompt === undefined) {
|
|
1145
|
+
this.#skillHint.hidden = true;
|
|
1146
|
+
void this.sendMessage(`/${skill.name}`);
|
|
1147
|
+
return;
|
|
1148
|
+
}
|
|
1099
1149
|
const { text, missing } = fillTemplate(skill.prompt, this.skillContext());
|
|
1100
1150
|
if (missing.length > 0) {
|
|
1151
|
+
// Hand the user something to work with rather than only a refusal. The
|
|
1152
|
+
// partially-filled template goes into the composer with its unresolved
|
|
1153
|
+
// `{placeholder}`s intact and the first one selected, so the next
|
|
1154
|
+
// keystroke replaces it. Blocking with a hint alone left whatever the
|
|
1155
|
+
// user had typed to open the palette — a lone "/" — sitting there, which
|
|
1156
|
+
// says nothing about what the skill wanted or how to give it.
|
|
1101
1157
|
this.#skillHint.textContent = this.#strings.skillNeeds
|
|
1102
1158
|
.replace("{title}", skill.title)
|
|
1103
1159
|
.replace("{fields}", missing.join(", "));
|
|
1104
1160
|
this.#skillHint.hidden = false;
|
|
1161
|
+
this.#input.value = text;
|
|
1162
|
+
this.#input.focus();
|
|
1163
|
+
this.#selectFirstPlaceholder(text);
|
|
1105
1164
|
return;
|
|
1106
1165
|
}
|
|
1107
1166
|
this.#skillHint.hidden = true;
|
|
1108
1167
|
this.#input.value = text;
|
|
1109
|
-
if (skill.sendImmediately ===
|
|
1110
|
-
void this.#submit();
|
|
1111
|
-
} else {
|
|
1168
|
+
if (skill.sendImmediately === false) {
|
|
1112
1169
|
this.#input.focus();
|
|
1170
|
+
return;
|
|
1113
1171
|
}
|
|
1172
|
+
void this.#submit();
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
/**
|
|
1176
|
+
* Put the caret on the first unresolved placeholder, selected.
|
|
1177
|
+
*
|
|
1178
|
+
* Typing then replaces it, which is the shortest path from "this skill needs
|
|
1179
|
+
* a topic" to a sendable prompt.
|
|
1180
|
+
*/
|
|
1181
|
+
#selectFirstPlaceholder(text: string): void {
|
|
1182
|
+
// The first surviving brace *is* the first unresolved placeholder — a
|
|
1183
|
+
// resolved one was substituted away — so this needs no search through the
|
|
1184
|
+
// missing keys and no not-found branch to defend.
|
|
1185
|
+
const start = text.indexOf("{");
|
|
1186
|
+
this.#input.setSelectionRange(start, text.indexOf("}", start) + 1);
|
|
1114
1187
|
}
|
|
1115
1188
|
|
|
1116
1189
|
/** Whether the widget is collapsed (reflected as the `collapsed` attribute). */
|
|
@@ -1164,6 +1237,113 @@ export class AgUiChat extends HTMLElement {
|
|
|
1164
1237
|
this.#syncThemeGlyph();
|
|
1165
1238
|
}
|
|
1166
1239
|
|
|
1240
|
+
/**
|
|
1241
|
+
* Which axes the current placement allows.
|
|
1242
|
+
*
|
|
1243
|
+
* A full-bleed layout is `100vw`/`100vh` by definition and cannot be resized
|
|
1244
|
+
* at all; a docked panel owns its height, leaving only its inner edge. Read
|
|
1245
|
+
* per interaction, because `placement` is a live attribute.
|
|
1246
|
+
*/
|
|
1247
|
+
#resizeAxis(): ResizeAxis {
|
|
1248
|
+
switch (this.getAttribute("placement")) {
|
|
1249
|
+
case "full":
|
|
1250
|
+
case "page":
|
|
1251
|
+
return "none";
|
|
1252
|
+
case "sidebar":
|
|
1253
|
+
case "side":
|
|
1254
|
+
return "width";
|
|
1255
|
+
default:
|
|
1256
|
+
return "both";
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
/**
|
|
1261
|
+
* Which edges the layout is holding still, by measuring rather than guessing.
|
|
1262
|
+
*
|
|
1263
|
+
* A resize has to be computed from the edge that does not move, and which
|
|
1264
|
+
* edge that is belongs to the **host's layout**, not to `placement`: a
|
|
1265
|
+
* floating panel is pinned bottom-right, while an embedded one goes wherever
|
|
1266
|
+
* the page's own CSS puts it — flex-start, flex-end, a grid cell. Mapping
|
|
1267
|
+
* placement to a corner got this wrong for any host that right-aligns the
|
|
1268
|
+
* element, and the symptom is bad enough to read as a broken control: the
|
|
1269
|
+
* panel shrinks when dragged outward, travelling by its opposite corner.
|
|
1270
|
+
*
|
|
1271
|
+
* So: nudge the size by a pixel, see which edges stayed put, and undo. One
|
|
1272
|
+
* forced reflow per drag, which is cheap next to being wrong.
|
|
1273
|
+
*/
|
|
1274
|
+
#measureAnchor(): ResizeAnchor {
|
|
1275
|
+
const before = this.getBoundingClientRect();
|
|
1276
|
+
const width = this.style.getPropertyValue("--ag-ui-width");
|
|
1277
|
+
const height = this.style.getPropertyValue("--ag-ui-height");
|
|
1278
|
+
this.#applySize({ width: before.width + 1, height: before.height + 1 });
|
|
1279
|
+
const after = this.getBoundingClientRect();
|
|
1280
|
+
// Restore exactly what was there, including "nothing" — leaving a probe
|
|
1281
|
+
// value behind would pin a panel that had been sizing itself.
|
|
1282
|
+
this.#restoreProperty("--ag-ui-width", width);
|
|
1283
|
+
this.#restoreProperty("--ag-ui-height", height);
|
|
1284
|
+
return {
|
|
1285
|
+
x: Math.abs(after.left - before.left) < 0.5 ? "left" : "right",
|
|
1286
|
+
y: Math.abs(after.top - before.top) < 0.5 ? "top" : "bottom",
|
|
1287
|
+
};
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
/** Stamp the measured anchor so the shadow CSS can place the grip. */
|
|
1291
|
+
#syncResizeAnchor(): void {
|
|
1292
|
+
if (!this.#connected) {
|
|
1293
|
+
return;
|
|
1294
|
+
}
|
|
1295
|
+
const anchor = this.#measureAnchor();
|
|
1296
|
+
this.setAttribute("data-resize-anchor", `${anchor.y}-${anchor.x}`);
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
/** Put a custom property back to a previous value, or remove it if there was none. */
|
|
1300
|
+
#restoreProperty(name: string, value: string): void {
|
|
1301
|
+
if (value === "") {
|
|
1302
|
+
this.style.removeProperty(name);
|
|
1303
|
+
return;
|
|
1304
|
+
}
|
|
1305
|
+
this.style.setProperty(name, value);
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
/**
|
|
1309
|
+
* Write a dragged size onto the host as custom properties.
|
|
1310
|
+
*
|
|
1311
|
+
* Properties rather than inline `width` / `height`: the placement rules set
|
|
1312
|
+
* those same properties, so an inline dimension would outrank them and a
|
|
1313
|
+
* panel dragged while floating would keep that width after switching to
|
|
1314
|
+
* fullscreen.
|
|
1315
|
+
*/
|
|
1316
|
+
#applySize(size: ResizeSize): void {
|
|
1317
|
+
if (size.width !== undefined) {
|
|
1318
|
+
this.style.setProperty("--ag-ui-width", `${size.width}px`);
|
|
1319
|
+
}
|
|
1320
|
+
if (size.height !== undefined) {
|
|
1321
|
+
this.style.setProperty("--ag-ui-height", `${size.height}px`);
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
/** Persist a dragged size per tab, alongside the collapsed/theme preferences. */
|
|
1326
|
+
#persistSize(size: ResizeSize): void {
|
|
1327
|
+
const stored = { ...this.#readSize(), ...size };
|
|
1328
|
+
sessionStorage.setItem(this.#storageKey(SIZE_KEY), JSON.stringify(stored));
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
/** The persisted size for this instance, or an empty record. */
|
|
1332
|
+
#readSize(): ResizeSize {
|
|
1333
|
+
const raw = this.#readScopedItem(SIZE_KEY);
|
|
1334
|
+
if (raw === null) {
|
|
1335
|
+
return {};
|
|
1336
|
+
}
|
|
1337
|
+
try {
|
|
1338
|
+
const parsed: unknown = JSON.parse(raw);
|
|
1339
|
+
return typeof parsed === "object" && parsed !== null ? (parsed as ResizeSize) : {};
|
|
1340
|
+
} catch {
|
|
1341
|
+
// A corrupt entry is not worth failing a mount over; fall back to the
|
|
1342
|
+
// placement's own size.
|
|
1343
|
+
return {};
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1167
1347
|
/** This instance's namespaced form of an origin-scoped storage key. */
|
|
1168
1348
|
#storageKey(base: string): string {
|
|
1169
1349
|
return this.#storageNs === "" ? base : `${base}:${this.#storageNs}`;
|
|
@@ -1648,10 +1828,36 @@ export class AgUiChat extends HTMLElement {
|
|
|
1648
1828
|
this.#rail.append(this.#iconElement("launcher", "launcher-icon", "💬"));
|
|
1649
1829
|
this.#rail.addEventListener("click", () => this.setCollapsed(false));
|
|
1650
1830
|
|
|
1831
|
+
this.#chat.append(
|
|
1832
|
+
createResizeHandle({
|
|
1833
|
+
axis: () => this.#resizeAxis(),
|
|
1834
|
+
anchor: () => this.#measureAnchor(),
|
|
1835
|
+
rect: () => this.getBoundingClientRect(),
|
|
1836
|
+
apply: (size) => this.#applySize(size),
|
|
1837
|
+
commit: (size) => {
|
|
1838
|
+
this.#persistSize(size);
|
|
1839
|
+
// Re-stamp after the drag: a host whose layout changed underneath us
|
|
1840
|
+
// would otherwise keep the grip in the old corner, which reads as the
|
|
1841
|
+
// control being in the wrong place even though the drag was right.
|
|
1842
|
+
this.#syncResizeAnchor();
|
|
1843
|
+
},
|
|
1844
|
+
label: this.#strings.resizePanel,
|
|
1845
|
+
}),
|
|
1846
|
+
);
|
|
1651
1847
|
this.#root.append(style, this.#chat, this.#rail);
|
|
1652
1848
|
}
|
|
1653
1849
|
|
|
1654
|
-
/**
|
|
1850
|
+
/**
|
|
1851
|
+
* Build a header control button: a named slot a host can project markup into,
|
|
1852
|
+
* with the built-in glyph as the slot's fallback.
|
|
1853
|
+
*
|
|
1854
|
+
* The glyph used to be the button's own `textContent`, which left a host able
|
|
1855
|
+
* to restyle the control through its `part` but unable to replace it — a CSS
|
|
1856
|
+
* `content` override could swap one character for another, and nothing could
|
|
1857
|
+
* supply a brand `<img>` or `<svg>`. This is the same slot-with-fallback
|
|
1858
|
+
* idiom the header icon already uses, so existing embeds render exactly as
|
|
1859
|
+
* before.
|
|
1860
|
+
*/
|
|
1655
1861
|
#headerButton(modifier: string, label: string, glyph: string): HTMLButtonElement {
|
|
1656
1862
|
const button = document.createElement("button");
|
|
1657
1863
|
button.type = "button";
|
|
@@ -1659,7 +1865,10 @@ export class AgUiChat extends HTMLElement {
|
|
|
1659
1865
|
button.setAttribute("part", `header-button ${modifier}-button`);
|
|
1660
1866
|
button.title = label;
|
|
1661
1867
|
button.setAttribute("aria-label", label);
|
|
1662
|
-
|
|
1868
|
+
const slot = document.createElement("slot");
|
|
1869
|
+
slot.name = `icon-${modifier}`;
|
|
1870
|
+
slot.append(document.createTextNode(glyph));
|
|
1871
|
+
button.append(slot);
|
|
1663
1872
|
return button;
|
|
1664
1873
|
}
|
|
1665
1874
|
|
|
@@ -1962,7 +2171,11 @@ export class AgUiChat extends HTMLElement {
|
|
|
1962
2171
|
// The run loop is suspended on this card; a Stop while it's open aborts
|
|
1963
2172
|
// the controller, resolving the decision as declined.
|
|
1964
2173
|
this.#confirmAbort = new AbortController();
|
|
1965
|
-
|
|
2174
|
+
// Into the turn's answer group, like every other inline card. Appending
|
|
2175
|
+
// to the message list made it a sibling *after* the group, so anything
|
|
2176
|
+
// that streamed afterwards rendered above it and the prompt drifted to
|
|
2177
|
+
// the foot of the turn no matter when it was asked.
|
|
2178
|
+
const decision = requestConfirmation(this.#ensureGroup(), request, {
|
|
1966
2179
|
signal: this.#confirmAbort.signal,
|
|
1967
2180
|
strings: this.#strings,
|
|
1968
2181
|
});
|
|
@@ -1970,6 +2183,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
1970
2183
|
this.#messages.scrollTop = this.#messages.scrollHeight;
|
|
1971
2184
|
const accepted = await decision;
|
|
1972
2185
|
this.#confirmAbort = null;
|
|
2186
|
+
card.recordDecision(accepted ? "approved" : "declined");
|
|
1973
2187
|
if (!accepted) {
|
|
1974
2188
|
const message = this.#strings.declinedAction;
|
|
1975
2189
|
card.settle(TOOL_CALL_STATUS.DECLINED, message);
|
|
@@ -2046,6 +2260,12 @@ export class AgUiChat extends HTMLElement {
|
|
|
2046
2260
|
: await requestApproval(this.#ensureGroup(), request, { signal, strings: this.#strings });
|
|
2047
2261
|
this.#updateEmptyState();
|
|
2048
2262
|
this.#messages.scrollTop = this.#messages.scrollHeight;
|
|
2263
|
+
// Same annotation as the client-side confirmation gate. Without it the
|
|
2264
|
+
// two gates read differently for the same act: a locally-confirmed call
|
|
2265
|
+
// said who let it through and a server-gated one said nothing, which is
|
|
2266
|
+
// backwards, since the server-side gate is the one guarding the tools
|
|
2267
|
+
// that actually run on the backend.
|
|
2268
|
+
card?.recordDecision(approved ? "approved" : "declined");
|
|
2049
2269
|
if (approved) {
|
|
2050
2270
|
responses[interrupt.id] = { status: "resolved", payload: { approved: true } };
|
|
2051
2271
|
} else {
|
|
@@ -2300,7 +2520,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
2300
2520
|
: (this.toolSummaries[call.name] ??
|
|
2301
2521
|
this.#toolCatalog[call.name] ??
|
|
2302
2522
|
prettifyToolName(call.name));
|
|
2303
|
-
const card = new ToolCallCard(call.name, call.args,
|
|
2523
|
+
const card = new ToolCallCard(call.name, call.args, summary, this.#strings);
|
|
2304
2524
|
this.#toolCards.set(call.id, card);
|
|
2305
2525
|
this.#ensureGroup().appendChild(card.element);
|
|
2306
2526
|
this.#updateEmptyState();
|
package/src/core/agui_client.ts
CHANGED
|
@@ -181,6 +181,13 @@ export class AgUiClient {
|
|
|
181
181
|
readonly #executeTool: ExecuteTool | null;
|
|
182
182
|
readonly #resolveInterrupts: ResolveInterrupts | null;
|
|
183
183
|
readonly #onPersist: (messages: readonly Message[]) => void;
|
|
184
|
+
/**
|
|
185
|
+
* Message ids the server has already closed, so a reuse can be reported.
|
|
186
|
+
*
|
|
187
|
+
* Per client rather than per run: the merge happens across runs, which is the
|
|
188
|
+
* case a per-run set would miss entirely.
|
|
189
|
+
*/
|
|
190
|
+
readonly #closedMessageIds = new Set<string>();
|
|
184
191
|
readonly #connectionLostMessage: string;
|
|
185
192
|
// Set by cancel(); reset at the top of each #run(). Checked by the loop so
|
|
186
193
|
// a cancel between frontend-tool rounds doesn't start another round.
|
|
@@ -397,14 +404,33 @@ export class AgUiClient {
|
|
|
397
404
|
|
|
398
405
|
#buildSubscriber(pending: AgUiToolCall[], runState: RunState): AgentSubscriber {
|
|
399
406
|
const h = this.#handlers;
|
|
407
|
+
const closed = this.#closedMessageIds;
|
|
400
408
|
return {
|
|
401
409
|
onRunInitialized() {
|
|
402
410
|
h.onRunStart();
|
|
403
411
|
},
|
|
412
|
+
onTextMessageStartEvent({ event }) {
|
|
413
|
+
// A server that reuses a message id gets its two answers merged into
|
|
414
|
+
// one transcript entry, silently, and that merged entry is what gets
|
|
415
|
+
// persisted. The protocol has no rule to enforce here and refusing the
|
|
416
|
+
// event would be worse than the merge, so this warns and continues —
|
|
417
|
+
// but it should not be silent, because the corruption outlives the
|
|
418
|
+
// session and reads as a client bug. Found by a demo harness doing
|
|
419
|
+
// exactly this.
|
|
420
|
+
if (closed.has(event.messageId)) {
|
|
421
|
+
console.warn(
|
|
422
|
+
`<ag-ui-chat>: the server reused message id "${event.messageId}", which was ` +
|
|
423
|
+
"already closed. Its content will be appended to that earlier message rather " +
|
|
424
|
+
"than starting a new one, and the merged result is what gets persisted. " +
|
|
425
|
+
"Issue a fresh id per message.",
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
},
|
|
404
429
|
onTextMessageContentEvent({ textMessageBuffer }) {
|
|
405
430
|
h.onTextDelta(textMessageBuffer);
|
|
406
431
|
},
|
|
407
|
-
onTextMessageEndEvent({ textMessageBuffer }) {
|
|
432
|
+
onTextMessageEndEvent({ event, textMessageBuffer }) {
|
|
433
|
+
closed.add(event.messageId);
|
|
408
434
|
h.onTextEnd(textMessageBuffer);
|
|
409
435
|
},
|
|
410
436
|
onToolCallEndEvent({ event, toolCallName, toolCallArgs }) {
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import type { Skill } from "./skill.js";
|
|
2
2
|
|
|
3
|
-
/**
|
|
3
|
+
/**
|
|
4
|
+
* Whether ``value`` has the required string fields of a {@link Skill}.
|
|
5
|
+
*
|
|
6
|
+
* `prompt` is optional and must stay so: a server-resolved skill deliberately
|
|
7
|
+
* omits it, and requiring it here would silently drop exactly the skills whose
|
|
8
|
+
* wording was kept off the client.
|
|
9
|
+
*/
|
|
4
10
|
function isSkill(value: unknown): value is Skill {
|
|
5
11
|
if (typeof value !== "object" || value === null) {
|
|
6
12
|
return false;
|
|
@@ -9,7 +15,7 @@ function isSkill(value: unknown): value is Skill {
|
|
|
9
15
|
return (
|
|
10
16
|
typeof record["name"] === "string" &&
|
|
11
17
|
typeof record["title"] === "string" &&
|
|
12
|
-
typeof record["prompt"] === "string"
|
|
18
|
+
(record["prompt"] === undefined || typeof record["prompt"] === "string")
|
|
13
19
|
);
|
|
14
20
|
}
|
|
15
21
|
|
package/src/skills/skill.ts
CHANGED
|
@@ -12,11 +12,21 @@ export interface Skill {
|
|
|
12
12
|
/** Secondary line shown in the palette. */
|
|
13
13
|
readonly description?: string;
|
|
14
14
|
/**
|
|
15
|
-
* The prompt
|
|
16
|
-
*
|
|
15
|
+
* The prompt to send. May contain `{placeholder}`s filled from the host's
|
|
16
|
+
* skill context before send; an unfilled placeholder blocks send.
|
|
17
|
+
*
|
|
18
|
+
* **Omit it to keep the prompt on the server.** The skill then sends the bare
|
|
19
|
+
* `/name` token and the agent resolves what it means, so the wording never
|
|
20
|
+
* reaches the browser — worth preferring for anything internal, since a
|
|
21
|
+
* fetched catalog is a plain GET and an embedded one sits in the page source.
|
|
22
|
+
*/
|
|
23
|
+
readonly prompt?: string;
|
|
24
|
+
/**
|
|
25
|
+
* Set `false` to pre-fill the composer instead of sending on pick. Only
|
|
26
|
+
* meaningful for a skill that carries its own `prompt`; a server-resolved one
|
|
27
|
+
* always sends. Defaults to sending — a chip that needs a second click to do
|
|
28
|
+
* anything is a two-step shortcut.
|
|
17
29
|
*/
|
|
18
|
-
readonly prompt: string;
|
|
19
|
-
/** Send immediately on pick instead of pre-filling the input (default false). */
|
|
20
30
|
readonly sendImmediately?: boolean;
|
|
21
31
|
/** Also surface this skill as a chip (default false; the palette shows all). */
|
|
22
32
|
readonly chip?: boolean;
|
|
@@ -40,8 +40,14 @@ export interface ConfirmationOptions {
|
|
|
40
40
|
* Unlike a modal overlay, the card lives in the transcript right where the
|
|
41
41
|
* action is — it reads naturally after the assistant's explanation and never
|
|
42
42
|
* steals focus from the page. Resolves ``true`` on confirm, ``false`` on
|
|
43
|
-
* cancel.
|
|
44
|
-
*
|
|
43
|
+
* cancel.
|
|
44
|
+
*
|
|
45
|
+
* **Answering it removes it.** A prompt and a record are two different objects:
|
|
46
|
+
* the prompt owns the user's attention until it is answered, and the record of
|
|
47
|
+
* what was decided belongs in the transcript, in order, scrolling with
|
|
48
|
+
* everything else. The tool card this gates is that record — it settles to
|
|
49
|
+
* `done` or `declined` and carries the decision. Leaving the spent form in
|
|
50
|
+
* place made an answered question read as outstanding.
|
|
45
51
|
*/
|
|
46
52
|
export function requestConfirmation(
|
|
47
53
|
host: Node & ParentNode,
|
|
@@ -66,6 +72,8 @@ export function requestConfirmation(
|
|
|
66
72
|
args.className = "confirm-args";
|
|
67
73
|
args.setAttribute("part", "confirm-args");
|
|
68
74
|
args.textContent = JSON.stringify(request.args, null, 2);
|
|
75
|
+
// A call with no arguments rendered the string "{}" in a box of its own.
|
|
76
|
+
args.hidden = Object.keys(request.args).length === 0;
|
|
69
77
|
|
|
70
78
|
const actions = document.createElement("div");
|
|
71
79
|
actions.className = "confirm-actions";
|
|
@@ -80,9 +88,11 @@ export function requestConfirmation(
|
|
|
80
88
|
return;
|
|
81
89
|
}
|
|
82
90
|
settled = true;
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
91
|
+
// The prompt leaves once it has been answered. What stays is the tool
|
|
92
|
+
// card next to it, which settles to the outcome and scrolls with the rest
|
|
93
|
+
// of the transcript -- a record of the action rather than a spent form
|
|
94
|
+
// sitting there as a standing reminder of one.
|
|
95
|
+
card.remove();
|
|
86
96
|
resolve(accepted);
|
|
87
97
|
};
|
|
88
98
|
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/** Which edges the layout holds still while the panel changes size. */
|
|
2
|
+
export interface ResizeAnchor {
|
|
3
|
+
/** The horizontal edge that does not move. */
|
|
4
|
+
readonly x: "left" | "right";
|
|
5
|
+
/** The vertical edge that does not move. */
|
|
6
|
+
readonly y: "top" | "bottom";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* What the current placement allows: both axes, width only, or nothing.
|
|
11
|
+
*
|
|
12
|
+
* Which *corner* the grip sits on is not part of this — that follows the host's
|
|
13
|
+
* layout, which the component measures rather than assumes.
|
|
14
|
+
*/
|
|
15
|
+
export type ResizeAxis = "none" | "width" | "both";
|
|
16
|
+
|
|
17
|
+
/** Persisted size, in CSS pixels. Either axis may be absent. */
|
|
18
|
+
export interface ResizeSize {
|
|
19
|
+
readonly width?: number;
|
|
20
|
+
readonly height?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The panel's position on screen at the moment a drag starts. */
|
|
24
|
+
export interface PanelRect {
|
|
25
|
+
readonly left: number;
|
|
26
|
+
readonly top: number;
|
|
27
|
+
readonly right: number;
|
|
28
|
+
readonly bottom: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** What the handle needs from its host to do its job. */
|
|
32
|
+
export interface ResizeOptions {
|
|
33
|
+
/**
|
|
34
|
+
* Which axes the current placement allows, read **per interaction**.
|
|
35
|
+
*
|
|
36
|
+
* A getter rather than a value because `placement` is a live attribute: read
|
|
37
|
+
* once at construction, a handle built while floating kept its axes after the
|
|
38
|
+
* host switched to a docked or full-bleed layout.
|
|
39
|
+
*/
|
|
40
|
+
readonly axis: () => ResizeAxis;
|
|
41
|
+
/**
|
|
42
|
+
* Which edges the layout is holding still, measured at the moment of the
|
|
43
|
+
* drag.
|
|
44
|
+
*
|
|
45
|
+
* **Measured, not derived from `placement`.** A floating panel is pinned
|
|
46
|
+
* bottom-right and an embedded one goes wherever the host's own CSS puts it —
|
|
47
|
+
* the demo playground drops it in a right-aligned flex slot, so "embedded"
|
|
48
|
+
* alone says nothing. Guessing produced a panel that shrank when dragged
|
|
49
|
+
* outward and travelled by its opposite corner.
|
|
50
|
+
*/
|
|
51
|
+
readonly anchor: () => ResizeAnchor;
|
|
52
|
+
/** The panel's current bounding box. */
|
|
53
|
+
readonly rect: () => PanelRect;
|
|
54
|
+
/** Apply a size (the host writes the custom properties). */
|
|
55
|
+
readonly apply: (size: ResizeSize) => void;
|
|
56
|
+
/** Called once per completed drag, for persistence. */
|
|
57
|
+
readonly commit: (size: ResizeSize) => void;
|
|
58
|
+
/** Accessible label. */
|
|
59
|
+
readonly label: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Smallest usable panel; below this the composer and header collide. */
|
|
63
|
+
const MIN_WIDTH = 280;
|
|
64
|
+
const MIN_HEIGHT = 240;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A drag handle that resizes the chat panel.
|
|
68
|
+
*
|
|
69
|
+
* The size was previously fixed by whatever the host set `--ag-ui-width` /
|
|
70
|
+
* `--ag-ui-height` to: themeable by the page, immovable by the person reading a
|
|
71
|
+
* long answer in a 380px column.
|
|
72
|
+
*
|
|
73
|
+
* **The new size is measured from the edge that is not moving, never from a
|
|
74
|
+
* delta**, and which edge that is is **measured rather than assumed**. A
|
|
75
|
+
* floating panel is pinned bottom-right; an embedded one goes wherever the
|
|
76
|
+
* host's CSS puts it, so `placement` does not answer the question. Getting it
|
|
77
|
+
* wrong is very visible: the panel shrinks when dragged outward and travels by
|
|
78
|
+
* its opposite corner.
|
|
79
|
+
*
|
|
80
|
+
* **It writes the custom properties rather than inline `width` / `height`.**
|
|
81
|
+
* The placement rules set those same properties, so an inline dimension would
|
|
82
|
+
* fight them — a sidebar would keep a dragged width after switching to
|
|
83
|
+
* fullscreen. Writing the property means placement still has the final say.
|
|
84
|
+
*
|
|
85
|
+
* The axes are read per interaction, so switching `placement` at runtime takes
|
|
86
|
+
* effect immediately rather than leaving whichever ones the element happened to
|
|
87
|
+
* mount with.
|
|
88
|
+
*/
|
|
89
|
+
export function createResizeHandle(options: ResizeOptions): HTMLDivElement {
|
|
90
|
+
const handle = document.createElement("div");
|
|
91
|
+
handle.className = "resize-handle";
|
|
92
|
+
handle.setAttribute("part", "resize-handle");
|
|
93
|
+
handle.setAttribute("role", "separator");
|
|
94
|
+
handle.setAttribute("aria-label", options.label);
|
|
95
|
+
handle.tabIndex = 0;
|
|
96
|
+
|
|
97
|
+
/** The size implied by a pointer at (x, y), given which edges are pinned. */
|
|
98
|
+
const sizeAt = (
|
|
99
|
+
axis: ResizeAxis,
|
|
100
|
+
anchor: ResizeAnchor,
|
|
101
|
+
rect: PanelRect,
|
|
102
|
+
x: number,
|
|
103
|
+
y: number,
|
|
104
|
+
): ResizeSize => {
|
|
105
|
+
const width = anchor.x === "right" ? rect.right - x : x - rect.left;
|
|
106
|
+
const clamped: ResizeSize = { width: Math.max(MIN_WIDTH, width) };
|
|
107
|
+
if (axis !== "both") {
|
|
108
|
+
return clamped;
|
|
109
|
+
}
|
|
110
|
+
const height = anchor.y === "bottom" ? rect.bottom - y : y - rect.top;
|
|
111
|
+
return { ...clamped, height: Math.max(MIN_HEIGHT, height) };
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
handle.addEventListener("pointerdown", (event: PointerEvent) => {
|
|
115
|
+
const axis = options.axis();
|
|
116
|
+
if (axis === "none") {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
// Captured once: the fixed edges cannot move during the drag, and reading
|
|
120
|
+
// them live would chase the panel as it resizes.
|
|
121
|
+
const anchor = options.anchor();
|
|
122
|
+
const rect = options.rect();
|
|
123
|
+
|
|
124
|
+
const onMove = (move: PointerEvent): void => {
|
|
125
|
+
options.apply(sizeAt(axis, anchor, rect, move.clientX, move.clientY));
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const onUp = (up: PointerEvent): void => {
|
|
129
|
+
window.removeEventListener("pointermove", onMove);
|
|
130
|
+
window.removeEventListener("pointerup", onUp);
|
|
131
|
+
handle.removeAttribute("data-dragging");
|
|
132
|
+
options.commit(sizeAt(axis, anchor, rect, up.clientX, up.clientY));
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
handle.setAttribute("data-dragging", "true");
|
|
136
|
+
// Listeners on `window`, not the handle: a fast drag outruns the pointer
|
|
137
|
+
// and would otherwise strand the panel mid-resize with no pointerup.
|
|
138
|
+
window.addEventListener("pointermove", onMove);
|
|
139
|
+
window.addEventListener("pointerup", onUp);
|
|
140
|
+
event.preventDefault();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// Keyboard parity. A pointer-only resize is unreachable without a mouse, and
|
|
144
|
+
// this control has no equivalent elsewhere in the UI.
|
|
145
|
+
handle.addEventListener("keydown", (event: KeyboardEvent) => {
|
|
146
|
+
const axis = options.axis();
|
|
147
|
+
if (axis === "none") {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const anchor = options.anchor();
|
|
151
|
+
const rect = options.rect();
|
|
152
|
+
const step = event.shiftKey ? 64 : 16;
|
|
153
|
+
// An arrow moves the grip, and whether that grows or shrinks depends on
|
|
154
|
+
// which side the grip is on — the same asymmetry the pointer path handles.
|
|
155
|
+
const outward = anchor.x === "right" ? -1 : 1;
|
|
156
|
+
const width = rect.right - rect.left;
|
|
157
|
+
const height = rect.bottom - rect.top;
|
|
158
|
+
let next: ResizeSize | null = null;
|
|
159
|
+
if (event.key === "ArrowLeft") {
|
|
160
|
+
next = { width: Math.max(MIN_WIDTH, width - step * outward) };
|
|
161
|
+
} else if (event.key === "ArrowRight") {
|
|
162
|
+
next = { width: Math.max(MIN_WIDTH, width + step * outward) };
|
|
163
|
+
} else if (axis === "both" && (event.key === "ArrowUp" || event.key === "ArrowDown")) {
|
|
164
|
+
const grow = event.key === (anchor.y === "bottom" ? "ArrowUp" : "ArrowDown");
|
|
165
|
+
next = { height: Math.max(MIN_HEIGHT, height + (grow ? step : -step)) };
|
|
166
|
+
}
|
|
167
|
+
if (next === null) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
event.preventDefault();
|
|
171
|
+
options.apply(next);
|
|
172
|
+
options.commit(next);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
return handle;
|
|
176
|
+
}
|