@haikit/client 0.7.0 → 0.9.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/src/icons.js ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The handful of icons the default UI draws. Stroked 24×24 paths in
3
+ * `currentColor`, so each takes the colour of the text around it.
4
+ *
5
+ * Built on call, never at import: the package is importable where there is no
6
+ * DOM.
7
+ */
8
+
9
+ const PATHS = {
10
+ // one line splitting in two: the dual channel
11
+ brand: "M2.5 12H8c3.5 0 4.5-6 9-6h4.5M8 12c3.5 0 4.5 6 9 6h4.5",
12
+ send: "M12 19V5M5.5 11.5 12 5l6.5 6.5",
13
+ plus: "M12 5v14M5 12h14",
14
+ panel: "M5 4h14a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2zM15 4v16",
15
+ close: "M6 6l12 12M18 6 6 18",
16
+ chevron: "M9 6l6 6-6 6",
17
+ check: "M5 12.5l4.5 4.5L19 7.5",
18
+ alert: "M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18zM12 7.5v5.5M12 16.5v.01",
19
+ clock: "M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18zM12 7v5l3.5 2",
20
+ reply: "M5 4v7a4 4 0 0 0 4 4h10M15 11l4 4-4 4",
21
+ };
22
+
23
+ const SVG = "http://www.w3.org/2000/svg";
24
+
25
+ /** An inline SVG icon, hidden from assistive technology: the text beside it, or
26
+ * the button's label, says what it means. */
27
+ export function icon(name, className = "hai-icon") {
28
+ const svg = document.createElementNS(SVG, "svg");
29
+ svg.setAttribute("class", className);
30
+ svg.setAttribute("viewBox", "0 0 24 24");
31
+ svg.setAttribute("fill", "none");
32
+ svg.setAttribute("stroke", "currentColor");
33
+ svg.setAttribute("stroke-width", "2");
34
+ svg.setAttribute("stroke-linecap", "round");
35
+ svg.setAttribute("stroke-linejoin", "round");
36
+ svg.setAttribute("aria-hidden", "true");
37
+ const path = document.createElementNS(SVG, "path");
38
+ path.setAttribute("d", PATHS[name]);
39
+ svg.append(path);
40
+ return svg;
41
+ }
package/src/index.d.ts CHANGED
@@ -37,6 +37,8 @@ export interface MountCtx {
37
37
  handle: string;
38
38
  mode: SurfaceMode;
39
39
  state: SurfaceState;
40
+ /** What the user picked, when the surface is mounted already frozen. */
41
+ selection?: unknown;
40
42
  send(action: string, value: unknown): Promise<void>;
41
43
  }
42
44
 
