@hienlh/ppm 0.14.20 → 0.14.21

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.
Files changed (36) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/assets/skills/ppm/SKILL.md +1 -1
  3. package/assets/skills/ppm/references/http-api.md +1 -1
  4. package/dist/web/assets/{audio-preview-Dd19CfSS.js → audio-preview-CJpcW3af.js} +1 -1
  5. package/dist/web/assets/chat-tab-bchStAIT.js +18 -0
  6. package/dist/web/assets/{code-editor-Tl7ISiKv.js → code-editor-CHgITnbd.js} +2 -2
  7. package/dist/web/assets/{conflict-editor-B5lmSt60.js → conflict-editor-CMeu434J.js} +1 -1
  8. package/dist/web/assets/{database-viewer-CN1xSIGE.js → database-viewer-BgsP9-uc.js} +1 -1
  9. package/dist/web/assets/{diff-viewer-DNRLiAkN.js → diff-viewer-DgKwBbYx.js} +1 -1
  10. package/dist/web/assets/{docx-preview-BpwwLe2H.js → docx-preview-BuiXNf6b.js} +1 -1
  11. package/dist/web/assets/{extension-webview-CA2vNGIv.js → extension-webview-D_NP0J9B.js} +1 -1
  12. package/dist/web/assets/{git-log-panel-Cy_lYOIT.js → git-log-panel-D9zXbgbE.js} +1 -1
  13. package/dist/web/assets/{glide-data-grid-3agxRvM7.js → glide-data-grid-DOF-6NWk.js} +1 -1
  14. package/dist/web/assets/{image-preview-DunIm1eD.js → image-preview-D1e_gJjP.js} +1 -1
  15. package/dist/web/assets/index-BKNIod9i.css +2 -0
  16. package/dist/web/assets/{index-Dq8HsKbm.js → index-CCC32WvB.js} +3 -3
  17. package/dist/web/assets/keybindings-store-Dm9xDJ6w.js +1 -0
  18. package/dist/web/assets/{markdown-renderer-CF-2Twzx.js → markdown-renderer-B6UQmUH_.js} +1 -1
  19. package/dist/web/assets/notification-store-B-ulw8mg.js +1 -0
  20. package/dist/web/assets/{pdf-preview-DKrKmPog.js → pdf-preview-BVQN975L.js} +1 -1
  21. package/dist/web/assets/{port-forwarding-tab-CAqB1X5v.js → port-forwarding-tab-CBXy17QJ.js} +1 -1
  22. package/dist/web/assets/{postgres-viewer-CfHuLQeX.js → postgres-viewer-CmAcvesE.js} +1 -1
  23. package/dist/web/assets/{settings-tab-CRLe4dCy.js → settings-tab-NWkMHtid.js} +1 -1
  24. package/dist/web/assets/{sql-query-editor-D_dLbWJd.js → sql-query-editor-Esdm2cZ4.js} +1 -1
  25. package/dist/web/assets/{sqlite-viewer-DM4eBxf2.js → sqlite-viewer-BdbO9GAP.js} +1 -1
  26. package/dist/web/assets/{system-monitor-tab-F_pUeS3m.js → system-monitor-tab-9-Bo9QUX.js} +1 -1
  27. package/dist/web/assets/{terminal-tab-DRQoBHgo.js → terminal-tab-C0xzMiGC.js} +1 -1
  28. package/dist/web/assets/{video-preview-Cv_1L2N6.js → video-preview-Z0BxXbyC.js} +1 -1
  29. package/dist/web/index.html +2 -2
  30. package/dist/web/sw.js +1 -1
  31. package/package.json +1 -1
  32. package/src/web/components/chat/message-list.tsx +130 -26
  33. package/dist/web/assets/chat-tab-Mkj4usuP.js +0 -18
  34. package/dist/web/assets/index-DDG-UPDA.css +0 -2
  35. package/dist/web/assets/keybindings-store-BlAnqPD8.js +0 -1
  36. package/dist/web/assets/notification-store-E7t4d42B.js +0 -1
@@ -7,8 +7,12 @@ import type { BashPartialEntry } from "../../hooks/use-chat";
7
7
  import { ToolCard } from "./tool-cards";
8
8
  import { TaskTracker } from "./task-tracker";
9
9
  import { extractJsonlPath } from "./pre-compact-button";
