@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.
- package/CHANGELOG.md +11 -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-o756_lUz.js → audio-preview-Dpmcl747.js} +1 -1
- package/dist/web/assets/chat-tab-DXBTsnF8.js +12 -0
- package/dist/web/assets/code-editor-Dj6Bpl_2.js +8 -0
- package/dist/web/assets/{conflict-editor-DpG5ZK2d.js → conflict-editor-DWmEigpj.js} +1 -1
- package/dist/web/assets/{database-viewer-C2EaJWuR.js → database-viewer-DWBFfKPT.js} +1 -1
- package/dist/web/assets/{diff-viewer-I3y18uIy.js → diff-viewer-Bm0_jtBc.js} +1 -1
- package/dist/web/assets/{extension-webview-BJ5RsGxi.js → extension-webview-BgsfCSSv.js} +2 -2
- package/dist/web/assets/{glide-data-grid-DAQyTRmX.js → glide-data-grid-CgBR08p4.js} +3 -3
- package/dist/web/assets/{image-preview-Da0-pK7U.js → image-preview-VYdq-6Me.js} +1 -1
- package/dist/web/assets/index-BG3vkzX-.js +27 -0
- package/dist/web/assets/index-DmkeN7Eo.css +2 -0
- package/dist/web/assets/keybindings-store-D4bcke1T.js +1 -0
- package/dist/web/assets/{markdown-renderer-DeuGyN7e.js → markdown-renderer-B2bQd2Zz.js} +1 -1
- package/dist/web/assets/notification-store-cWjv3Qu4.js +1 -0
- package/dist/web/assets/{pdf-preview-BAcJhOc0.js → pdf-preview-RCOjBSNz.js} +1 -1
- package/dist/web/assets/port-forwarding-tab-DSs4ce0D.js +1 -0
- package/dist/web/assets/{postgres-viewer-C9ouNMds.js → postgres-viewer-BET58NOb.js} +2 -2
- package/dist/web/assets/{settings-tab-DjrtWjMi.js → settings-tab-C7LHGpuM.js} +1 -1
- package/dist/web/assets/{sql-query-editor-vIJNWRZR.js → sql-query-editor-DueILhFD.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-8KsTrAAv.js → sqlite-viewer-qrmWrdx6.js} +1 -1
- package/dist/web/assets/terminal-tab-WwRXVV_9.js +1 -0
- package/dist/web/assets/{video-preview-D_UOFgju.js → video-preview-BwNo3VVV.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/chat-history-bar.tsx +32 -13
- package/src/web/components/chat/chat-tab.tsx +1 -1
- package/src/web/components/chat/message-list.tsx +84 -91
- package/src/web/components/shared/markdown-error-boundary.tsx +35 -0
- package/src/web/stores/notification-store.ts +12 -0
- package/dist/web/assets/chat-tab-7eUBtble.js +0 -12
- package/dist/web/assets/code-editor-I6rJozDs.js +0 -8
- package/dist/web/assets/index-B7c42LjL.js +0 -27
- package/dist/web/assets/index-C5sLGvFC.css +0 -2
- package/dist/web/assets/keybindings-store-Ban22mlc.js +0 -1
- package/dist/web/assets/notification-store-DpmwVsn4.js +0 -1
- package/dist/web/assets/port-forwarding-tab-BYzipE8D.js +0 -1
- package/dist/web/assets/terminal-tab-BYyzleHf.js +0 -1
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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
|
-
{/*
|
|
398
|
-
{
|
|
399
|
+
{/* Reload messages + connection status */}
|
|
400
|
+
{onReload && (
|
|
399
401
|
<button
|
|
400
|
-
onClick={
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
<
|
|
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=
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
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}
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
<
|
|
1057
|
-
<
|
|
1058
|
-
|
|
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;
|