@@ -78,6 +80,8 @@ export interface SurfaceRecord {
78
80
  mode: SurfaceMode;
79
81
  state: SurfaceState;
80
82
  props: unknown | null;
83
+ /** What the user picked, once frozen. */
84
+ selection?: unknown;
81
85
  instance: SurfaceInstance | null;
82
86
  /** The element it was last mounted into. */
83
87
  element: HTMLElement | null;
package/src/index.js CHANGED
@@ -222,6 +222,8 @@ export function createChat({ endpoint = "/hai", registry }) {
222
222
  const surface = state.surfaces.get(event.handle);
223
223
  if (surface) {
224
224
  surface.state = event.state;
225
+ // kept, so a surface mounted again once frozen still knows the answer
226
+ surface.selection = event.selection;
225
227
  surface.instance?.freeze?.(event.selection);
226
228
  }
227
229
  break;
@@ -320,6 +322,7 @@ export function createChat({ endpoint = "/hai", registry }) {
320
322
  handle,
321
323
  mode: surface.mode,
322
324
  state: surface.state,
325
+ selection: surface.selection,
323
326
  // The ONLY channel a component has to the server. It passes an action
324
327
  // name declared in the contract — never a tool, never a handler.
325
328
  send: (action, value) => interact(handle, action, value),
package/src/transcript.js CHANGED
@@ -6,6 +6,8 @@
6
6
  * Everything uses textContent. Tool payloads are untrusted input.
7
7
  */
8
8
 
9
+ import { icon } from "./icons.js";
10
+
9
11
  export const h = (tag, className, text) => {
10
12
  const node = document.createElement(tag);
11
13
  if (className) node.className = className;
@@ -75,9 +77,53 @@ export function closeTranscript(root) {
75
77
  function appearance(block, chat) {
76
78
  // a surface shows a skeleton until its props arrive, and then itself for good
77
79
  if (block.kind === "ui") return chat.state.surfaces.get(block.handle)?.props ? "mounted" : "loading";
80
+ if (block.kind === "tool") return JSON.stringify({ ...block, status: toolStatus(block, chat) });
78
81
  return JSON.stringify(block);
79
82
  }
80
83
 
84
+ /**
85
+ * What a tool row says of its call. The server leaves an elicit tool's row
86
+ * `awaiting` for good; the question has closed once its surface is frozen —
87
+ * answered, or typed over — or the conversation is out of date. An answered
88
+ * question stays answered when the conversation goes out of date. A tool can
89
+ * show a display surface before its question, and that one never freezes, so
90
+ * it is the elicit surface that says.
91
+ */
92
+ function toolStatus(block, chat) {
93
+ if (block.status !== "awaiting") return block.status;
94
+ const { blocks, surfaces, expired } = chat.state;
95
+ // Found once per row and kept: every answered question keeps its row
96
+ // `awaiting`, and this runs for each of them on every streamed event.
97
+ let handle = questions.get(block);
98
+ if (handle === undefined) {
99
+ handle = blocks.find(
100
+ (b) => b.kind === "ui" && b.toolId === block.id && surfaces.get(b.handle)?.mode === "elicit",
101
+ )?.handle;
102
+ if (handle !== undefined) questions.set(block, handle);
103
+ }
104
+ if (surfaces.get(handle)?.state === "frozen") return "resolved";
105
+ return expired ? "expired" : "awaiting";
106
+ }
107
+
108
+ /** A tool row's block → the handle of the question it asked, once seen. */
109
+ const questions = new WeakMap();
110
+
111
+ const STATUS_TEXT = {
112
+ running: () => "running…",
113
+ awaiting: () => "awaiting you",
114
+ resolved: () => "resolved",
115
+ expired: () => "out of date",
116
+ error: (ms) => `failed · ${ms} ms`,
117
+ };
118
+
119
+ function statusMark(status) {
120
+ const mark = h("span", "hai-ticon");
121
+ if (status === "running") mark.append(h("span", "hai-spinner"));
122
+ else if (status === "awaiting") mark.append(h("span", "hai-pulse"));
123
+ else mark.append(icon(status === "error" ? "alert" : status === "expired" ? "clock" : "check"));
124
+ return mark;
125
+ }
126
+
81
127
  /**
82
128
  * Put `elements` in order under `root`, moving none that is already there:
83
129
  * one taken out and put back loses its scroll position and focus, and any
@@ -204,43 +250,54 @@ function renderBlock(block, chat) {
204
250
  return el;
205
251
  }
206
252
 
253
+ // The user's answer, given in a surface rather than typed: it went back to
254
+ // the model as the tool's result.
207
255
  case "interaction": {
208
256
  const el = h("div", "hai-block hai-interaction");
209
- el.append(h("span", "hai-arrow", "↳"), h("span", null, block.label));
257
+ const arrow = h("span", "hai-arrow");
258
+ arrow.append(icon("reply"));
259
+ el.append(arrow, h("span", "hai-body", block.label));
210
260
  return el;
211
261
  }
212
262
 
213
- case "error":
214
- return h("div", "hai-block hai-error", `error: ${block.message}`);
263
+ case "error": {
264
+ const el = h("div", "hai-block hai-error");
265
+ el.setAttribute("role", "alert");
266
+ el.append(icon("alert"), h("div", "hai-body", block.message));
267
+ return el;
268
+ }
215
269
 
216
270
  // The conversation is closed, so the notice carries the only way forward.
217
271
  case "expired": {
218
272
  const el = h("div", "hai-block hai-expired");
219
273
  const again = h("button", "hai-new", "Start a new conversation");
274
+ again.type = "button";
220
275
  again.onclick = () => chat.reset();
221
- el.append(h("div", "hai-body", block.message), again);
276
+ el.append(icon("clock"), h("div", "hai-body", block.message), again);
222
277
  return el;
223
278
  }
224
279
 
225
280
  // Provenance chrome. The tool row and its surface are one visual unit:
226
281
  // what produced this, with what arguments, and what the model got back.
227
282
  case "tool": {
228
- const el = h("details", `hai-block hai-tool hai-status-${block.status}`);
283
+ const status = toolStatus(block, chat);
284
+ const el = h("details", `hai-block hai-tool hai-status-${status}`);
229
285
  const summary = h("summary");
230
286
  summary.append(
231
- h("span", "hai-gear", "⚙"),
287
+ statusMark(status),
232
288
  h("span", "hai-tname", block.name),
233
289
  h("span", "hai-targs", JSON.stringify(block.input)),
234
- h(
235
- "span",
236
- "hai-tstatus",
237
- block.status === "running" ? "…" : block.status === "awaiting" ? "awaiting user" : `${block.ms}ms`,
238
- ),
290
+ h("span", "hai-tstatus", (STATUS_TEXT[status] ?? ((ms) => `${ms} ms`))(block.ms)),
291
+ icon("chevron", "hai-icon hai-chevron"),
239
292
  );
240
293
  el.append(summary);
241
294
  const detail = h("div", "hai-tool-detail");
242
- detail.append(h("div", "hai-label", "→ tool_result — all the model receives"));
243
- detail.append(h("pre", "hai-digest", block.result ?? "…"));
295
+ detail.append(
296
+ h("div", "hai-label", "input"),
297
+ h("pre", "hai-code", JSON.stringify(block.input ?? {}, null, 2)),
298
+ h("div", "hai-label", "→ tool_result — all the model receives"),
299
+ h("pre", "hai-digest", block.result ?? "…"),
300
+ );
244
301
  el.append(detail);
245
302
  return el;
246
303
  }
@@ -249,10 +306,15 @@ function renderBlock(block, chat) {
249
306
  const el = h("div", "hai-surface");
250
307
  el.dataset.handle = block.handle;
251
308
  const surface = chat.state.surfaces.get(block.handle);
309
+ if (surface) el.dataset.component = surface.component;
252
310
  if (surface?.props) {
253
311
  queueMicrotask(() => chat.mount(block.handle, el));
254
312
  } else {
255
- el.append(h("div", "hai-skeleton", `${surface?.component ?? "surface"} · loading…`));
313
+ el.setAttribute("aria-busy", "true");
314
+ const skeleton = h("div", "hai-skeleton");
315
+ skeleton.append(h("div", "hai-skeleton-label", `${surface?.component ?? "surface"} · loading…`));
316
+ for (let i = 0; i < 3; i++) skeleton.append(h("div", "hai-shimmer"));
317
+ el.append(skeleton);
256
318
  }
257
319
  return el;
258
320
  }