@artooi/ag-ui-web-component 0.19.0 → 0.20.1

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.
@@ -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,20 @@ 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
+ // A placement owns the axes it fixes, so hand those back before anything
542
+ // else: a size dragged under the previous placement would otherwise sit
543
+ // inline and outrank the new one.
544
+ this.#releaseOwnedAxes();
545
+ // Placement also moves the panel, so the edges its layout holds still
546
+ // change with it. Deferred a frame so the new rules have applied.
547
+ requestAnimationFrame(() => this.#syncResizeAnchor());
548
+ return;
549
+ }
531
550
  if (name === "title-text") {
532
551
  // `#strings` is the resolved table once connected, the English defaults
533
552
  // before then.
@@ -787,6 +806,14 @@ export class AgUiChat extends HTMLElement {
787
806
  // key read/write, so this instance doesn't share collapsed/theme/thread
788
807
  // state with another on the same origin.
789
808
  this.#storageNs = this.id !== "" ? this.id : this.endpoint;
809
+ // Restore a dragged size before the panel paints, so it does not snap from
810
+ // the placement default to the user's width on the first frame.
811
+ this.#applySize(this.#readSize());
812
+ // Position the grip at the corner this layout grows toward. Deferred to a
813
+ // frame so the host's own stylesheet has applied; re-measured on every drag
814
+ // anyway, so a wrong first guess costs a grip in the wrong corner and never
815
+ // a wrong resize.
816
+ requestAnimationFrame(() => this.#syncResizeAnchor());
790
817
  // Resolve the string table before rendering any chrome (defaults are the
791
818
  // floor; `data-strings` then the `strings` property layer over them).
792
819
  this.#strings = mergeUiStrings({ ...this.#readStringOverrides(), ...this.strings });
@@ -1098,23 +1125,69 @@ export class AgUiChat extends HTMLElement {
1098
1125
  this.#skillsMenu.setSkills([...merged.values()]);
1099
1126
  }
1100
1127
 
1101
- /** Pre-fill (or send) a picked skill's prompt, filling its placeholders. */
1128
+ /**
1129
+ * Act on a picked skill.
1130
+ *
1131
+ * A skill that ships no `prompt` is **server-resolved**: the catalog carries
1132
+ * only its name and label, and picking it sends the bare `/name` token for
1133
+ * the agent to expand — from the harness `Skills` capability, or from the
1134
+ * server's own instructions. That is the shape to prefer, because the prompt
1135
+ * then never reaches the browser at all: a skill is often where a project's
1136
+ * internal workflow is written down most plainly, and a catalog endpoint is a
1137
+ * plain GET.
1138
+ *
1139
+ * A skill that does carry a `prompt` keeps the older behaviour — the client
1140
+ * fills its `{placeholder}`s from the page and sends (or pre-fills) the text.
1141
+ * Right for a user-facing convenience, and for placeholders only the page can
1142
+ * supply.
1143
+ *
1144
+ * Either way a pick now **sends**, rather than parking text in the composer
1145
+ * for a second click; `sendImmediately: false` opts back into pre-filling.
1146
+ */
1102
1147
  #applySkill(skill: Skill): void {
1148
+ if (skill.prompt === undefined) {
1149
+ this.#skillHint.hidden = true;
1150
+ void this.sendMessage(`/${skill.name}`);
1151
+ return;
1152
+ }
1103
1153
  const { text, missing } = fillTemplate(skill.prompt, this.skillContext());
1104
1154
  if (missing.length > 0) {
1155
+ // Hand the user something to work with rather than only a refusal. The
1156
+ // partially-filled template goes into the composer with its unresolved
1157
+ // `{placeholder}`s intact and the first one selected, so the next
1158
+ // keystroke replaces it. Blocking with a hint alone left whatever the
1159
+ // user had typed to open the palette — a lone "/" — sitting there, which
1160
+ // says nothing about what the skill wanted or how to give it.
1105
1161
  this.#skillHint.textContent = this.#strings.skillNeeds
1106
1162
  .replace("{title}", skill.title)
1107
1163
  .replace("{fields}", missing.join(", "));
1108
1164
  this.#skillHint.hidden = false;
1165
+ this.#input.value = text;
1166
+ this.#input.focus();
1167
+ this.#selectFirstPlaceholder(text);
1109
1168
  return;
1110
1169
  }
1111
1170
  this.#skillHint.hidden = true;
1112
1171
  this.#input.value = text;
1113
- if (skill.sendImmediately === true) {
1114
- void this.#submit();
1115
- } else {
1172
+ if (skill.sendImmediately === false) {
1116
1173
  this.#input.focus();
1174
+ return;
1117
1175
  }
1176
+ void this.#submit();
1177
+ }
1178
+
1179
+ /**
1180
+ * Put the caret on the first unresolved placeholder, selected.
1181
+ *
1182
+ * Typing then replaces it, which is the shortest path from "this skill needs
1183
+ * a topic" to a sendable prompt.
1184
+ */
1185
+ #selectFirstPlaceholder(text: string): void {
1186
+ // The first surviving brace *is* the first unresolved placeholder — a
1187
+ // resolved one was substituted away — so this needs no search through the
1188
+ // missing keys and no not-found branch to defend.
1189
+ const start = text.indexOf("{");
1190
+ this.#input.setSelectionRange(start, text.indexOf("}", start) + 1);
1118
1191
  }
1119
1192
 
1120
1193
  /** Whether the widget is collapsed (reflected as the `collapsed` attribute). */
@@ -1168,6 +1241,138 @@ export class AgUiChat extends HTMLElement {
1168
1241
  this.#syncThemeGlyph();
1169
1242
  }
1170
1243
 
1244
+ /**
1245
+ * Which axes the current placement allows.
1246
+ *
1247
+ * A full-bleed layout is `100vw`/`100vh` by definition and cannot be resized
1248
+ * at all; a docked panel owns its height, leaving only its inner edge. Read
1249
+ * per interaction, because `placement` is a live attribute.
1250
+ */
1251
+ #resizeAxis(): ResizeAxis {
1252
+ switch (this.getAttribute("placement")) {
1253
+ case "full":
1254
+ case "page":
1255
+ return "none";
1256
+ case "sidebar":
1257
+ case "side":
1258
+ return "width";
1259
+ default:
1260
+ return "both";
1261
+ }
1262
+ }
1263
+
1264
+ /**
1265
+ * Which edges the layout is holding still, by measuring rather than guessing.
1266
+ *
1267
+ * A resize has to be computed from the edge that does not move, and which
1268
+ * edge that is belongs to the **host's layout**, not to `placement`: a
1269
+ * floating panel is pinned bottom-right, while an embedded one goes wherever
1270
+ * the page's own CSS puts it — flex-start, flex-end, a grid cell. Mapping
1271
+ * placement to a corner got this wrong for any host that right-aligns the
1272
+ * element, and the symptom is bad enough to read as a broken control: the
1273
+ * panel shrinks when dragged outward, travelling by its opposite corner.
1274
+ *
1275
+ * So: nudge the size by a pixel, see which edges stayed put, and undo. One
1276
+ * forced reflow per drag, which is cheap next to being wrong.
1277
+ */
1278
+ #measureAnchor(): ResizeAnchor {
1279
+ const before = this.getBoundingClientRect();
1280
+ const width = this.style.getPropertyValue("--ag-ui-width");
1281
+ const height = this.style.getPropertyValue("--ag-ui-height");
1282
+ this.#applySize({ width: before.width + 1, height: before.height + 1 });
1283
+ const after = this.getBoundingClientRect();
1284
+ // Restore exactly what was there, including "nothing" — leaving a probe
1285
+ // value behind would pin a panel that had been sizing itself.
1286
+ this.#restoreProperty("--ag-ui-width", width);
1287
+ this.#restoreProperty("--ag-ui-height", height);
1288
+ return {
1289
+ x: Math.abs(after.left - before.left) < 0.5 ? "left" : "right",
1290
+ y: Math.abs(after.top - before.top) < 0.5 ? "top" : "bottom",
1291
+ };
1292
+ }
1293
+
1294
+ /** Stamp the measured anchor so the shadow CSS can place the grip. */
1295
+ #syncResizeAnchor(): void {
1296
+ if (!this.#connected) {
1297
+ return;
1298
+ }
1299
+ const anchor = this.#measureAnchor();
1300
+ this.setAttribute("data-resize-anchor", `${anchor.y}-${anchor.x}`);
1301
+ }
1302
+
1303
+ /** Put a custom property back to a previous value, or remove it if there was none. */
1304
+ #restoreProperty(name: string, value: string): void {
1305
+ if (value === "") {
1306
+ this.style.removeProperty(name);
1307
+ return;
1308
+ }
1309
+ this.style.setProperty(name, value);
1310
+ }
1311
+
1312
+ /**
1313
+ * Write a dragged size onto the host, on the axes this placement leaves free.
1314
+ *
1315
+ * ⚠ Writing the custom property rather than inline `width` / `height` does
1316
+ * **not** by itself leave placement in charge — an inline custom property
1317
+ * still outranks a `:host([placement=…])` rule setting the same property, so
1318
+ * a height dragged while floating capped a docked sidebar that had asked for
1319
+ * `100vh`. The cascade cannot arbitrate this; the axis check has to.
1320
+ *
1321
+ * So the rule is explicit: a placement owns the axes it fixes, and a
1322
+ * persisted size is only ever applied to the ones it does not.
1323
+ */
1324
+ #applySize(size: ResizeSize): void {
1325
+ const axis = this.#resizeAxis();
1326
+ if (axis === "none") {
1327
+ return;
1328
+ }
1329
+ if (size.width !== undefined) {
1330
+ this.style.setProperty("--ag-ui-width", `${size.width}px`);
1331
+ }
1332
+ if (size.height !== undefined && axis === "both") {
1333
+ this.style.setProperty("--ag-ui-height", `${size.height}px`);
1334
+ }
1335
+ }
1336
+
1337
+ /**
1338
+ * Drop any dragged size the new placement has taken ownership of.
1339
+ *
1340
+ * Without this a size survives the switch as an inline property and silently
1341
+ * overrides the placement it moved to — the panel keeps a floating height
1342
+ * while docked, and reads as a component that cannot do full height.
1343
+ */
1344
+ #releaseOwnedAxes(): void {
1345
+ const axis = this.#resizeAxis();
1346
+ if (axis !== "both") {
1347
+ this.style.removeProperty("--ag-ui-height");
1348
+ }
1349
+ if (axis === "none") {
1350
+ this.style.removeProperty("--ag-ui-width");
1351
+ }
1352
+ }
1353
+
1354
+ /** Persist a dragged size per tab, alongside the collapsed/theme preferences. */
1355
+ #persistSize(size: ResizeSize): void {
1356
+ const stored = { ...this.#readSize(), ...size };
1357
+ sessionStorage.setItem(this.#storageKey(SIZE_KEY), JSON.stringify(stored));
1358
+ }
1359
+
1360
+ /** The persisted size for this instance, or an empty record. */
1361
+ #readSize(): ResizeSize {
1362
+ const raw = this.#readScopedItem(SIZE_KEY);
1363
+ if (raw === null) {
1364
+ return {};
1365
+ }
1366
+ try {
1367
+ const parsed: unknown = JSON.parse(raw);
1368
+ return typeof parsed === "object" && parsed !== null ? (parsed as ResizeSize) : {};
1369
+ } catch {
1370
+ // A corrupt entry is not worth failing a mount over; fall back to the
1371
+ // placement's own size.
1372
+ return {};
1373
+ }
1374
+ }
1375
+
1171
1376
  /** This instance's namespaced form of an origin-scoped storage key. */
