@narumitw/pi-webui 0.31.0 → 0.33.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.
@@ -35,7 +35,16 @@ import {
35
35
  Theme,
36
36
  } from "@radix-ui/themes";
37
37
  import { Collapsible, Popover, Tooltip } from "radix-ui";
38
- import { StrictMode, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
38
+ import {
39
+ memo,
40
+ StrictMode,
41
+ useCallback,
42
+ useEffect,
43
+ useMemo,
44
+ useRef,
45
+ useState,
46
+ useSyncExternalStore,
47
+ } from "react";
39
48
  import { createRoot } from "react-dom/client";
40
49
  import { dropAfterTarget, imagesStackVertically } from "../image-drag.js";
41
50
  import { parseMarkdown } from "../markdown.js";
@@ -156,10 +165,10 @@ function App() {
156
165
  if (!open) requestAnimationFrame(() => clearReturnFocus.current?.focus());
157
166
  }
158
167
 
159
- function openForgetDialog(id, trigger) {
168
+ const openForgetDialog = useCallback((id, trigger) => {
160
169
  forgetReturnFocus.current = trigger;
161
170
  setForgetId(id);
162
- }
171
+ }, []);
163
172
 
164
173
  function closeForgetDialog() {
165
174
  setForgetId("");
@@ -355,7 +364,7 @@ function Conversation({ model, onForget, view }) {
355
364
  );
356
365
  }
357
366
 
358
- function Message({ message, onForget, retained, tools }) {
367
+ const Message = memo(function Message({ message, onForget, retained, tools }) {
359
368
  const role = knownRole(message.role);
360
369
  const body = (
361
370
  <Box className="message-body">
@@ -401,6 +410,23 @@ function Message({ message, onForget, retained, tools }) {
401
410
  </li>
402
411
  </Box>
403
412
  );
413
+ }, messagePropsEqual);
414
+
415
+ function messagePropsEqual(previous, next) {
416
+ if (
417
+ previous.message !== next.message ||
418
+ previous.onForget !== next.onForget ||
419
+ previous.retained !== next.retained
420
+ ) {
421
+ return false;
422
+ }
423
+ if (previous.tools === next.tools) return true;
424
+ for (const block of previous.message.content ?? []) {
425
+ if (block.type === "toolCall" && previous.tools.get(block.id) !== next.tools.get(block.id)) {
426
+ return false;
427
+ }
428
+ }
429
+ return true;
404
430
  }
405
431
 
406
432
  function MessageBlock({ block, onForget, retained, tool }) {
@@ -426,7 +452,7 @@ function MessageBlock({ block, onForget, retained, tool }) {
426
452
  return null;
427
453
  }
428
454
 
429
- function Markdown({ text }) {
455
+ const Markdown = memo(function Markdown({ text }) {
430
456
  return (
431
457
  <Box className="message-markdown">
432
458
  {withStableKeys(parseMarkdown(text)).map(({ key, value: block }) => (
@@ -434,7 +460,7 @@ function Markdown({ text }) {
434
460
  ))}
435
461
  </Box>
436
462
  );
437
- }
463
+ });
438
464
 
439
465
  function MarkdownBlock({ block }) {
440
466
  if (block.type === "heading") {
@@ -23,6 +23,14 @@ import {
23
23
  prepareSend,
24
24
  setNearBottom,
25
25
  } from "../state.js";
26
+ import {
27
+ allowTranscriptAutoScroll,
28
+ createRenderBatcher,
29
+ hasConversationReferenceChange,
30
+ shouldBatchConversationEvent,
31
+ shouldScrollForConversationEvent,
32
+ withPublishedConversation,
33
+ } from "./view-helpers.js";
26
34
 
27
35
  const SUPPORTED_IMAGE_TYPES = new Set([
28
36
  "image/png",
@@ -42,6 +50,8 @@ const listeners = new Set();
42
50
  const retryFiles = new Map();
43
51
  const uploadProgress = new Map();
44
52
  let model = initialState();
53
+ let publishedMessages = model.messages;
54
+ let publishedTools = model.tools;
45
55
  let events;
46
56
  let reconnectTimer;
47
57
  let reconnectDelay = 500;
@@ -52,10 +62,23 @@ let draftSaveQueue = Promise.resolve();
52
62
  let mutatingAttachments = false;
53
63
  let started = false;
54
64
  let view = createView();
65
+ const scheduleConversationRender = createRenderBatcher(
66
+ (callback) => {
67
+ // Streaming can produce many events per frame. Cap transcript work so input remains responsive.
68
+ setTimeout(() => requestAnimationFrame(callback), 50);
69
+ },
70
+ (extra) => {
71
+ publishConversation(extra);
72
+ emit({
73
+ ...extra,
74
+ scrollToLatest: allowTranscriptAutoScroll(model.following, !model.closed),
75
+ });
76
+ },
77
+ );
55
78
 
56
79
  function createView(extra = {}) {
57
80
  return {
58
- model,
81
+ model: withPublishedConversation(model, publishedMessages, publishedTools),
59
82
  mutatingAttachments,
60
83
  uploadProgress: new Map(uploadProgress),
61
84
  retryableIds: new Set(retryFiles.keys()),
@@ -71,6 +94,20 @@ function emit(extra) {
71
94
  for (const listener of listeners) listener();
72
95
  }
73
96
 
97
+ function publishConversation(extra = {}) {
98
+ for (const key of extra.transcriptUpdateKeys ?? []) {
99
+ model = noteUnseenUpdate(model, key);
100
+ }
101
+ publishedMessages = model.messages;
102
+ publishedTools = model.tools;
103
+ }
104
+
105
+ function drainConversationRender() {
106
+ const pending = scheduleConversationRender.drain();
107
+ publishConversation(pending);
108
+ return pending;
109
+ }
110
+
74
111
  export const webClient = {
75
112
  subscribe(listener) {
76
113
  listeners.add(listener);
@@ -148,14 +185,20 @@ async function refreshSnapshot(requiredSequence = 0) {
148
185
  if (!snapshotRefresh) {
149
186
  snapshotRefresh = (async () => {
150
187
  do {
188
+ let pendingConversation = {};
151
189
  const response = await fetch("/api/state", { cache: "no-store" });
152
190
  if (!response.ok) throw new Error(await responseError(response));
153
191
  const snapshot = await response.json();
192
+ const replacesConversation =
193
+ Number.isSafeInteger(snapshot?.sequence) && snapshot.sequence >= model.sequence;
154
194
  model = applySnapshot(model, snapshot);
155
195
  if (typeof snapshot.lease?.activeClientId === "string") {
156
196
  model = applyLease(model, snapshot.lease, clientId);
157
197
  }
158
- emit({ scrollToLatest: model.following });
198
+ if (replacesConversation) {
199
+ pendingConversation = drainConversationRender();
200
+ }
201
+ emit({ ...pendingConversation, scrollToLatest: model.following });
159
202
  } while (model.sequence < snapshotTarget);
160
203
  })().finally(() => {
161
204
  snapshotRefresh = undefined;
@@ -188,20 +231,46 @@ function connectEvents() {
188
231
  });
189
232
  events.addEventListener("conversation", (event) => {
190
233
  const conversationEvent = JSON.parse(event.data);
234
+ const previousMessages = model.messages;
235
+ const previousTools = model.tools;
191
236
  model = applyConversationEvent(model, conversationEvent);
192
237
  if (model.needsSnapshot) {
193
238
  void refreshSnapshot(conversationEvent.sequence).catch(connectionFailure);
194
239
  return;
195
240
  }
196
- model = noteUnseenUpdate(model, conversationUpdateKey(conversationEvent));
241
+ if (shouldBatchConversationEvent(conversationEvent.type)) {
242
+ scheduleConversationRender({
243
+ transcriptAnnouncement: conversationAnnouncement(conversationEvent),
244
+ transcriptUpdateKeys: [conversationUpdateKey(conversationEvent)],
245
+ });
246
+ return;
247
+ }
248
+ let pendingConversation = {};
249
+ if (conversationEvent.type === "snapshot" || conversationEvent.type === "session-ended") {
250
+ pendingConversation = drainConversationRender();
251
+ if (
252
+ conversationEvent.type === "snapshot" &&
253
+ hasConversationReferenceChange(previousMessages, previousTools, model)
254
+ ) {
255
+ model = noteUnseenUpdate(model, conversationUpdateKey(conversationEvent));
256
+ }
257
+ publishConversation();
258
+ }
197
259
  emit({
198
- transcriptAnnouncement: conversationAnnouncement(conversationEvent),
199
- scrollToLatest: model.following,
260
+ ...pendingConversation,
261
+ scrollToLatest: shouldScrollForConversationEvent(
262
+ conversationEvent.type,
263
+ model.following && !model.closed,
264
+ ),
200
265
  });
201
266
  });
202
267
  events.addEventListener("snapshot", (event) => {
203
- model = applySnapshot(model, JSON.parse(event.data));
204
- emit({ scrollToLatest: model.following });
268
+ const snapshot = JSON.parse(event.data);
269
+ const replacesConversation =
270
+ Number.isSafeInteger(snapshot?.sequence) && snapshot.sequence >= model.sequence;
271
+ model = applySnapshot(model, snapshot);
272
+ const pendingConversation = replacesConversation ? drainConversationRender() : {};
273
+ emit({ ...pendingConversation, scrollToLatest: model.following });
205
274
  });
206
275
  events.addEventListener("lease", (event) => {
207
276
  model = applyLease(model, JSON.parse(event.data), clientId);
@@ -226,13 +295,20 @@ function connectEvents() {
226
295
  events.addEventListener("session-ended", () => {
227
296
  model = { ...model, closed: true, activity: "ended", connected: false };
228
297
  events?.close();
229
- emit({ transcriptAnnouncement: "Pi session ended." });
298
+ const pendingConversation = drainConversationRender();
299
+ emit({
300
+ ...pendingConversation,
301
+ transcriptAnnouncement: [pendingConversation.transcriptAnnouncement, "Pi session ended."]
302
+ .filter(Boolean)
303
+ .join(" "),
304
+ });
230
305
  });
231
306
  events.addEventListener("error", () => {
232
307
  events?.close();
233
308
  if (model.closed) return;
234
309
  model = { ...model, connected: false };
235
- emit();
310
+ const pendingConversation = drainConversationRender();
311
+ emit(pendingConversation);
236
312
  scheduleReconnect();
237
313
  });
238
314
  }
@@ -254,7 +330,8 @@ function scheduleReconnect() {
254
330
 
255
331
  function connectionFailure(error) {
256
332
  model = { ...model, connected: false, error: errorMessage(error) };
257
- emit();
333
+ const pendingConversation = drainConversationRender();
334
+ emit(pendingConversation);
258
335
  scheduleReconnect();
259
336
  }
260
337
 
@@ -1,3 +1,66 @@
1
+ export function shouldBatchConversationEvent(type) {
2
+ return type === "message" || type === "tool";
3
+ }
4
+
5
+ export function shouldScrollForConversationEvent(type, following) {
6
+ return type === "snapshot" && following;
7
+ }
8
+
9
+ export function hasConversationReferenceChange(previousMessages, previousTools, current) {
10
+ return current.messages !== previousMessages || current.tools !== previousTools;
11
+ }
12
+
13
+ export function withPublishedConversation(model, messages, tools) {
14
+ if (model.messages === messages && model.tools === tools) return model;
15
+ return { ...model, messages, tools };
16
+ }
17
+
18
+ export function allowTranscriptAutoScroll(following, active = true) {
19
+ return Boolean(following && active);
20
+ }
21
+
22
+ export function createRenderBatcher(schedule, render) {
23
+ let generation = 0;
24
+ let scheduled = false;
25
+ let pending = {};
26
+ const batch = (extra = {}) => {
27
+ const transcriptAnnouncement = [pending.transcriptAnnouncement, extra.transcriptAnnouncement]
28
+ .filter(Boolean)
29
+ .join(" ");
30
+ const transcriptUpdateKeys = [
31
+ ...new Set([...(pending.transcriptUpdateKeys ?? []), ...(extra.transcriptUpdateKeys ?? [])]),
32
+ ];
33
+ pending = {
34
+ ...pending,
35
+ ...extra,
36
+ transcriptAnnouncement,
37
+ scrollToLatest: Boolean(pending.scrollToLatest || extra.scrollToLatest),
38
+ ...(transcriptUpdateKeys.length > 0 ? { transcriptUpdateKeys } : {}),
39
+ };
40
+ if (scheduled) return;
41
+ scheduled = true;
42
+ const scheduledGeneration = generation;
43
+ schedule(() => {
44
+ if (scheduledGeneration !== generation) return;
45
+ scheduled = false;
46
+ const next = pending;
47
+ pending = {};
48
+ render(next);
49
+ });
50
+ };
51
+ batch.drain = () => {
52
+ generation += 1;
53
+ scheduled = false;
54
+ const next = pending;
55
+ pending = {};
56
+ return next;
57
+ };
58
+ batch.cancel = () => {
59
+ batch.drain();
60
+ };
61
+ return batch;
62
+ }
63
+
1
64
  export function withStableKeys(values) {
2
65
  return values.map((value, index) => {
3
66
  const type =