10
+ // Kick off the markdown chunk fetch at module load (not first render): Suspense
11
+ // skeletons that resolve *after* the list mounts grow each message and shove the
12
+ // bottom of the transcript out of view on fresh load.
13
+ const markdownRendererImport = import("@/components/shared/markdown-renderer");
10
14
  const MarkdownRenderer = lazy(() =>
11
- import("@/components/shared/markdown-renderer").then((m) => ({ default: m.MarkdownRenderer }))
15
+ markdownRendererImport.then((m) => ({ default: m.MarkdownRenderer }))
12
16
  );
13
17
  import { cn, basename } from "@/lib/utils";
14
18
  import { RenderErrorBoundary } from "@/components/shared/markdown-error-boundary";
@@ -124,15 +128,28 @@ export function MessageList({
124
128
  return hasContent || hasEvents;
125
129
  }), [messages]);
126
130
 
131
+ // The approval card + "thinking…" indicator ride as one extra virtual row at
132
+ // the very end. Rendering them outside the virtualized list left them below
133
+ // the anchored end (scrolled to the last message, indicator off-screen), so
134
+ // fold them into the count instead — anchorTo:'end' then keeps them in view.
135
+ const hasTrailing = !!pendingApproval || isStreaming;
136
+ // The trailing key embeds the message count on purpose: virtual-core fires
137
+ // followOnAppend only when the LAST item's key changes on a count increase
138
+ // (options-update check in Virtualizer.setOptions). A constant trailing key
139
+ // made the last key never change once streaming started, suppressing the
140
+ // scroll-to-end for every mid-turn append — the thinking indicator stayed
141
+ // just below the fold.
142
+ const trailingKey = `__ppm_trailing__:${filtered.length}`;
143
+
127
144
  // Virtualize the whole transcript so only on-screen bubbles live in the DOM.
128
145
  // anchorTo/followOnAppend/scrollEndThreshold need virtual-core >= 3.16 (chat
129
146
  // scroll physics): stay pinned to the newest message, follow appends only when
130
147
  // already at the end, and keep the visible anchor stable when history prepends.
