@haikit/client 0.11.0 → 0.12.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haikit/client",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "The haikit browser runtime: streams tool payloads into interactive UI surfaces. Plain ESM, no build step, no dependencies.",
5
5
  "keywords": [
6
6
  "haikit",
package/src/hai.css CHANGED
@@ -327,7 +327,26 @@
327
327
  font: 12px var(--hai-mono);
328
328
  color: var(--hai-dim);
329
329
  }
330
- .hai-tstatus { color: var(--hai-dim); white-space: nowrap; font-variant-numeric: tabular-nums; }
330
+ .hai-tstatus {
331
+ max-width: 50%;
332
+ overflow: hidden;
333
+ text-overflow: ellipsis;
334
+ color: var(--hai-dim);
335
+ white-space: nowrap;
336
+ font-variant-numeric: tabular-nums;
337
+ }
338
+ /* a running tool's progress: the message gives way before the count does */
339
+ .hai-tprogress { display: flex; min-width: 0; white-space: pre; }
340
+ .hai-tmessage { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
341
+ .hai-tcount { flex: none; }
342
+ /* …and a bar along the bottom edge of its row */
343
+ .hai-tool summary:has(.hai-tbar) { position: relative; }
344
+ .hai-tbar {
345
+ position: absolute;
346
+ inset: auto 0 0 0;
347
+ height: 2px;
348
+ background: linear-gradient(to right, var(--hai-accent) var(--hai-progress, 0%), transparent 0);
349
+ }
331
350
  .hai-chevron { width: 14px; height: 14px; color: var(--hai-dim); transition: transform .15s; }
332
351
  .hai-tool[open] .hai-chevron { transform: rotate(90deg); }
333
352
 
package/src/index.d.ts CHANGED
@@ -12,11 +12,28 @@ export type ConversationStatus = "idle" | "streaming" | "awaiting";
12
12
  export type SurfaceMode = "display" | "elicit";
13
13
  export type SurfaceState = "live" | "frozen";
14
14
 
15
+ /** How far a running tool has got, as its `ctx.progress` calls said. */
16
+ export interface Progress {
17
+ message?: string;
18
+ done?: number;
19
+ total?: number;
20
+ }
21
+
15
22
  /** Blocks the server emits, plus the three the client synthesises locally. */
16
23
  export type Block =
17
24
  | { kind: "user"; id: string; text: string }
18
25
  | { kind: "assistant"; id: string; text: string }
19
- | { kind: "tool"; id: string; name: string; input: unknown; status: string; ms?: number; result?: string }
26
+ | {
27
+ kind: "tool";
28
+ id: string;
29
+ name: string;
30
+ input: unknown;
31
+ status: string;
32
+ ms?: number;
33
+ result?: string;
34
+ /** What the tool's `ctx.progress` calls have said so far, merged. */
35
+ progress?: Progress;
36
+ }
20
37
  | { kind: "interaction"; id: string; handle: string; label: string }
21
38
  /** synthesised from `ui_open` — the slot a surface mounts into */
22
39
  | { kind: "ui"; id: string; handle: string; toolId: string }
package/src/index.js CHANGED
@@ -190,6 +190,17 @@ export function createChat({ endpoint = "/hai", registry }) {
190
190
  break;
191
191
  }
192
192
 
193
+ // A frame carries only the fields its call named, so it is merged: a
194
+ // tool says what it is doing once, then counts.
195
+ case "progress": {
196
+ const block = state.blocks.find((b) => b.id === event.toolId);
197
+ if (block?.kind === "tool") {
198
+ const { type, toolId, ...fields } = event;
199
+ block.progress = { ...block.progress, ...fields };
200
+ }
201
+ break;
202
+ }
203
+
193
204
  case "ui_open":
194
205
  state.surfaces.set(event.handle, {
195
206
  handle: event.handle,
package/src/transcript.js CHANGED
@@ -46,8 +46,13 @@ export function renderTranscript(root, chat) {
46
46
  el = renderBlock(block, chat);
47
47
  // an expanded tool row stays expanded when its status changes
48
48
  if (before?.el.open) el.open = true;
49
+ } else if (block.progress !== before.progress) {
50
+ // Progress changes the row in place. Rebuilt for every frame, a row a
51
+ // keyboard user is on would take their focus up to ten times a second.
52
+ const parts = rowParts.get(el);
53
+ if (parts) showStatus(parts, block, toolStatus(block, chat));
49
54
  }
50
- shown.set(block.id, { el, look });
55
+ shown.set(block.id, { el, look, progress: block.progress });
51
56
  return el;
52
57
  });
53
58
  follow.shown = shown;
@@ -77,7 +82,8 @@ export function closeTranscript(root) {
77
82
  function appearance(block, chat) {
78
83
  // a surface shows a skeleton until its props arrive, and then itself for good
79
84
  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) });