1172
1377
  #storageKey(base: string): string {
1173
1378
  return this.#storageNs === "" ? base : `${base}:${this.#storageNs}`;
@@ -1652,6 +1857,22 @@ export class AgUiChat extends HTMLElement {
1652
1857
  this.#rail.append(this.#iconElement("launcher", "launcher-icon", "💬"));
1653
1858
  this.#rail.addEventListener("click", () => this.setCollapsed(false));
1654
1859
 
1860
+ this.#chat.append(
1861
+ createResizeHandle({
1862
+ axis: () => this.#resizeAxis(),
1863
+ anchor: () => this.#measureAnchor(),
1864
+ rect: () => this.getBoundingClientRect(),
1865
+ apply: (size) => this.#applySize(size),
1866
+ commit: (size) => {
1867
+ this.#persistSize(size);
1868
+ // Re-stamp after the drag: a host whose layout changed underneath us
1869
+ // would otherwise keep the grip in the old corner, which reads as the
1870
+ // control being in the wrong place even though the drag was right.
1871
+ this.#syncResizeAnchor();
1872
+ },
1873
+ label: this.#strings.resizePanel,
1874
+ }),
1875
+ );
1655
1876
  this.#root.append(style, this.#chat, this.#rail);
1656
1877
  }
1657
1878
 
