@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.
- package/CHANGELOG.md +8 -0
- package/assets/skills/ppm/SKILL.md +1 -1
- package/assets/skills/ppm/references/http-api.md +1 -1
- package/dist/web/assets/{audio-preview-Dd19CfSS.js → audio-preview-CJpcW3af.js} +1 -1
- package/dist/web/assets/chat-tab-bchStAIT.js +18 -0
- package/dist/web/assets/{code-editor-Tl7ISiKv.js → code-editor-CHgITnbd.js} +2 -2
- package/dist/web/assets/{conflict-editor-B5lmSt60.js → conflict-editor-CMeu434J.js} +1 -1
- package/dist/web/assets/{database-viewer-CN1xSIGE.js → database-viewer-BgsP9-uc.js} +1 -1
- package/dist/web/assets/{diff-viewer-DNRLiAkN.js → diff-viewer-DgKwBbYx.js} +1 -1
- package/dist/web/assets/{docx-preview-BpwwLe2H.js → docx-preview-BuiXNf6b.js} +1 -1
- package/dist/web/assets/{extension-webview-CA2vNGIv.js → extension-webview-D_NP0J9B.js} +1 -1
- package/dist/web/assets/{git-log-panel-Cy_lYOIT.js → git-log-panel-D9zXbgbE.js} +1 -1
- package/dist/web/assets/{glide-data-grid-3agxRvM7.js → glide-data-grid-DOF-6NWk.js} +1 -1
- package/dist/web/assets/{image-preview-DunIm1eD.js → image-preview-D1e_gJjP.js} +1 -1
- package/dist/web/assets/index-BKNIod9i.css +2 -0
- package/dist/web/assets/{index-Dq8HsKbm.js → index-CCC32WvB.js} +3 -3
- package/dist/web/assets/keybindings-store-Dm9xDJ6w.js +1 -0
- package/dist/web/assets/{markdown-renderer-CF-2Twzx.js → markdown-renderer-B6UQmUH_.js} +1 -1
- package/dist/web/assets/notification-store-B-ulw8mg.js +1 -0
- package/dist/web/assets/{pdf-preview-DKrKmPog.js → pdf-preview-BVQN975L.js} +1 -1
- package/dist/web/assets/{port-forwarding-tab-CAqB1X5v.js → port-forwarding-tab-CBXy17QJ.js} +1 -1
- package/dist/web/assets/{postgres-viewer-CfHuLQeX.js → postgres-viewer-CmAcvesE.js} +1 -1
- package/dist/web/assets/{settings-tab-CRLe4dCy.js → settings-tab-NWkMHtid.js} +1 -1
- package/dist/web/assets/{sql-query-editor-D_dLbWJd.js → sql-query-editor-Esdm2cZ4.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-DM4eBxf2.js → sqlite-viewer-BdbO9GAP.js} +1 -1
- package/dist/web/assets/{system-monitor-tab-F_pUeS3m.js → system-monitor-tab-9-Bo9QUX.js} +1 -1
- package/dist/web/assets/{terminal-tab-DRQoBHgo.js → terminal-tab-C0xzMiGC.js} +1 -1
- package/dist/web/assets/{video-preview-Cv_1L2N6.js → video-preview-Z0BxXbyC.js} +1 -1
- package/dist/web/index.html +2 -2
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/src/web/components/chat/message-list.tsx +130 -26
- package/dist/web/assets/chat-tab-Mkj4usuP.js +0 -18
- package/dist/web/assets/index-DDG-UPDA.css +0 -2
- package/dist/web/assets/keybindings-store-BlAnqPD8.js +0 -1
- 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
|
-
|
|
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
|
-
//
|
|
145
|
-
//
|
|
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={
|
|
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)
|
|
399
|
-
|
|
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)
|
|
405
|
-
|
|
406
|
-
|
|
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";
|