131
148
  const rowVirtualizer = useVirtualizer({
132
- count: filtered.length,
149
+ count: filtered.length + (hasTrailing ? 1 : 0),
133
150
  getScrollElement: () => parentRef.current,
134
151
  estimateSize: () => 120,
135
- getItemKey: (index) => filtered[index]?.id ?? index,
152
+ getItemKey: (index) => (index < filtered.length ? (filtered[index]?.id ?? index) : trailingKey),
136
153
  overscan: 6,
137
154
  anchorTo: "end",
138
155
  followOnAppend: true,
@@ -141,13 +158,82 @@ export function MessageList({
141
158
 
142
159
  const virtualItems = rowVirtualizer.getVirtualItems();
143
160
 
144
- // Jump to the newest message on first mount and whenever the conversation
145
- // identity changes (session swap). anchorTo keeps it pinned afterwards.
161
+ // App-owned stick-to-bottom invariant: while the user is pinned (near the
162
+ // real DOM bottom), any content-height change snaps scrollTop to the true
163
+ // scrollHeight. The core's own end-anchoring adjusts by size *deltas* against
164
+ // estimated offsets, which can land short of the bottom on fresh load (items
165
+ // measure smaller than the estimate → browser clamps scrollTop → the delta
166
+ // compensation double-corrects). Snapping to the absolute bottom is immune.
167
+ const pinnedRef = useRef(true);
168
+ const spacerRef = useRef<HTMLDivElement>(null);
169
+
170
+ // The scroll element mounts LATE: while messagesLoading this component
171
+ // early-returns a loading screen, so mount-time effects saw a null ref and
172
+ // never attached their listeners/observers. A callback ref + state re-runs
173
+ // them the moment the real list appears.
174
+ const [scrollEl, setScrollEl] = useState<HTMLDivElement | null>(null);
175
+ const setScrollRef = useCallback((el: HTMLDivElement | null) => {
176
+ parentRef.current = el;
177
+ setScrollEl(el);
178
+ }, []);
179
+
180
+ // Unpin ONLY on user intent (wheel up / touch drag / scrollbar drag). The
181
+ // virtualizer's own adjustments fire programmatic scroll events too — during
182
+ // load they can transiently leave a >threshold gap, and treating those as
183
+ // "user scrolled away" turned the pin off and left the list stuck short of
184
+ // the bottom. Reaching the bottom by any means re-pins.
185
+ const pointerDownRef = useRef(false);
186
+ useEffect(() => {
187
+ const el = scrollEl;
188
+ if (!el) return;
189
+ const gap = () => el.scrollHeight - el.scrollTop - el.clientHeight;
190
+ const onScroll = () => {
191
+ if (gap() <= 100) pinnedRef.current = true;
192
+ else if (pointerDownRef.current) pinnedRef.current = false; // scrollbar drag
193
+ };
194
+ const onWheel = (e: WheelEvent) => {
195
+ if (e.deltaY < 0) pinnedRef.current = false;
196
+ };
197
+ const onTouchMove = () => {
198
+ if (gap() > 100) pinnedRef.current = false;
199
+ };
200
+ const onPointerDown = () => { pointerDownRef.current = true; };
201
+ const onPointerUp = () => { pointerDownRef.current = false; };
202
+ el.addEventListener("scroll", onScroll, { passive: true });
203
+ el.addEventListener("wheel", onWheel, { passive: true });
204
+ el.addEventListener("touchmove", onTouchMove, { passive: true });
205
+ el.addEventListener("pointerdown", onPointerDown, { passive: true });
206
+ window.addEventListener("pointerup", onPointerUp, { passive: true });
207
+ return () => {
208
+ el.removeEventListener("scroll", onScroll);
209
+ el.removeEventListener("wheel", onWheel);
210
+ el.removeEventListener("touchmove", onTouchMove);
211
+ el.removeEventListener("pointerdown", onPointerDown);
212
+ window.removeEventListener("pointerup", onPointerUp);
213
+ };
214
+ }, [scrollEl]);
215
+
216
+ useEffect(() => {
217
+ const el = scrollEl;
218
+ const content = spacerRef.current;
219
+ if (!el || !content) return;
220
+ const ro = new ResizeObserver(() => {
221
+ if (pinnedRef.current) el.scrollTop = el.scrollHeight;
222
+ });
223
+ ro.observe(content); // total virtual size changes (measure/grow/shrink/append)
224
+ ro.observe(el); // container resize (panel/keyboard/window)
225
+ return () => ro.disconnect();
226
+ }, [scrollEl]);
227
+
228
+ // Jump to the newest message on first mount, when the scroll element appears
229
+ // (it mounts late — after the loading screen), and on conversation swap.
146
230
  const conversationId = filtered[0]?.id;
147
231
  useLayoutEffect(() => {
232
+ pinnedRef.current = true;
148
233
  rowVirtualizer.scrollToEnd();
234
+ if (scrollEl) scrollEl.scrollTop = scrollEl.scrollHeight;
149
235
  // eslint-disable-next-line react-hooks/exhaustive-deps
150
- }, [conversationId]);
236
+ }, [conversationId, scrollEl]);
151
237
 
152
238
  // Stable fork handler — avoids new closure per message (preserves MessageBubble memo)
153
239
  const handleFork = useCallback((msgContent: string, msgId: string | undefined) => {
@@ -258,10 +344,30 @@ export function MessageList({
258
344
  Loading previous conversation...
259
345
  </div>
260
346
  )}
261
- <div ref={parentRef} className="flex-1 overflow-y-auto overflow-x-hidden [overflow-anchor:none]">
262
- <div style={{ height: rowVirtualizer.getTotalSize(), width: "100%", position: "relative" }}>
347
+ <div ref={setScrollRef} className="flex-1 overflow-y-auto overflow-x-hidden [overflow-anchor:none]">
348
+ <div ref={spacerRef} style={{ height: rowVirtualizer.getTotalSize(), width: "100%", position: "relative" }}>
263
349
  {virtualItems.map((vi) => {
264
350
  const globalIdx = vi.index;
351
+ // Trailing virtual row: approval card + "thinking…" indicator. Kept
352
+ // inside the virtual list so anchorTo:'end' scrolls it into view too.
353
+ if (globalIdx >= filtered.length) {
354
+ return (
355
+ <div
356
+ key={vi.key}
357
+ data-index={vi.index}
358
+ ref={rowVirtualizer.measureElement}
359
+ style={{ position: "absolute", top: 0, left: 0, width: "100%", transform: `translateY(${vi.start}px)` }}
360
+ className="px-4 pt-4 pb-4 space-y-4 select-none"
361
+ >
362
+ {pendingApproval && (
363
+ pendingApproval.tool === "AskUserQuestion"
364
+ ? <AskUserQuestionCard approval={pendingApproval} onRespond={onApprovalResponse} />
365
+ : <ApprovalCard approval={pendingApproval} onRespond={onApprovalResponse} />
366
+ )}
367
+ {isStreaming && <ThinkingIndicator lastMessage={messages[messages.length - 1]} phase={phase} elapsed={connectingElapsed} statusMessage={compactStatus === "compacting" ? "Compacting messages..." : statusMessage} />}
368
+ </div>
369
+ );
370
+ }
265
371
  const msg = filtered[globalIdx];
266
372
  if (!msg) return null;
267
373
  const prevMsg = globalIdx > 0 ? filtered[globalIdx - 1] : undefined;
@@ -318,19 +424,8 @@ export function MessageList({
318
424
  );
319
425
  })}
320
426
  </div>
321
-
322
- {/* Trailing (non-virtual) content pinned below the last message. */}
323
- <div className="px-4 pb-4 pt-4 space-y-4 select-none">
324
- {pendingApproval && (
325
- pendingApproval.tool === "AskUserQuestion"
326
- ? <AskUserQuestionCard approval={pendingApproval} onRespond={onApprovalResponse} />
327
- : <ApprovalCard approval={pendingApproval} onRespond={onApprovalResponse} />
328
- )}
329
-
330
- {isStreaming && <ThinkingIndicator lastMessage={messages[messages.length - 1]} phase={phase} elapsed={connectingElapsed} statusMessage={compactStatus === "compacting" ? "Compacting messages..." : statusMessage} />}
331
- </div>
332
427
  </div>
333
- <ScrollNavButtons virtualizer={rowVirtualizer} parentRef={parentRef} userIndices={userIndices} />
428
+ <ScrollNavButtons virtualizer={rowVirtualizer} parentRef={parentRef} userIndices={userIndices} pinnedRef={pinnedRef} />
334
429
  </div>
335
430
  );
336
431
  }
@@ -342,10 +437,11 @@ export function MessageList({
342
437
  * message above the top of the viewport; Down jumps to the nearest one below, or
343
438
  * to the very bottom when none remain.
344
439
  */
345
- function ScrollNavButtons({ virtualizer, parentRef, userIndices }: {
440
+ function ScrollNavButtons({ virtualizer, parentRef, userIndices, pinnedRef }: {
346
441
  virtualizer: Virtualizer<HTMLDivElement, Element>;
347
442
  parentRef: React.RefObject<HTMLDivElement | null>;
348
443
  userIndices: number[];
444
+ pinnedRef: React.MutableRefObject<boolean>;
349
445
  }) {
350
446
  const [hasAbove, setHasAbove] = useState(false);
351
447
  const [atBottom, setAtBottom] = useState(true);
@@ -395,15 +491,23 @@ function ScrollNavButtons({ virtualizer, parentRef, userIndices }: {
395
491
  if (i < topIndex) target = i; // ascending — keep the last one still above
396
492
  else break;
397
493
  }
398
- if (target != null) virtualizer.scrollToIndex(target, { align: "start" });
399
- }, [virtualizer, userIndices, getTopVisibleIndex]);
494
+ if (target != null) {
495
+ pinnedRef.current = false; // user is navigating away from the bottom
496
+ virtualizer.scrollToIndex(target, { align: "start" });
497
+ }
498
+ }, [virtualizer, userIndices, getTopVisibleIndex, pinnedRef]);
400
499
 
401
500
  const goDown = useCallback(() => {
402
501
  const topIndex = getTopVisibleIndex();
403
502
  const target = userIndices.find((i) => i > topIndex);
404
- if (target != null) virtualizer.scrollToIndex(target, { align: "start" });
405
- else virtualizer.scrollToEnd();
406
- }, [virtualizer, userIndices, getTopVisibleIndex]);
503
+ if (target != null) {
504
+ pinnedRef.current = false;
505
+ virtualizer.scrollToIndex(target, { align: "start" });
506
+ } else {
507
+ pinnedRef.current = true;
508
+ virtualizer.scrollToEnd();
509
+ }
510
+ }, [virtualizer, userIndices, getTopVisibleIndex, pinnedRef]);
407
511
 
408
512
  const btnClass =
409
513
  "size-8 flex items-center justify-center rounded-full bg-surface-elevated/60 border border-border/60 text-text-secondary shadow-md backdrop-blur-sm transition-all hover:bg-surface-elevated hover:text-foreground disabled:opacity-30 disabled:cursor-default disabled:hover:bg-surface-elevated/60 disabled:hover:text-text-secondary";