@@ -2068,6 +2289,12 @@ export class AgUiChat extends HTMLElement {
2068
2289
  : await requestApproval(this.#ensureGroup(), request, { signal, strings: this.#strings });
2069
2290
  this.#updateEmptyState();
2070
2291
  this.#messages.scrollTop = this.#messages.scrollHeight;
2292
+ // Same annotation as the client-side confirmation gate. Without it the
2293
+ // two gates read differently for the same act: a locally-confirmed call
2294
+ // said who let it through and a server-gated one said nothing, which is
2295
+ // backwards, since the server-side gate is the one guarding the tools
2296
+ // that actually run on the backend.
2297
+ card?.recordDecision(approved ? "approved" : "declined");
2071
2298
  if (approved) {
2072
2299
  responses[interrupt.id] = { status: "resolved", payload: { approved: true } };
2073
2300
  } else {
@@ -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
- /** Whether ``value`` has the required string fields of a {@link Skill}. */
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
 
@@ -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 inserted (or sent). May contain `{placeholder}`s filled from the
16
- * host's skill context before send; an unfilled placeholder blocks send.
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;
@@ -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
+ }
@@ -141,6 +141,10 @@ export class SkillsMenu {
141
141
  button.className = "skill-chip";
142
142
  button.setAttribute("part", "skill-chip");
143
143
  button.textContent = skill.title;
144
+ // The chip stays a label — it is a one-click affordance, not something to
145
+ // memorise — but naming the command it stands for makes the two
146
+ // discoverable as the same thing.
147
+ button.title = `/${skill.name}`;
144
148
  button.addEventListener("click", () => this.#pick(skill));
145
149
  this.chips.appendChild(button);
146
150
  }
@@ -159,7 +163,15 @@ export class SkillsMenu {
159
163
  const title = document.createElement("span");
160
164
  title.className = "skill-item-title";
161
165
  title.setAttribute("part", "skill-item-title");
162
- title.textContent = skill.title;
166
+
167
+ // The token leads, because it is the thing the user has to type. A row
168
+ // showing only the label taught the label and not the command, so the
169
+ // palette could not be used to learn its own vocabulary.
170
+ const token = document.createElement("code");
171
+ token.className = "skill-item-token";
172
+ token.setAttribute("part", "skill-item-token");
173
+ token.textContent = `/${skill.name}`;
174
+ title.append(token, document.createTextNode(` ${skill.title}`));
163
175
  item.appendChild(title);
164
176
 
165
177
  if (skill.description !== undefined) {