85
+ // progress is left out: it changes the row in place (see renderTranscript)
86
+ if (block.kind === "tool") return JSON.stringify({ ...block, progress: undefined, status: toolStatus(block, chat) });
81
87
  return JSON.stringify(block);
82
88
  }
83
89
 
@@ -116,6 +122,46 @@ const STATUS_TEXT = {
116
122
  error: (ms) => `failed · ${ms} ms`,
117
123
  };
118
124
 
125
+ /** "2/4", or "2" when the tool hasn't said how many in all. */
126
+ function progressCount({ done, total }) {
127
+ return total !== undefined ? `${done ?? 0}/${total}` : done !== undefined ? String(done) : "";
128
+ }
129
+
130
+ /** A tool row's element → its status text and progress bar, which progress changes in place. */
131
+ const rowParts = new WeakMap();
132
+
133
+ /**
134
+ * Fill in what a tool row says of its call. While it runs, that is its
135
+ * progress once it has reported some: the message gives way first on a narrow
136
+ * row, so the count stays readable, and the bar fills once there is a total.
137
+ */
138
+ function showStatus({ status: text, bar }, block, status) {
139
+ const progress = status === "running" ? block.progress : undefined;
140
+ const message = progress?.message ?? "";
141
+ const count = progress ? progressCount(progress) : "";
142
+ if (message || count) {
143
+ text.className = "hai-tstatus hai-tprogress";
144
+ const parts = [];
145
+ if (message) parts.push(h("span", "hai-tmessage", message));
146
+ if (message && count) parts.push(h("span", null, " · "));
147
+ if (count) parts.push(h("span", "hai-tcount", count));
148
+ text.replaceChildren(...parts);
149
+ } else {
150
+ text.className = "hai-tstatus";
151
+ text.textContent = (STATUS_TEXT[status] ?? ((ms) => `${ms} ms`))(block.ms);
152
+ }
153
+
154
+ if (!bar) return;
155
+ const total = progress?.total;
156
+ bar.hidden = !total;
157
+ if (!total) return;
158
+ const done = Math.min(progress.done ?? 0, total);
159
+ bar.setAttribute("aria-valuemax", String(total));
160
+ bar.setAttribute("aria-valuenow", String(done));
161
+ bar.setAttribute("aria-valuetext", [message, `${done} of ${total}`].filter(Boolean).join(" · "));
162
+ bar.style.setProperty("--hai-progress", `${(done / total) * 100}%`);
163
+ }
164
+
119
165
  function statusMark(status) {
120
166
  const mark = h("span", "hai-ticon");
121
167
  if (status === "running") mark.append(h("span", "hai-spinner"));
@@ -283,13 +329,24 @@ function renderBlock(block, chat) {
283
329
  const status = toolStatus(block, chat);
284
330
  const el = h("details", `hai-block hai-tool hai-status-${status}`);
285
331
  const summary = h("summary");
332
+ const parts = { status: h("span", "hai-tstatus"), bar: null };
286
333
  summary.append(
287
334
  statusMark(status),
288
335
  h("span", "hai-tname", block.name),
289
336
  h("span", "hai-targs", JSON.stringify(block.input)),
290
- h("span", "hai-tstatus", (STATUS_TEXT[status] ?? ((ms) => `${ms} ms`))(block.ms)),
337
+ parts.status,
291
338
  icon("chevron", "hai-icon hai-chevron"),
292
339
  );
340
+ // a thin bar along the row's bottom edge, for progress with a total
341
+ if (status === "running") {
342
+ parts.bar = h("span", "hai-tbar");
343
+ parts.bar.setAttribute("role", "progressbar");
344
+ parts.bar.setAttribute("aria-label", `${block.name} progress`);
345
+ parts.bar.setAttribute("aria-valuemin", "0");
346
+ summary.append(parts.bar);
347
+ }
348
+ rowParts.set(el, parts);
349
+ showStatus(parts, block, status);
293
350
  el.append(summary);
294
351
  const detail = h("div", "hai-tool-detail");
295
352
  detail.append(