@hienlh/ppm 0.13.25 → 0.13.27

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 (41) 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-o756_lUz.js → audio-preview-Dpmcl747.js} +1 -1
  5. package/dist/web/assets/chat-tab-DXBTsnF8.js +12 -0
  6. package/dist/web/assets/code-editor-Dj6Bpl_2.js +8 -0
  7. package/dist/web/assets/{conflict-editor-DpG5ZK2d.js → conflict-editor-DWmEigpj.js} +1 -1
  8. package/dist/web/assets/{database-viewer-C2EaJWuR.js → database-viewer-DWBFfKPT.js} +1 -1
  9. package/dist/web/assets/{diff-viewer-I3y18uIy.js → diff-viewer-Bm0_jtBc.js} +1 -1
  10. package/dist/web/assets/{extension-webview-BJ5RsGxi.js → extension-webview-BgsfCSSv.js} +2 -2
  11. package/dist/web/assets/{glide-data-grid-DAQyTRmX.js → glide-data-grid-CgBR08p4.js} +3 -3
  12. package/dist/web/assets/{image-preview-Da0-pK7U.js → image-preview-VYdq-6Me.js} +1 -1
  13. package/dist/web/assets/index-BG3vkzX-.js +27 -0
  14. package/dist/web/assets/index-DmkeN7Eo.css +2 -0
  15. package/dist/web/assets/keybindings-store-D4bcke1T.js +1 -0
  16. package/dist/web/assets/{markdown-renderer-DeuGyN7e.js → markdown-renderer-B2bQd2Zz.js} +1 -1
  17. package/dist/web/assets/notification-store-cWjv3Qu4.js +1 -0
  18. package/dist/web/assets/{pdf-preview-BAcJhOc0.js → pdf-preview-RCOjBSNz.js} +1 -1
  19. package/dist/web/assets/port-forwarding-tab-DSs4ce0D.js +1 -0
  20. package/dist/web/assets/{postgres-viewer-C9ouNMds.js → postgres-viewer-BET58NOb.js} +2 -2
  21. package/dist/web/assets/{settings-tab-DjrtWjMi.js → settings-tab-C7LHGpuM.js} +1 -1
  22. package/dist/web/assets/{sql-query-editor-vIJNWRZR.js → sql-query-editor-DueILhFD.js} +1 -1
  23. package/dist/web/assets/{sqlite-viewer-8KsTrAAv.js → sqlite-viewer-qrmWrdx6.js} +1 -1
  24. package/dist/web/assets/terminal-tab-WwRXVV_9.js +1 -0
  25. package/dist/web/assets/{video-preview-D_UOFgju.js → video-preview-BwNo3VVV.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/chat-history-bar.tsx +32 -13
  30. package/src/web/components/chat/chat-tab.tsx +1 -1
  31. package/src/web/components/chat/message-list.tsx +84 -91
  32. package/src/web/components/shared/markdown-error-boundary.tsx +35 -0
  33. package/src/web/stores/notification-store.ts +12 -0
  34. package/dist/web/assets/chat-tab-7eUBtble.js +0 -12
  35. package/dist/web/assets/code-editor-I6rJozDs.js +0 -8
  36. package/dist/web/assets/index-B7c42LjL.js +0 -27
  37. package/dist/web/assets/index-C5sLGvFC.css +0 -2
  38. package/dist/web/assets/keybindings-store-Ban22mlc.js +0 -1
  39. package/dist/web/assets/notification-store-DpmwVsn4.js +0 -1
  40. package/dist/web/assets/port-forwarding-tab-BYzipE8D.js +0 -1
  41. package/dist/web/assets/terminal-tab-BYyzleHf.js +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hienlh/ppm",
3
- "version": "0.13.25",
3
+ "version": "0.13.27",
4
4
  "description": "Personal Project Manager — mobile-first web IDE with AI assistance",
5
5
  "author": "hienlh",
6
6
  "license": "MIT",
@@ -1,9 +1,10 @@
1
- import { useState, useEffect, useCallback, useRef } from "react";
1
+ import { useState, useEffect, useCallback, useRef, type MouseEvent } from "react";
2
2
  import { History, Settings2, Loader2, MessageSquare, RefreshCw, Search, Pencil, Check, X, BellOff, Bug, ClipboardCheck, Pin, PinOff, Trash2, Users, Bot, Tags, CalendarX2 } from "lucide-react";
3
3
  import { Activity } from "lucide-react";
4
4
  import { api, projectUrl } from "@/lib/api-client";
5
5
  import { useTabStore } from "@/stores/tab-store";
6
- import { useNotificationStore } from "@/stores/notification-store";
6
+ import { useNotificationStore, notificationTint } from "@/stores/notification-store";
7
+ import { cn } from "@/lib/utils";
7
8
  import { AISettingsSection } from "@/components/settings/ai-settings-section";
8
9
  import { TagSettingsSection } from "@/components/settings/tag-settings-section";
9
10
  import { SessionContextMenu } from "./session-context-menu";
@@ -36,7 +37,7 @@ interface ChatHistoryBarProps {
36
37
  onSelectSession?: (session: SessionInfo) => void;
37
38
  onBugReport?: () => void;
38
39
  isConnected?: boolean;
39
- onReconnect?: () => void;
40
+ onReload?: () => void;
40
41
  teamActivity?: TeamActivityState;
41
42
  teamMessages?: TeamMessageItem[];
42
43
  onTeamOpen?: () => void;
@@ -91,12 +92,13 @@ function DebugCopyButton({ sessionId, projectName }: { sessionId: string; projec
91
92
 
92
93
  export function ChatHistoryBar({
93
94
  projectName, usageInfo, usageLoading, refreshUsage, lastFetchedAt,
94
- sessionId, providerId, onSelectSession, onBugReport, isConnected, onReconnect,
95
+ sessionId, providerId, onSelectSession, onBugReport, isConnected, onReload,
95
96
  teamActivity, teamMessages, onTeamOpen,
96
97
  }: ChatHistoryBarProps) {
97
98
  const [activePanel, setActivePanel] = useState<PanelType>(null);
98
99
  const [sessions, setSessions] = useState<SessionInfo[]>([]);
99
100
  const [loading, setLoading] = useState(false);
101
+ const notifications = useNotificationStore((s) => s.notifications);
100
102
  const hasUnread = useNotificationStore((s) => sessionId ? s.notifications.has(sessionId) : false);
101
103
  const clearForSession = useNotificationStore((s) => s.clearForSession);
102
104
  const [searchQuery, setSearchQuery] = useState("");
@@ -394,14 +396,22 @@ export function ChatHistoryBar({
394
396
  <DebugCopyButton sessionId={sessionId} projectName={projectName} />
395
397
  )}
396
398
 
397
- {/* Connection indicator */}
398
- {onReconnect && (
399
+ {/* Reload messages + connection status */}
400
+ {onReload && (
399
401
  <button
400
- onClick={onReconnect}
401
- className="size-4 flex items-center justify-center"
402
- title={isConnected ? "Connected" : "Disconnected — click to reconnect"}
402
+ onClick={(e: MouseEvent<HTMLButtonElement>) => {
403
+ const icon = e.currentTarget.querySelector("svg");
404
+ if (icon) {
405
+ icon.classList.add("animate-spin");
406
+ setTimeout(() => icon.classList.remove("animate-spin"), 600);
407
+ }
408
+ onReload();
409
+ }}
410
+ className="relative size-4 flex items-center justify-center"
411
+ title={isConnected ? "Reload messages" : "Disconnected — click to reload"}
403
412
  >
404
- <span className={`size-2 rounded-full ${isConnected ? "bg-green-500" : "bg-red-500 animate-pulse"}`} />
413
+ <RefreshCw className={`size-3 ${isConnected ? "text-muted-foreground/60" : "text-red-400"}`} strokeWidth={2.5} />
414
+ <span className={`absolute -top-0.5 -right-0.5 size-1.5 rounded-full ${isConnected ? "bg-green-500" : "bg-red-500 animate-pulse"}`} />
405
415
  </button>
406
416
  )}
407
417
  </div>
@@ -488,7 +498,10 @@ export function ChatHistoryBar({
488
498
  </div>
489
499
  ) : (
490
500
  <>
491
- {filteredSessions.map((session) => (
501
+ {filteredSessions.map((session) => {
502
+ const notif = notifications.get(session.id);
503
+ const isUnread = !!notif;
504
+ return (
492
505
  <SessionContextMenu
493
506
  key={session.id}
494
507
  session={session}
@@ -500,7 +513,12 @@ export function ChatHistoryBar({
500
513
  onTagChanged={handleTagChanged}
501
514
  >
502
515
  <div
503
- className="flex items-center gap-2 w-full px-3 py-1.5 text-left hover:bg-surface-elevated transition-colors group"
516
+ className={cn(
517
+ "flex items-center gap-2 w-full px-3 py-1.5 text-left hover:bg-surface-elevated transition-colors group",
518
+ isUnread && "font-medium text-foreground",
519
+ isUnread && notificationTint(notif.type),
520
+ !isUnread && "text-text-secondary",
521
+ )}
504
522
  >
505
523
  <ProviderBadge providerId={session.providerId} />
506
524
  {session.tag && (
@@ -572,7 +590,8 @@ export function ChatHistoryBar({
572
590
  )}
573
591
  </div>
574
592
  </SessionContextMenu>
575
- ))}
593
+ );
594
+ })}
576
595
  {hasMore && (
577
596
  <button
578
597
  onClick={loadMore}
@@ -424,7 +424,7 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
424
424
  onSelectSession={handleSelectSession}
425
425
  onBugReport={sessionId ? () => openBugReportPopup(version, { sessionId, projectName }) : undefined}
426
426
  isConnected={isConnected}
427
- onReconnect={() => {
427
+ onReload={() => {
428
428
  if (!isConnected) reconnect();
429
429
  refetchMessages();
430
430
  }}
@@ -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 { MarkdownErrorBoundary } 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,10 +183,7 @@ 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;
@@ -168,8 +197,6 @@ export function MessageList({
168
197
  onFork={msg.role === "user" && onFork ? handleFork : undefined}
169
198
  prevMsgId={prevMsg?.sdkUuid ?? prevMsg?.id}
170
199
  bashPartialOutput={bashPartialOutput}
171
- onExpandCompact={handleExpandCompact}
172
- isCompactExpanded={isCompactExpanded}
173
200
  />
174
201
  );
175
202
  })}
@@ -240,13 +267,35 @@ function ScrollToBottomButton() {
240
267
  );
241
268
  }
242
269
 
243
- const MessageBubble = memo(function MessageBubble({ message, isStreaming, projectName, onFork, prevMsgId, bashPartialOutput, onExpandCompact, isCompactExpanded }: {
270
+ /** IntersectionObserver sentinel auto-triggers loadMore when scrolled near top */
271
+ function LoadMoreSentinel({ onLoadMore, loading }: { onLoadMore: () => void; loading: boolean }) {
272
+ const sentinelRef = useRef<HTMLDivElement>(null);
273
+ const onLoadMoreRef = useRef(onLoadMore);
274
+ onLoadMoreRef.current = onLoadMore;
275
+
276
+ useEffect(() => {
277
+ const el = sentinelRef.current;
278
+ if (!el) return;
279
+ const observer = new IntersectionObserver(
280
+ ([entry]) => { if (entry?.isIntersecting) onLoadMoreRef.current(); },
281
+ { rootMargin: "200px 0px 0px 0px" },
282
+ );
283
+ observer.observe(el);
284
+ return () => observer.disconnect();
285
+ }, []);
286
+
287
+ return (
288
+ <div ref={sentinelRef} className="flex items-center justify-center py-2 text-xs text-text-secondary">
289
+ {loading && <><Loader2 className="size-3 animate-spin mr-1.5" />Loading previous conversation...</>}
290
+ </div>
291
+ );
292
+ }
293
+
294
+ const MessageBubble = memo(function MessageBubble({ message, isStreaming, projectName, onFork, prevMsgId, bashPartialOutput }: {
244
295
  message: ChatMessage; isStreaming: boolean; projectName?: string;
245
296
  onFork?: (content: string, messageId: string | undefined) => void;
246
297
  prevMsgId?: string;
247
298
  bashPartialOutput?: React.RefObject<Map<string, BashPartialEntry>>;
248
- onExpandCompact?: (compactMessageId: string, jsonlPath: string) => Promise<number>;
249
- isCompactExpanded?: (compactMessageId: string) => boolean;
250
299
  }) {
251
300
  if (message.role === "user") {
252
301
  const handleFork = onFork ? () => onFork(message.content, prevMsgId) : undefined;
@@ -256,8 +305,6 @@ const MessageBubble = memo(function MessageBubble({ message, isStreaming, projec
256
305
  messageId={message.id}
257
306
  projectName={projectName}
258
307
  onFork={handleFork}
259
- onExpandCompact={onExpandCompact}
260
- isCompactExpanded={isCompactExpanded}
261
308
  />
262
309
  );
263
310
  }
@@ -275,7 +322,7 @@ const MessageBubble = memo(function MessageBubble({ message, isStreaming, projec
275
322
  return (
276
323
  <div className="flex flex-col gap-2">
277
324
  {message.events && message.events.length > 0
278
- ? <InterleavedEvents events={message.events} isStreaming={isStreaming} projectName={projectName} bashPartialOutput={bashPartialOutput} messageId={message.id} onExpandCompact={onExpandCompact} isCompactExpanded={isCompactExpanded} />
325
+ ? <InterleavedEvents events={message.events} isStreaming={isStreaming} projectName={projectName} bashPartialOutput={bashPartialOutput} />
279
326
  : message.content && (
280
327
  <div className="text-sm text-text-primary select-text">
281
328
  <MarkdownContent content={message.content} projectName={projectName} />
@@ -382,42 +429,22 @@ function isPdfPath(path: string): boolean {
382
429
  const SYSTEM_TAG_NAMES = new Set(["task-notification", "environment_details"]);
383
430
 
384
431
  /** User message bubble — full width, collapsible, with system tag badges */
385
- function UserBubble({ content, messageId, projectName, onFork, onExpandCompact, isCompactExpanded }: {
432
+ function UserBubble({ content, messageId, projectName, onFork }: {
386
433
  content: string;
387
434
  messageId?: string;
388
435
  projectName?: string;
389
436
  onFork?: () => void;
390
- onExpandCompact?: (compactMessageId: string, jsonlPath: string) => Promise<number>;
391
- isCompactExpanded?: (compactMessageId: string) => boolean;
392
437
  }) {
393
- const { files, text, tags, command, jsonlPath } = useMemo(() => {
438
+ const { files, text, tags, command } = useMemo(() => {
394
439
  const parsed = parseUserAttachments(content);
395
440
  const { cleanText: noSysTags, tags } = extractSystemTags(parsed.text);
396
441
  const { command, cleanText } = parseCommandTags(noSysTags);
397
- // Merge command args into body text so line-clamp + Show more applies uniformly
398
442
  const bodyText = command?.args
399
443
  ? (cleanText ? `${command.args}\n\n${cleanText}` : command.args)
400
444
  : cleanText;
401
- return { files: parsed.files, text: bodyText, tags, command, jsonlPath: extractJsonlPath(cleanText) };
445
+ return { files: parsed.files, text: bodyText, tags, command };
402
446
  }, [content]);
403
447
 
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
448
  const isSystemContext = tags.some((t) => SYSTEM_TAG_NAMES.has(t.name));
422
449
 
423
450
  const [expanded, setExpanded] = useState(false);
@@ -497,15 +524,6 @@ function UserBubble({ content, messageId, projectName, onFork, onExpandCompact,
497
524
  {expanded ? <><ChevronUp className="size-3" />Show less</> : <><ChevronDown className="size-3" />Show more</>}
498
525
  </button>
499
526
  )}
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
527
  {/* Fork/Rewind button — only for real user messages */}
510
528
  {!isSystemContext && onFork && (
511
529
  <button
@@ -780,31 +798,12 @@ type EventGroup =
780
798
  | { kind: "thinking"; content: string }
781
799
  | { kind: "tool"; tool: ChatEvent; result?: ChatEvent; completed?: boolean };
782
800
 
783
- function InterleavedEvents({ events, isStreaming, projectName, bashPartialOutput, messageId, onExpandCompact, isCompactExpanded }: {
801
+ function InterleavedEvents({ events, isStreaming, projectName, bashPartialOutput }: {
784
802
  events: ChatEvent[];
785
803
  isStreaming: boolean;
786
804
  projectName?: string;
787
805
  bashPartialOutput?: React.RefObject<Map<string, BashPartialEntry>>;
788
- messageId?: string;
789
- onExpandCompact?: (compactMessageId: string, jsonlPath: string) => Promise<number>;
790
- isCompactExpanded?: (compactMessageId: string) => boolean;
791
806
  }) {
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
807
  // Group: consecutive text → merged text block; tool_use + tool_result paired by toolUseId
809
808
  const groups: EventGroup[] = [];
810
809
  let textBuffer = "";
@@ -919,17 +918,9 @@ function InterleavedEvents({ events, isStreaming, projectName, bashPartialOutput
919
918
  }
920
919
  if (group.kind === "text") {
921
920
  const isLast = isStreaming && i === groups.length - 1;
922
- const jsonlPath = extractJsonlPath(group.content);
923
921
  return (
924
922
  <div key={`text-${i}`} className="text-sm text-text-primary select-text">
925
923
  <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
924
  </div>
934
925
  );
935
926
  }
@@ -1053,9 +1044,11 @@ function MarkdownContent({ content, projectName, isStreaming }: { content: strin
1053
1044
  const cleaned = stripTeammateMessages(content);
1054
1045
  if (!cleaned) return null;
1055
1046
  return (
1056
- <Suspense fallback={<div className="animate-pulse h-4 bg-muted rounded" />}>
1057
- <MarkdownRenderer content={cleaned} projectName={projectName} codeActions isStreaming={isStreaming} />
1058
- </Suspense>
1047
+ <MarkdownErrorBoundary fallbackContent={cleaned}>
1048
+ <Suspense fallback={<div className="animate-pulse h-4 bg-muted rounded" />}>
1049
+ <MarkdownRenderer content={cleaned} projectName={projectName} codeActions isStreaming={isStreaming} />
1050
+ </Suspense>
1051
+ </MarkdownErrorBoundary>
1059
1052
  );
1060
1053
  }
1061
1054
 
@@ -0,0 +1,35 @@
1
+ import { Component, type ReactNode } from "react";
2
+
3
+ interface Props {
4
+ fallbackContent?: string;
5
+ children: ReactNode;
6
+ }
7
+
8
+ interface State {
9
+ hasError: boolean;
10
+ }
11
+
12
+ /**
13
+ * Error boundary that catches React DOM reconciliation errors
14
+ * (e.g. "removeChild" failures from rehype-raw or browser extensions).
15
+ * Falls back to plain text rendering instead of crashing the whole app.
16
+ */
17
+ export class MarkdownErrorBoundary extends Component<Props, State> {
18
+ override state: State = { hasError: false };
19
+
20
+ static getDerivedStateFromError(): State {
21
+ return { hasError: true };
22
+ }
23
+
24
+ override render() {
25
+ if (this.state.hasError) {
26
+ // Show raw text as fallback — still readable, just not formatted
27
+ return this.props.fallbackContent ? (
28
+ <div className="text-sm whitespace-pre-wrap break-words text-text-primary opacity-80">
29
+ {this.props.fallbackContent}
30
+ </div>
31
+ ) : null;
32
+ }
33
+ return this.props.children;
34
+ }
35
+ }
@@ -27,6 +27,18 @@ export function notificationColor(type: string | null | undefined): string {
27
27
  return (type && TYPE_COLORS[type]) || "bg-red-500";
28
28
  }
29
29
 
30
+ /** Subtle bg tint per notification type (for unread row highlights) */
31
+ const TYPE_TINTS: Record<string, string> = {
32
+ approval_request: "bg-red-500/10",
33
+ question: "bg-amber-500/10",
34
+ done: "bg-blue-500/10",
35
+ };
36
+
37
+ /** Get subtle background tint class for a notification type */
38
+ export function notificationTint(type: string | null | undefined): string {
39
+ return (type && TYPE_TINTS[type]) || "bg-red-500/10";
40
+ }
41
+
30
42
  interface NotificationStore {
31
43
  notifications: Map<string, NotificationEntry>;
32
44
  addNotification: (sessionId: string, type: string, projectName: string) => void;