@hienlh/ppm 0.13.26 → 0.13.28

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 (34) hide show
  1. package/CHANGELOG.md +11 -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-DrmHuA-G.js → audio-preview-DL-OtRC9.js} +1 -1
  5. package/dist/web/assets/chat-tab-ChHeUbi6.js +12 -0
  6. package/dist/web/assets/{code-editor-4MBhv6Od.js → code-editor-DZvviyVX.js} +2 -2
  7. package/dist/web/assets/{conflict-editor-CSxdnpiG.js → conflict-editor-Ae1WLZpv.js} +1 -1
  8. package/dist/web/assets/{database-viewer-XcGue74R.js → database-viewer-CCICxO9M.js} +1 -1
  9. package/dist/web/assets/{diff-viewer-CGc4N0js.js → diff-viewer-CBE_cN6N.js} +1 -1
  10. package/dist/web/assets/{extension-webview-DTOGXnGR.js → extension-webview-CIkO_pa4.js} +1 -1
  11. package/dist/web/assets/{glide-data-grid-C2WvgT1J.js → glide-data-grid-_nnWqMUN.js} +1 -1
  12. package/dist/web/assets/{image-preview-f1WDtlkM.js → image-preview-Cdb0Sc4L.js} +1 -1
  13. package/dist/web/assets/{index-lUbclLL5.js → index-BbVelBGY.js} +3 -3
  14. package/dist/web/assets/index-DmkeN7Eo.css +2 -0
  15. package/dist/web/assets/keybindings-store-CGRLyrRW.js +1 -0
  16. package/dist/web/assets/{markdown-renderer-BaSBTBlu.js → markdown-renderer-BCnVrEK7.js} +1 -1
  17. package/dist/web/assets/notification-store-B29tRj0q.js +1 -0
  18. package/dist/web/assets/{pdf-preview-BhhXBrgJ.js → pdf-preview-DnONDTTg.js} +1 -1
  19. package/dist/web/assets/{port-forwarding-tab-BeuYRS6h.js → port-forwarding-tab-DkFth4Rt.js} +1 -1
  20. package/dist/web/assets/{postgres-viewer-DhV5wpcs.js → postgres-viewer-DzCDLjbV.js} +1 -1
  21. package/dist/web/assets/{settings-tab-i_1RYDNd.js → settings-tab-BPeOOXw1.js} +1 -1
  22. package/dist/web/assets/{sql-query-editor-lKOK8JsE.js → sql-query-editor-B9uNn4Y3.js} +1 -1
  23. package/dist/web/assets/{sqlite-viewer-81v28Si-.js → sqlite-viewer-BRX2lt6O.js} +1 -1
  24. package/dist/web/assets/{terminal-tab-DheGnuJt.js → terminal-tab-B2W7MQRx.js} +1 -1
  25. package/dist/web/assets/{video-preview-C1RTCvxl.js → video-preview-CxiB4qdZ.js} +1 -1
  26. package/dist/web/index.html +2 -2
  27. package/dist/web/sw.js +1 -1
  28. package/package.json +1 -1
  29. package/src/web/components/chat/message-list.tsx +94 -100
  30. package/src/web/components/shared/markdown-error-boundary.tsx +38 -0
  31. package/dist/web/assets/chat-tab-DY7pY-uK.js +0 -12
  32. package/dist/web/assets/index-Csu6hOB7.css +0 -2
  33. package/dist/web/assets/keybindings-store-DChuOfXo.js +0 -1
  34. package/dist/web/assets/notification-store-DLSGCmV8.js +0 -1
@@ -5,11 +5,12 @@ import type { ChatMessage, ChatEvent } from "../../../types/chat";
5
5
  import type { SessionPhase } from "../../../types/api";
6
6
  import type { BashPartialEntry } from "../../hooks/use-chat";
7
7
  import { ToolCard } from "./tool-cards";
8
- import { extractJsonlPath, PreCompactButton, type PreCompactStatus } from "./pre-compact-button";
8
+ import { extractJsonlPath } from "./pre-compact-button";
9
9
  const MarkdownRenderer = lazy(() =>
10
10
  import("@/components/shared/markdown-renderer").then((m) => ({ default: m.MarkdownRenderer }))
11
11
  );
12
12
  import { cn, basename } from "@/lib/utils";
13
+ import { RenderErrorBoundary } from "@/components/shared/markdown-error-boundary";
13
14
 
14
15
  import {
15
16
  AlertCircle,
@@ -102,7 +103,7 @@ export function MessageList({
102
103
  return filtered.slice(start);
103
104
  }, [filtered, visibleCount]);
104
105
 
105
- const hasMore = visibleCount < filtered.length;
106
+ const hasMoreInMemory = visibleCount < filtered.length;
106
107
 
107
108
  // Stable fork handler — avoids new closure per message (preserves MessageBubble memo)
108
109
  const handleFork = useCallback((msgContent: string, msgId: string | undefined) => {
@@ -110,22 +111,53 @@ export function MessageList({
110
111
  }, [onFork]);
111
112
 
112
113
  // Scroll anchor bridge published from inside StickToBottom (needs the context's scrollRef).
113
- // MessageList captures pre-expand scroll metrics and restores post-render so the compact
114
- // message stays at the same viewport offset when history is prepended.
115
114
  const scrollAnchorRef = useRef<ScrollAnchorHandle | null>(null);
116
115
 
117
- // Wrap expandCompact: bump visibleCount, then restore scroll after React commits the new DOM.
118
- // Pre-compact messages land at top of flattened array, above pagination window bumping
119
- // visibleCount by loaded count ensures they render immediately.
120
- const handleExpandCompact = useCallback(async (compactId: string, jsonlPath: string): Promise<number> => {
121
- if (!onExpandCompact) throw new Error("Expansion not wired");
122
- scrollAnchorRef.current?.capture();
123
- const count = await onExpandCompact(compactId, jsonlPath);
124
- setVisibleCount((c) => c + count);
125
- // rAF fires after React commits + layout; two rAFs to cover any async measure (lazy markdown).
126
- requestAnimationFrame(() => requestAnimationFrame(() => scrollAnchorRef.current?.restore()));
127
- return count;
128
- }, [onExpandCompact]);
116
+ // Find the topmost displayed message that has an unexpanded compact JSONL path.
117
+ const findTopUnexpandedCompact = useCallback((): { id: string; jsonlPath: string } | null => {
118
+ if (!onExpandCompact || !isCompactExpanded) return null;
119
+ for (const msg of displayed) {
120
+ if (isCompactExpanded(msg.id)) continue;
121
+ // Check user message content for JSONL path
122
+ const path = extractJsonlPath(msg.content || "");
123
+ if (path) return { id: msg.id, jsonlPath: path };
124
+ // Check assistant events for JSONL path
125
+ if (msg.events) {
126
+ for (const ev of msg.events) {
127
+ if (ev.type === "text") {
128
+ const evPath = extractJsonlPath(ev.content || "");
129
+ if (evPath) return { id: msg.id, jsonlPath: evPath };
130
+ }
131
+ }
132
+ }
133
+ }
134
+ return null;
135
+ }, [displayed, onExpandCompact, isCompactExpanded]);
136
+
137
+ // Unified load-more: first show more in-memory messages, then auto-expand compact history
138
+ const [autoLoadingCompact, setAutoLoadingCompact] = useState(false);
139
+ const loadMore = useCallback(async () => {
140
+ if (hasMoreInMemory) {
141
+ scrollAnchorRef.current?.capture();
142
+ setVisibleCount((c) => c + PAGE_SIZE);
143
+ requestAnimationFrame(() => requestAnimationFrame(() => scrollAnchorRef.current?.restore()));
144
+ return;
145
+ }
146
+ // All in-memory messages visible — try expanding topmost compact
147
+ const compact = findTopUnexpandedCompact();
148
+ if (!compact || !onExpandCompact || autoLoadingCompact) return;
149
+ setAutoLoadingCompact(true);
150
+ try {
151
+ scrollAnchorRef.current?.capture();
152
+ const count = await onExpandCompact(compact.id, compact.jsonlPath);
153
+ setVisibleCount((c) => c + count);
154
+ requestAnimationFrame(() => requestAnimationFrame(() => scrollAnchorRef.current?.restore()));
155
+ } finally {
156
+ setAutoLoadingCompact(false);
157
+ }
158
+ }, [hasMoreInMemory, findTopUnexpandedCompact, onExpandCompact, autoLoadingCompact]);
159
+
160
+ const hasMore = hasMoreInMemory || !!findTopUnexpandedCompact();
129
161
 
130
162
  if (messagesLoading) {
131
163
  return (
@@ -151,26 +183,22 @@ export function MessageList({
151
183
  <StickToBottom.Content className="p-4 space-y-4 select-none [&>*]:[overflow-anchor:auto]">
152
184
  <ScrollAnchorBridge bridgeRef={scrollAnchorRef} />
153
185
  {hasMore && (
154
- <button onClick={() => setVisibleCount((c) => c + PAGE_SIZE)}
155
- className="w-full py-2 text-xs text-text-secondary hover:text-text-primary bg-surface-elevated/50 hover:bg-surface-elevated rounded-md border border-border/50 transition-colors">
156
- Load {Math.min(PAGE_SIZE, filtered.length - visibleCount)} more messages...
157
- </button>
186
+ <LoadMoreSentinel onLoadMore={loadMore} loading={autoLoadingCompact} />
158
187
  )}
159
188
  {displayed.map((msg, idx) => {
160
189
  const globalIdx = filtered.length - displayed.length + idx;
161
190
  const prevMsg = globalIdx > 0 ? filtered[globalIdx - 1] : undefined;
162
191
  return (
163
- <MessageBubble
164
- key={msg.id}
165
- message={msg}
166
- isStreaming={isStreaming && msg.id.startsWith("streaming-")}
167
- projectName={projectName}
168
- onFork={msg.role === "user" && onFork ? handleFork : undefined}
169
- prevMsgId={prevMsg?.sdkUuid ?? prevMsg?.id}
170
- bashPartialOutput={bashPartialOutput}
171
- onExpandCompact={handleExpandCompact}
172
- isCompactExpanded={isCompactExpanded}
173
- />
192
+ <RenderErrorBoundary key={msg.id} fallbackContent={msg.content}>
193
+ <MessageBubble
194
+ message={msg}
195
+ isStreaming={isStreaming && msg.id.startsWith("streaming-")}
196
+ projectName={projectName}
197
+ onFork={msg.role === "user" && onFork ? handleFork : undefined}
198
+ prevMsgId={prevMsg?.sdkUuid ?? prevMsg?.id}
199
+ bashPartialOutput={bashPartialOutput}
200
+ />
201
+ </RenderErrorBoundary>
174
202
  );
175
203
  })}
176
204
 
@@ -240,13 +268,35 @@ function ScrollToBottomButton() {
240
268
  );
241
269
  }
242
270
 
243
- const MessageBubble = memo(function MessageBubble({ message, isStreaming, projectName, onFork, prevMsgId, bashPartialOutput, onExpandCompact, isCompactExpanded }: {
271
+ /** IntersectionObserver sentinel auto-triggers loadMore when scrolled near top */
272
+ function LoadMoreSentinel({ onLoadMore, loading }: { onLoadMore: () => void; loading: boolean }) {
273
+ const sentinelRef = useRef<HTMLDivElement>(null);
274
+ const onLoadMoreRef = useRef(onLoadMore);
275
+ onLoadMoreRef.current = onLoadMore;
276
+
277
+ useEffect(() => {
278
+ const el = sentinelRef.current;
279
+ if (!el) return;
280
+ const observer = new IntersectionObserver(
281
+ ([entry]) => { if (entry?.isIntersecting) onLoadMoreRef.current(); },
282
+ { rootMargin: "200px 0px 0px 0px" },
283
+ );
284
+ observer.observe(el);
285
+ return () => observer.disconnect();
286
+ }, []);
287
+
288
+ return (
289
+ <div ref={sentinelRef} className="flex items-center justify-center py-2 text-xs text-text-secondary">
290
+ {loading && <><Loader2 className="size-3 animate-spin mr-1.5" />Loading previous conversation...</>}
291
+ </div>
292
+ );
293
+ }
294
+
295
+ const MessageBubble = memo(function MessageBubble({ message, isStreaming, projectName, onFork, prevMsgId, bashPartialOutput }: {
244
296
  message: ChatMessage; isStreaming: boolean; projectName?: string;
245
297
  onFork?: (content: string, messageId: string | undefined) => void;
246
298
  prevMsgId?: string;
247
299
  bashPartialOutput?: React.RefObject<Map<string, BashPartialEntry>>;
248
- onExpandCompact?: (compactMessageId: string, jsonlPath: string) => Promise<number>;
249
- isCompactExpanded?: (compactMessageId: string) => boolean;
250
300
  }) {
251
301
  if (message.role === "user") {
252
302
  const handleFork = onFork ? () => onFork(message.content, prevMsgId) : undefined;
@@ -256,8 +306,6 @@ const MessageBubble = memo(function MessageBubble({ message, isStreaming, projec
256
306
  messageId={message.id}
257
307
  projectName={projectName}
258
308
  onFork={handleFork}
259
- onExpandCompact={onExpandCompact}
260
- isCompactExpanded={isCompactExpanded}
261
309
  />
262
310
  );
263
311
  }
@@ -275,7 +323,7 @@ const MessageBubble = memo(function MessageBubble({ message, isStreaming, projec
275
323
  return (
276
324
  <div className="flex flex-col gap-2">
277
325
  {message.events && message.events.length > 0
278
- ? <InterleavedEvents events={message.events} isStreaming={isStreaming} projectName={projectName} bashPartialOutput={bashPartialOutput} messageId={message.id} onExpandCompact={onExpandCompact} isCompactExpanded={isCompactExpanded} />
326
+ ? <InterleavedEvents events={message.events} isStreaming={isStreaming} projectName={projectName} bashPartialOutput={bashPartialOutput} />
279
327
  : message.content && (
280
328
  <div className="text-sm text-text-primary select-text">
281
329
  <MarkdownContent content={message.content} projectName={projectName} />
@@ -382,42 +430,22 @@ function isPdfPath(path: string): boolean {
382
430
  const SYSTEM_TAG_NAMES = new Set(["task-notification", "environment_details"]);
383
431
 
384
432
  /** User message bubble — full width, collapsible, with system tag badges */
385
- function UserBubble({ content, messageId, projectName, onFork, onExpandCompact, isCompactExpanded }: {
433
+ function UserBubble({ content, messageId, projectName, onFork }: {
386
434
  content: string;
387
435
  messageId?: string;
388
436
  projectName?: string;
389
437
  onFork?: () => void;
390
- onExpandCompact?: (compactMessageId: string, jsonlPath: string) => Promise<number>;
391
- isCompactExpanded?: (compactMessageId: string) => boolean;
392
438
  }) {
393
- const { files, text, tags, command, jsonlPath } = useMemo(() => {
439
+ const { files, text, tags, command } = useMemo(() => {
394
440
  const parsed = parseUserAttachments(content);
395
441
  const { cleanText: noSysTags, tags } = extractSystemTags(parsed.text);
396
442
  const { command, cleanText } = parseCommandTags(noSysTags);
397
- // Merge command args into body text so line-clamp + Show more applies uniformly
398
443
  const bodyText = command?.args
399
444
  ? (cleanText ? `${command.args}\n\n${cleanText}` : command.args)
400
445
  : cleanText;
401
- return { files: parsed.files, text: bodyText, tags, command, jsonlPath: extractJsonlPath(cleanText) };
446
+ return { files: parsed.files, text: bodyText, tags, command };
402
447
  }, [content]);
403
448
 
404
- // Pre-compact expansion state — local per button instance
405
- const [preCompactStatus, setPreCompactStatus] = useState<PreCompactStatus>(() =>
406
- messageId && isCompactExpanded?.(messageId) ? "loaded" : "idle",
407
- );
408
- const [preCompactCount, setPreCompactCount] = useState<number | undefined>();
409
- const handleExpand = useCallback(async () => {
410
- if (!jsonlPath || !messageId || !onExpandCompact) return;
411
- setPreCompactStatus("loading");
412
- try {
413
- const count = await onExpandCompact(messageId, jsonlPath);
414
- setPreCompactCount(count);
415
- setPreCompactStatus("loaded");
416
- } catch {
417
- setPreCompactStatus("error");
418
- }
419
- }, [jsonlPath, messageId, onExpandCompact]);
420
-
421
449
  const isSystemContext = tags.some((t) => SYSTEM_TAG_NAMES.has(t.name));
422
450
 
423
451
  const [expanded, setExpanded] = useState(false);
@@ -497,15 +525,6 @@ function UserBubble({ content, messageId, projectName, onFork, onExpandCompact,
497
525
  {expanded ? <><ChevronUp className="size-3" />Show less</> : <><ChevronDown className="size-3" />Show more</>}
498
526
  </button>
499
527
  )}
500
- {/* Expand compacted conversation: detect JSONL path in compact summary user message.
501
- Prepends pre-compact messages into main flattened list (see useChat.expandCompact). */}
502
- {jsonlPath && messageId && onExpandCompact && (
503
- <PreCompactButton
504
- status={preCompactStatus}
505
- onLoad={preCompactStatus === "idle" || preCompactStatus === "error" ? handleExpand : undefined}
506
- count={preCompactCount}
507
- />
508
- )}
509
528
  {/* Fork/Rewind button — only for real user messages */}
510
529
  {!isSystemContext && onFork && (
511
530
  <button
@@ -780,31 +799,12 @@ type EventGroup =
780
799
  | { kind: "thinking"; content: string }
781
800
  | { kind: "tool"; tool: ChatEvent; result?: ChatEvent; completed?: boolean };
782
801
 
783
- function InterleavedEvents({ events, isStreaming, projectName, bashPartialOutput, messageId, onExpandCompact, isCompactExpanded }: {
802
+ function InterleavedEvents({ events, isStreaming, projectName, bashPartialOutput }: {
784
803
  events: ChatEvent[];
785
804
  isStreaming: boolean;
786
805
  projectName?: string;
787
806
  bashPartialOutput?: React.RefObject<Map<string, BashPartialEntry>>;
788
- messageId?: string;
789
- onExpandCompact?: (compactMessageId: string, jsonlPath: string) => Promise<number>;
790
- isCompactExpanded?: (compactMessageId: string) => boolean;
791
807
  }) {
792
- // Local state for the /compact slash-command path (assistant-authored summary)
793
- const [preCompactStatus, setPreCompactStatus] = useState<PreCompactStatus>(() =>
794
- messageId && isCompactExpanded?.(messageId) ? "loaded" : "idle",
795
- );
796
- const [preCompactCount, setPreCompactCount] = useState<number | undefined>();
797
- const handleExpand = useCallback(async (jsonlPath: string) => {
798
- if (!messageId || !onExpandCompact) return;
799
- setPreCompactStatus("loading");
800
- try {
801
- const count = await onExpandCompact(messageId, jsonlPath);
802
- setPreCompactCount(count);
803
- setPreCompactStatus("loaded");
804
- } catch {
805
- setPreCompactStatus("error");
806
- }
807
- }, [messageId, onExpandCompact]);
808
808
  // Group: consecutive text → merged text block; tool_use + tool_result paired by toolUseId
809
809
  const groups: EventGroup[] = [];
810
810
  let textBuffer = "";
@@ -919,17 +919,9 @@ function InterleavedEvents({ events, isStreaming, projectName, bashPartialOutput
919
919
  }
920
920
  if (group.kind === "text") {
921
921
  const isLast = isStreaming && i === groups.length - 1;
922
- const jsonlPath = extractJsonlPath(group.content);
923
922
  return (
924
923
  <div key={`text-${i}`} className="text-sm text-text-primary select-text">
925
924
  <StreamingText content={group.content} animate={isLast} projectName={projectName} />
926
- {jsonlPath && messageId && onExpandCompact && (
927
- <PreCompactButton
928
- status={preCompactStatus}
929
- onLoad={preCompactStatus === "idle" || preCompactStatus === "error" ? () => handleExpand(jsonlPath) : undefined}
930
- count={preCompactCount}
931
- />
932
- )}
933
925
  </div>
934
926
  );
935
927
  }
@@ -1053,9 +1045,11 @@ function MarkdownContent({ content, projectName, isStreaming }: { content: strin
1053
1045
  const cleaned = stripTeammateMessages(content);
1054
1046
  if (!cleaned) return null;
1055
1047
  return (
1056
- <Suspense fallback={<div className="animate-pulse h-4 bg-muted rounded" />}>
1057
- <MarkdownRenderer content={cleaned} projectName={projectName} codeActions isStreaming={isStreaming} />
1058
- </Suspense>
1048
+ <RenderErrorBoundary fallbackContent={cleaned}>
1049
+ <Suspense fallback={<div className="animate-pulse h-4 bg-muted rounded" />}>
1050
+ <MarkdownRenderer content={cleaned} projectName={projectName} codeActions isStreaming={isStreaming} />
1051
+ </Suspense>
1052
+ </RenderErrorBoundary>
1059
1053
  );
1060
1054
  }
1061
1055
 
@@ -0,0 +1,38 @@
1
+ import { Component, type ReactNode } from "react";
2
+
3
+ interface Props {
4
+ /** Plain text fallback when fallback ReactNode is not provided */
5
+ fallbackContent?: string;
6
+ /** Custom fallback ReactNode — takes precedence over fallbackContent */
7
+ fallback?: ReactNode;
8
+ children: ReactNode;
9
+ }
10
+
11
+ interface State {
12
+ hasError: boolean;
13
+ }
14
+
15
+ /**
16
+ * Error boundary that catches React DOM reconciliation errors
17
+ * (e.g. "removeChild" failures from rehype-raw or browser extensions).
18
+ * Falls back to provided content instead of crashing the whole app.
19
+ */
20
+ export class RenderErrorBoundary extends Component<Props, State> {
21
+ override state: State = { hasError: false };
22
+
23
+ static getDerivedStateFromError(): State {
24
+ return { hasError: true };
25
+ }
26
+
27
+ override render() {
28
+ if (this.state.hasError) {
29
+ if (this.props.fallback) return this.props.fallback;
30
+ return this.props.fallbackContent ? (
31
+ <div className="text-sm whitespace-pre-wrap break-words text-text-primary opacity-80">
32
+ {this.props.fallbackContent}
33
+ </div>
34
+ ) : null;
35
+ }
36
+ return this.props.children;
37
+ }
38
+ }