@hienlh/ppm 0.17.34 → 0.17.37
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 +17 -0
- package/CLAUDE.md +1 -0
- package/Dockerfile.test +5 -1
- package/assets/skills/ppm/SKILL.md +1 -1
- package/assets/skills/ppm/references/http-api.md +1 -1
- package/bun.lock +5 -0
- package/dist/web/assets/{ai-resource-editor-QNfSU0Fi.js → ai-resource-editor-DAIaB4mX.js} +1 -1
- package/dist/web/assets/{api-client-DhXpjLdi.js → api-client-BgM59GxL.js} +1 -1
- package/dist/web/assets/api-client-BwMdPtp6.js +1 -0
- package/dist/web/assets/{api-settings-BUYvGBdi.js → api-settings-D108koFG.js} +1 -1
- package/dist/web/assets/api-settings-D2fY0GUc.js +1 -0
- package/dist/web/assets/{api-themes-BpoKUPkQ.js → api-themes-cPGCYGB_.js} +1 -1
- package/dist/web/assets/{audio-preview-IokB77Cj.js → audio-preview-Df6_blMs.js} +1 -1
- package/dist/web/assets/chat-tab-yPXWmX01.js +13 -0
- package/dist/web/assets/{code-editor-B8YiBPOX.js → code-editor-CSDB2W8N.js} +2 -2
- package/dist/web/assets/{conflict-editor-CY1o5kBO.js → conflict-editor-BqMdj782.js} +1 -1
- package/dist/web/assets/{csv-preview-AEg0GB-V.js → csv-preview-BC_BuOzK.js} +1 -1
- package/dist/web/assets/{database-viewer-C5Meg0s_.js → database-viewer-DZQDQOye.js} +1 -1
- package/dist/web/assets/diff-viewer-CaKD5pR5.js +4 -0
- package/dist/web/assets/docx-preview-MBY4op8y.js +12 -0
- package/dist/web/assets/{edit-diff-preview-_CXUdwsF.js → edit-diff-preview-D1TEK7Jz.js} +1 -1
- package/dist/web/assets/{extension-webview-CQceYb7O.js → extension-webview-CMtucoeg.js} +1 -1
- package/dist/web/assets/git-log-panel-DWZUIupS.js +1 -0
- package/dist/web/assets/{glide-data-grid-cGxkgluR.js → glide-data-grid-SLs8Xp6G.js} +1 -1
- package/dist/web/assets/{group-chat-tab-B-eA5ytm.js → group-chat-tab-Celp6ihW.js} +1 -1
- package/dist/web/assets/image-gallery-CObzTisL.js +1 -0
- package/dist/web/assets/{image-preview-DRUzYptI.js → image-preview-lRcpEoEk.js} +1 -1
- package/dist/web/assets/index-BkH2b_6W.js +50 -0
- package/dist/web/assets/index-eW0VEaRF.css +2 -0
- package/dist/web/assets/keybindings-store-CtfEQL3U.js +1 -0
- package/dist/web/assets/markdown-renderer-D9r_4-qe.js +3 -0
- package/dist/web/assets/markdown-renderer-DkYo6o9f.js +1 -0
- package/dist/web/assets/notification-store-OF6L8lWu.js +1 -0
- package/dist/web/assets/panel-store-CpKvXNnm.js +1 -0
- package/dist/web/assets/pdf-preview-D17y9l8U.js +1 -0
- package/dist/web/assets/{postgres-viewer-BeantJZe.js → postgres-viewer-DgpX_Kyl.js} +1 -1
- package/dist/web/assets/project-store-8_AdCduJ.js +1 -0
- package/dist/web/assets/{settings-store-DFMz_Oer.js → settings-store-BhZ7VYJD.js} +2 -2
- package/dist/web/assets/settings-tab-Cou9P1gO.js +1 -0
- package/dist/web/assets/{sql-query-editor-DqU9zfVy.js → sql-query-editor-BIeAQego.js} +1 -1
- package/dist/web/assets/sqlite-viewer-SipxlqWM.js +1 -0
- package/dist/web/assets/{system-monitor-tab-Bue4p4Vb.js → system-monitor-tab-Cp-rP5HZ.js} +1 -1
- package/dist/web/assets/{tab-store-CNxCwdXw.js → tab-store-BQmDVh9c.js} +1 -1
- package/dist/web/assets/{terminal-tab-BfNnKU71.js → terminal-tab-C9T_X4oo.js} +1 -1
- package/dist/web/assets/tool-cards-BFVmoF_j.js +7 -0
- package/dist/web/assets/use-blob-url-BYDGMRMU.js +1 -0
- package/dist/web/assets/{use-monaco-theme-5XjAzOzE.js → use-monaco-theme-Bd1GBgB3.js} +1 -1
- package/dist/web/assets/{video-preview-DMqLOqmc.js → video-preview-DT8B2-re.js} +1 -1
- package/dist/web/index.html +8 -8
- package/dist/web/sw.js +1 -1
- package/docker-compose.test.yml +5 -1
- package/package.json +4 -3
- package/src/server/routes/chat.ts +66 -1
- package/src/services/image-dimensions.ts +67 -0
- package/src/services/transcript-images.ts +174 -0
- package/src/shared/tool-result-content.ts +14 -3
- package/src/web/components/chat/chat-history-bar.tsx +3 -104
- package/src/web/components/chat/message-input.tsx +4 -2
- package/src/web/components/chat/message-list.tsx +10 -3
- package/src/web/components/chat/session-debug-dialog.tsx +240 -0
- package/src/web/components/chat/tool-image-preview.tsx +4 -2
- package/src/web/components/group-chat/group-create-dialog.tsx +2 -1
- package/src/web/components/layout/editor-panel.tsx +31 -5
- package/src/web/components/layout/notification-bell-popover.tsx +5 -1
- package/src/web/components/layout/tab-bar.tsx +2 -2
- package/src/web/components/shared/image-overlay.tsx +188 -24
- package/src/web/components/shared/markdown-context.ts +1 -1
- package/src/web/components/shared/markdown-renderer.tsx +3 -1
- package/src/web/hooks/use-draft.ts +17 -2
- package/src/web/hooks/use-image-transform.ts +276 -0
- package/src/web/lib/api-client.ts +50 -7
- package/src/web/lib/clipboard.ts +41 -0
- package/src/web/lib/file-download.ts +1 -1
- package/src/web/lib/image-gallery.ts +25 -0
- package/src/web/stores/image-overlay-store.ts +35 -7
- package/src/web/stores/panel-store.ts +40 -6
- package/src/web/stores/panel-utils.ts +19 -0
- package/dist/web/assets/api-client-DNTkryX1.js +0 -1
- package/dist/web/assets/api-settings-Bp5HXQCW.js +0 -1
- package/dist/web/assets/chat-tab-BZNEvS88.js +0 -13
- package/dist/web/assets/diff-viewer-BRRGvZnp.js +0 -4
- package/dist/web/assets/docx-preview-DqTX7NDp.js +0 -12
- package/dist/web/assets/git-log-panel-D7QlAJqu.js +0 -1
- package/dist/web/assets/index-BuX7JkCt.js +0 -50
- package/dist/web/assets/index-PX2pZ3bH.css +0 -2
- package/dist/web/assets/keybindings-store-B3plqyxF.js +0 -1
- package/dist/web/assets/markdown-renderer-BKDbjc3g.js +0 -3
- package/dist/web/assets/markdown-renderer-C9Gy8tM2.js +0 -1
- package/dist/web/assets/notification-store-jFDauzse.js +0 -1
- package/dist/web/assets/panel-store-D-RxcUgH.js +0 -1
- package/dist/web/assets/pdf-preview-CzHUqXJB.js +0 -1
- package/dist/web/assets/project-store-C1jInVih.js +0 -1
- package/dist/web/assets/settings-tab-CqQHhqV1.js +0 -1
- package/dist/web/assets/sqlite-viewer-BH891DC0.js +0 -1
- package/dist/web/assets/tool-cards-CSOwe7_i.js +0 -7
- package/dist/web/assets/use-blob-url-jSRmyXVg.js +0 -1
- package/skills-lock.json +0 -11
- package/spike-memory-region-dump.ps1 +0 -69
- /package/dist/web/assets/{csv-parser-BPS94T2h.js → csv-parser-DVHBcvuE.js} +0 -0
- /package/dist/web/assets/{lib-DA3QSc5_.js → lib-DTaN9riD.js} +0 -0
- /package/dist/web/assets/{libesm-BS7D9de3.js → libesm-DdtEjhIi.js} +0 -0
- /package/dist/web/assets/{vendor-xterm-CsgYwG03.js → vendor-xterm-QDr0jvpJ.js} +0 -0
|
@@ -29,11 +29,22 @@ function isImageBlock(block: unknown): block is { source?: { data?: unknown } }
|
|
|
29
29
|
|
|
30
30
|
function imagePlaceholder(block: { source?: { data?: unknown } }): string {
|
|
31
31
|
const data = block.source?.data;
|
|
32
|
-
if (typeof data !== "string" || data.length === 0) return
|
|
33
|
-
return
|
|
32
|
+
if (typeof data !== "string" || data.length === 0) return imagePlaceholderText();
|
|
33
|
+
return imagePlaceholderText(base64ByteLength(data));
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
/**
|
|
37
|
+
* The exact text that stands in for an image block.
|
|
38
|
+
*
|
|
39
|
+
* Anything that removes an image from a transcript must use this, so that
|
|
40
|
+
* `resultHasImagePlaceholder` still recognises the result as having carried an image and the
|
|
41
|
+
* chat card keeps hiding the textual output in favour of the rendered file.
|
|
42
|
+
*/
|
|
43
|
+
export function imagePlaceholderText(bytes?: number): string {
|
|
44
|
+
return bytes == null ? "[image]" : `[image · ${formatBytes(bytes)}]`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function base64ByteLength(data: string): number {
|
|
37
48
|
const padding = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0;
|
|
38
49
|
return Math.max(0, Math.floor((data.length * 3) / 4) - padding);
|
|
39
50
|
}
|
|
@@ -1,23 +1,19 @@
|
|
|
1
1
|
import { useState, useEffect, useCallback, useRef, type MouseEvent } from "react";
|
|
2
|
-
import { History, Settings2, Loader2, MessageSquare, RefreshCw, Search, Pencil, Check, X, BellOff, Circle,
|
|
2
|
+
import { History, Settings2, Loader2, MessageSquare, RefreshCw, Search, Pencil, Check, X, BellOff, Circle, 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
|
-
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
|
6
|
-
import { BottomSheet } from "@/components/ui/mobile-bottom-sheet";
|
|
7
|
-
import { Button } from "@/components/ui/button";
|
|
8
|
-
import { useIsMobile } from "@/hooks/use-is-mobile";
|
|
9
5
|
import { useTabStore } from "@/stores/tab-store";
|
|
10
6
|
import { useNotificationStore, notificationTint } from "@/stores/notification-store";
|
|
11
7
|
import { cn } from "@/lib/utils";
|
|
12
8
|
import { AISettingsSection } from "@/components/settings/ai-settings-section";
|
|
13
9
|
import { TagSettingsSection } from "@/components/settings/tag-settings-section";
|
|
14
10
|
import { SessionContextMenu } from "./session-context-menu";
|
|
11
|
+
import { SessionDebugButton } from "./session-debug-dialog";
|
|
15
12
|
import { UsageDetailPanel } from "./usage-badge";
|
|
16
13
|
import { CodexUsagePanel } from "./codex-usage-panel";
|
|
17
14
|
import { TeamActivityPanel } from "./team-activity-panel";
|
|
18
15
|
import { ProviderBadge } from "./provider-selector";
|
|
19
16
|
import { formatRelativeDate } from "@/lib/format-date";
|
|
20
|
-
import { copyToClipboard } from "@/lib/clipboard";
|
|
21
17
|
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
|
22
18
|
import type { SessionInfo, SessionListResponse, ProjectTag } from "../../../types/chat";
|
|
23
19
|
import type { UsageInfo } from "../../../types/chat";
|
|
@@ -64,103 +60,6 @@ function pctColor(pct: number): string {
|
|
|
64
60
|
return "text-success";
|
|
65
61
|
}
|
|
66
62
|
|
|
67
|
-
/** Live render weight of the currently open transcript (this button sits in the
|
|
68
|
-
* active session's toolbar, so the visible chat scroller IS this session). */
|
|
69
|
-
function gatherRenderStats(): string[] {
|
|
70
|
-
let scroller: HTMLElement | null = null;
|
|
71
|
-
for (const el of document.querySelectorAll<HTMLElement>(".overflow-y-auto")) {
|
|
72
|
-
if (el.clientHeight < 100 || !el.querySelector("[data-msg-index]")) continue;
|
|
73
|
-
if (!scroller || el.scrollHeight > scroller.scrollHeight) scroller = el;
|
|
74
|
-
}
|
|
75
|
-
const lines: string[] = [];
|
|
76
|
-
if (scroller) {
|
|
77
|
-
lines.push(`Rendered messages: ${scroller.querySelectorAll("[data-msg-index]").length}`);
|
|
78
|
-
lines.push(`Transcript DOM nodes: ${scroller.querySelectorAll("*").length}`);
|
|
79
|
-
lines.push(`Transcript height: ${Math.round(scroller.scrollHeight)}px`);
|
|
80
|
-
}
|
|
81
|
-
// Chrome-only; page-wide (not just the transcript) but the best in-app proxy.
|
|
82
|
-
const mem = (performance as unknown as { memory?: { usedJSHeapSize: number } }).memory;
|
|
83
|
-
if (mem) lines.push(`JS heap (page): ${(mem.usedJSHeapSize / 1048576).toFixed(1)} MB`);
|
|
84
|
-
return lines;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
const fmtMB = (bytes: number) => `${(bytes / 1048576).toFixed(2)} MB`;
|
|
88
|
-
|
|
89
|
-
function DebugCopyButton({ sessionId, projectName }: { sessionId: string; projectName: string }) {
|
|
90
|
-
const [open, setOpen] = useState(false);
|
|
91
|
-
const [info, setInfo] = useState<string | null>(null);
|
|
92
|
-
const [copied, setCopied] = useState(false);
|
|
93
|
-
const isMobile = useIsMobile();
|
|
94
|
-
|
|
95
|
-
const openPopup = () => {
|
|
96
|
-
setOpen(true);
|
|
97
|
-
setCopied(false);
|
|
98
|
-
setInfo(null);
|
|
99
|
-
api.get<{ ppmSessionId: string; sdkSessionId: string; jsonlPath: string | null; projectPath: string; jsonlSizeBytes: number | null; jsonlLines: number | null }>(
|
|
100
|
-
`${projectUrl(projectName)}/chat/sessions/${sessionId}/debug?project=${encodeURIComponent(projectName)}`,
|
|
101
|
-
).then((data) => {
|
|
102
|
-
const weight = data.jsonlSizeBytes != null
|
|
103
|
-
? ` (${fmtMB(data.jsonlSizeBytes)}${data.jsonlLines != null ? `, ${data.jsonlLines} records` : ""})`
|
|
104
|
-
: "";
|
|
105
|
-
setInfo([
|
|
106
|
-
`PPM Session: ${data.ppmSessionId}`,
|
|
107
|
-
`SDK Session: ${data.sdkSessionId}`,
|
|
108
|
-
data.jsonlPath ? `JSONL: ${data.jsonlPath}${weight}` : `JSONL: not found`,
|
|
109
|
-
data.projectPath ? `Project: ${data.projectPath}` : null,
|
|
110
|
-
...gatherRenderStats(),
|
|
111
|
-
].filter(Boolean).join("\n"));
|
|
112
|
-
}).catch(() => setInfo("Failed to load debug info"));
|
|
113
|
-
};
|
|
114
|
-
|
|
115
|
-
const copy = () => {
|
|
116
|
-
if (!info) return;
|
|
117
|
-
copyToClipboard(info).then((ok) => {
|
|
118
|
-
if (!ok) return;
|
|
119
|
-
setCopied(true);
|
|
120
|
-
setTimeout(() => setCopied(false), 1500);
|
|
121
|
-
});
|
|
122
|
-
};
|
|
123
|
-
|
|
124
|
-
const body = (
|
|
125
|
-
<div className="flex flex-col gap-3">
|
|
126
|
-
<h2 className="flex items-center gap-2 text-sm font-semibold text-foreground">
|
|
127
|
-
<Bug className="size-4 text-primary" />
|
|
128
|
-
Session debug info
|
|
129
|
-
</h2>
|
|
130
|
-
<pre className="max-h-[50vh] overflow-y-auto whitespace-pre-wrap break-all rounded-md border border-border bg-surface p-2.5 font-mono text-[11px] leading-relaxed text-text-secondary select-text">
|
|
131
|
-
{info ?? "Loading..."}
|
|
132
|
-
</pre>
|
|
133
|
-
<Button size="sm" onClick={copy} disabled={!info} className="w-full">
|
|
134
|
-
{copied ? <><ClipboardCheck className="size-3.5" /> Copied!</> : <><Copy className="size-3.5" /> Copy</>}
|
|
135
|
-
</Button>
|
|
136
|
-
</div>
|
|
137
|
-
);
|
|
138
|
-
|
|
139
|
-
return (
|
|
140
|
-
<>
|
|
141
|
-
<button
|
|
142
|
-
onClick={openPopup}
|
|
143
|
-
className="p-1 rounded text-text-subtle hover:text-text-secondary hover:bg-surface-elevated transition-colors"
|
|
144
|
-
title="Session debug info"
|
|
145
|
-
>
|
|
146
|
-
<Bug className="size-3" />
|
|
147
|
-
</button>
|
|
148
|
-
{isMobile ? (
|
|
149
|
-
<BottomSheet open={open} onClose={() => setOpen(false)} className="popover-solid">
|
|
150
|
-
<div className="px-4 pb-4 pt-1">{body}</div>
|
|
151
|
-
</BottomSheet>
|
|
152
|
-
) : (
|
|
153
|
-
<Dialog open={open} onOpenChange={setOpen}>
|
|
154
|
-
<DialogContent className="sm:max-w-lg">
|
|
155
|
-
<DialogTitle className="sr-only">Session debug info</DialogTitle>
|
|
156
|
-
{body}
|
|
157
|
-
</DialogContent>
|
|
158
|
-
</Dialog>
|
|
159
|
-
)}
|
|
160
|
-
</>
|
|
161
|
-
);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
63
|
export function ChatHistoryBar({
|
|
165
64
|
projectName, usageInfo, usageLoading, refreshUsage, lastFetchedAt,
|
|
166
65
|
sessionId, providerId, onSelectSession, onBugReport, isConnected, onReload,
|
|
@@ -477,7 +376,7 @@ export function ChatHistoryBar({
|
|
|
477
376
|
|
|
478
377
|
{/* Debug info — copy session IDs + JSONL path */}
|
|
479
378
|
{sessionId && (
|
|
480
|
-
<
|
|
379
|
+
<SessionDebugButton sessionId={sessionId} projectName={projectName} />
|
|
481
380
|
)}
|
|
482
381
|
|
|
483
382
|
{/* Reload messages + connection status */}
|
|
@@ -252,9 +252,11 @@ export const MessageInput = memo(function MessageInput({
|
|
|
252
252
|
return () => window.removeEventListener("ppm:send-to-chat", handler);
|
|
253
253
|
}, [getVisibleTextarea]);
|
|
254
254
|
|
|
255
|
-
// Apply initialValue when it changes (e.g. "Ask AI" from command palette)
|
|
255
|
+
// Apply initialValue when it changes (e.g. "Ask AI" from command palette).
|
|
256
|
+
// A restored draft can land after the input is already on screen, so never
|
|
257
|
+
// write over text that is already there — what was typed wins.
|
|
256
258
|
useEffect(() => {
|
|
257
|
-
if (initialValue) {
|
|
259
|
+
if (initialValue && !valueRef.current) {
|
|
258
260
|
writeTextareas(initialValue);
|
|
259
261
|
// Focus and move cursor to end
|
|
260
262
|
setTimeout(() => {
|
|
@@ -57,6 +57,7 @@ import { useTabStore } from "@/stores/tab-store";
|
|
|
57
57
|
import { api } from "@/lib/api-client";
|
|
58
58
|
import { useProjectStore } from "@/stores/project-store";
|
|
59
59
|
import { useImageOverlay } from "@/stores/image-overlay-store";
|
|
60
|
+
import { collectGallery, GALLERY_ITEM_ATTR, GALLERY_ROOT_ATTR } from "@/lib/image-gallery";
|
|
60
61
|
|
|
61
62
|
interface MessageListProps {
|
|
62
63
|
messages: ChatMessage[];
|
|
@@ -359,6 +360,7 @@ export function MessageList({
|
|
|
359
360
|
)}
|
|
360
361
|
<div
|
|
361
362
|
ref={setScrollRef}
|
|
363
|
+
{...{ [GALLERY_ROOT_ATTR]: "" }}
|
|
362
364
|
className="flex-1 overflow-y-auto overflow-x-hidden [overflow-anchor:none]"
|
|
363
365
|
style={{ WebkitOverflowScrolling: "touch", overscrollBehavior: "contain" }}
|
|
364
366
|
>
|
|
@@ -981,11 +983,16 @@ function AuthImage({ src, alt }: { src: string; alt: string }) {
|
|
|
981
983
|
}
|
|
982
984
|
|
|
983
985
|
return (
|
|
984
|
-
<button
|
|
986
|
+
<button
|
|
987
|
+
type="button"
|
|
988
|
+
onClick={(e) => openOverlay(blobUrl, alt, collectGallery(e.currentTarget))}
|
|
989
|
+
className="block text-left"
|
|
990
|
+
>
|
|
985
991
|
<img
|
|
986
992
|
src={blobUrl}
|
|
987
993
|
alt={alt}
|
|
988
994
|
style={box}
|
|
995
|
+
{...{ [GALLERY_ITEM_ATTR]: "" }}
|
|
989
996
|
onLoad={(e) => {
|
|
990
997
|
const el = e.currentTarget;
|
|
991
998
|
const c = imageBlobCache.get(src);
|
|
@@ -1007,11 +1014,11 @@ function AuthImageThumbnail({ filePath, projectName }: { filePath: string; proje
|
|
|
1007
1014
|
return (
|
|
1008
1015
|
<button
|
|
1009
1016
|
type="button"
|
|
1010
|
-
onClick={() => blobUrl && openOverlay(blobUrl, name)}
|
|
1017
|
+
onClick={(e) => blobUrl && openOverlay(blobUrl, name, collectGallery(e.currentTarget))}
|
|
1011
1018
|
className="flex items-center gap-1 rounded-md border border-border/60 bg-background/40 px-1.5 py-0.5 text-[11px] text-text-secondary hover:bg-surface transition-colors cursor-pointer"
|
|
1012
1019
|
>
|
|
1013
1020
|
{blobUrl ? (
|
|
1014
|
-
<img src={blobUrl} alt={name} className="size-4 rounded-sm object-cover shrink-0" />
|
|
1021
|
+
<img src={blobUrl} alt={name} {...{ [GALLERY_ITEM_ATTR]: "" }} className="size-4 rounded-sm object-cover shrink-0" />
|
|
1015
1022
|
) : error ? (
|
|
1016
1023
|
<ImageIcon className="size-3 shrink-0" />
|
|
1017
1024
|
) : (
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { Bug, ClipboardCheck, Copy, ImageOff, Loader2, TriangleAlert } from "lucide-react";
|
|
3
|
+
import { api, projectUrl } from "@/lib/api-client";
|
|
4
|
+
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
|
5
|
+
import { BottomSheet } from "@/components/ui/mobile-bottom-sheet";
|
|
6
|
+
import { Button } from "@/components/ui/button";
|
|
7
|
+
import { useIsMobile } from "@/hooks/use-is-mobile";
|
|
8
|
+
import { copyToClipboard } from "@/lib/clipboard";
|
|
9
|
+
|
|
10
|
+
interface DebugInfo {
|
|
11
|
+
ppmSessionId: string;
|
|
12
|
+
sdkSessionId: string;
|
|
13
|
+
jsonlPath: string | null;
|
|
14
|
+
projectPath: string;
|
|
15
|
+
jsonlSizeBytes: number | null;
|
|
16
|
+
jsonlLines: number | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface ImageAudit {
|
|
20
|
+
total: number;
|
|
21
|
+
oversized: number;
|
|
22
|
+
bytes: number;
|
|
23
|
+
oversizedBytes: number;
|
|
24
|
+
largestSide: number;
|
|
25
|
+
limit: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const fmtMB = (bytes: number) => `${(bytes / 1048576).toFixed(2)} MB`;
|
|
29
|
+
|
|
30
|
+
function fmtSize(bytes: number): string {
|
|
31
|
+
if (bytes >= 1048576) return `${(bytes / 1048576).toFixed(1)} MB`;
|
|
32
|
+
return `${Math.max(1, Math.round(bytes / 1024))} KB`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Live render weight of the currently open transcript.
|
|
37
|
+
*
|
|
38
|
+
* This dialog opens from the active session's toolbar, so the tallest scroller holding
|
|
39
|
+
* message nodes is that session's transcript.
|
|
40
|
+
*/
|
|
41
|
+
function gatherRenderStats(): string[] {
|
|
42
|
+
let scroller: HTMLElement | null = null;
|
|
43
|
+
for (const el of document.querySelectorAll<HTMLElement>(".overflow-y-auto")) {
|
|
44
|
+
if (el.clientHeight < 100 || !el.querySelector("[data-msg-index]")) continue;
|
|
45
|
+
if (!scroller || el.scrollHeight > scroller.scrollHeight) scroller = el;
|
|
46
|
+
}
|
|
47
|
+
const lines: string[] = [];
|
|
48
|
+
if (scroller) {
|
|
49
|
+
lines.push(`Rendered messages: ${scroller.querySelectorAll("[data-msg-index]").length}`);
|
|
50
|
+
lines.push(`Transcript DOM nodes: ${scroller.querySelectorAll("*").length}`);
|
|
51
|
+
lines.push(`Transcript height: ${Math.round(scroller.scrollHeight)}px`);
|
|
52
|
+
}
|
|
53
|
+
// Chrome-only; page-wide (not just the transcript) but the best in-app proxy.
|
|
54
|
+
const mem = (performance as unknown as { memory?: { usedJSHeapSize: number } }).memory;
|
|
55
|
+
if (mem) lines.push(`JS heap (page): ${(mem.usedJSHeapSize / 1048576).toFixed(1)} MB`);
|
|
56
|
+
return lines;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Removal of the image payloads a transcript replays on every turn.
|
|
61
|
+
*
|
|
62
|
+
* The transcript is re-sent in full each time the session resumes, so every image it
|
|
63
|
+
* carries is paid for again. Images past the API's per-image dimension cap are rejected
|
|
64
|
+
* outright once a request holds several of them, so those cost bandwidth and raise an
|
|
65
|
+
* error while adding nothing — which is why they get their own button.
|
|
66
|
+
*
|
|
67
|
+
* Nothing here runs on its own: a transcript is the record of a session, and trimming it
|
|
68
|
+
* is the user's call.
|
|
69
|
+
*/
|
|
70
|
+
function TranscriptImages({ sessionId, projectName }: { sessionId: string; projectName: string }) {
|
|
71
|
+
const [audit, setAudit] = useState<ImageAudit | null>(null);
|
|
72
|
+
const [error, setError] = useState<string | null>(null);
|
|
73
|
+
const [busy, setBusy] = useState<"oversized" | "all" | null>(null);
|
|
74
|
+
const [freed, setFreed] = useState<{ removed: number; bytes: number } | null>(null);
|
|
75
|
+
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
let live = true;
|
|
78
|
+
api.get<ImageAudit>(
|
|
79
|
+
`${projectUrl(projectName)}/chat/sessions/${sessionId}/images?project=${encodeURIComponent(projectName)}`,
|
|
80
|
+
).then((a) => { if (live) setAudit(a); })
|
|
81
|
+
.catch((e: Error) => { if (live) setError(e.message); });
|
|
82
|
+
return () => { live = false; };
|
|
83
|
+
}, [sessionId, projectName]);
|
|
84
|
+
|
|
85
|
+
const strip = (mode: "oversized" | "all") => {
|
|
86
|
+
setBusy(mode);
|
|
87
|
+
setError(null);
|
|
88
|
+
api.post<{ removed: number; bytesFreed: number; remaining: ImageAudit }>(
|
|
89
|
+
`${projectUrl(projectName)}/chat/sessions/${sessionId}/images/strip?project=${encodeURIComponent(projectName)}`,
|
|
90
|
+
{ mode },
|
|
91
|
+
).then((r) => {
|
|
92
|
+
setAudit(r.remaining);
|
|
93
|
+
setFreed({ removed: r.removed, bytes: r.bytesFreed });
|
|
94
|
+
}).catch((e: Error) => setError(e.message)).finally(() => setBusy(null));
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
if (error && !audit) return <p className="text-[11px] text-error">{error}</p>;
|
|
98
|
+
if (!audit) return <p className="text-[11px] text-text-subtle">Scanning transcript for images...</p>;
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<div className="flex flex-col gap-2">
|
|
102
|
+
<h3 className="text-xs font-semibold text-foreground">Transcript images</h3>
|
|
103
|
+
|
|
104
|
+
{audit.total === 0 ? (
|
|
105
|
+
<p className="text-[11px] text-text-subtle">
|
|
106
|
+
No images in tool results{freed ? " — transcript is clean." : "."}
|
|
107
|
+
</p>
|
|
108
|
+
) : (
|
|
109
|
+
<p className="text-[11px] text-text-secondary">
|
|
110
|
+
{audit.total} image{audit.total === 1 ? "" : "s"} in tool results · {fmtSize(audit.bytes)} of base64,
|
|
111
|
+
replayed on every turn.
|
|
112
|
+
</p>
|
|
113
|
+
)}
|
|
114
|
+
|
|
115
|
+
{audit.oversized > 0 && (
|
|
116
|
+
<div className="flex gap-2 rounded-md border border-warning/40 bg-warning/10 p-2 text-[11px] text-text-secondary">
|
|
117
|
+
<TriangleAlert className="mt-px size-3.5 shrink-0 text-warning" />
|
|
118
|
+
<span>
|
|
119
|
+
{audit.oversized} image{audit.oversized === 1 ? " is" : "s are"} wider than {audit.limit}px
|
|
120
|
+
(largest {audit.largestSide}px). The API rejects those once a request carries several images,
|
|
121
|
+
so they already contribute nothing — removing them loses no context.
|
|
122
|
+
</span>
|
|
123
|
+
</div>
|
|
124
|
+
)}
|
|
125
|
+
|
|
126
|
+
{freed && (
|
|
127
|
+
<p className="text-[11px] text-success">
|
|
128
|
+
Removed {freed.removed} image{freed.removed === 1 ? "" : "s"}, freed {fmtSize(freed.bytes)}.
|
|
129
|
+
</p>
|
|
130
|
+
)}
|
|
131
|
+
{error && <p className="text-[11px] text-error">{error}</p>}
|
|
132
|
+
|
|
133
|
+
<div className="flex flex-col gap-1.5 sm:flex-row">
|
|
134
|
+
<Button
|
|
135
|
+
size="sm"
|
|
136
|
+
variant="outline"
|
|
137
|
+
className="flex-1"
|
|
138
|
+
disabled={busy !== null || audit.oversized === 0}
|
|
139
|
+
onClick={() => strip("oversized")}
|
|
140
|
+
>
|
|
141
|
+
{busy === "oversized" ? <Loader2 className="size-3.5 animate-spin" /> : <ImageOff className="size-3.5" />}
|
|
142
|
+
Remove oversized{audit.oversized > 0 ? ` (${audit.oversized} · ${fmtSize(audit.oversizedBytes)})` : ""}
|
|
143
|
+
</Button>
|
|
144
|
+
<Button
|
|
145
|
+
size="sm"
|
|
146
|
+
variant="destructive"
|
|
147
|
+
className="flex-1"
|
|
148
|
+
disabled={busy !== null || audit.total === 0}
|
|
149
|
+
onClick={() => strip("all")}
|
|
150
|
+
>
|
|
151
|
+
{busy === "all" ? <Loader2 className="size-3.5 animate-spin" /> : <ImageOff className="size-3.5" />}
|
|
152
|
+
Remove all{audit.total > 0 ? ` (${audit.total} · ${fmtSize(audit.bytes)})` : ""}
|
|
153
|
+
</Button>
|
|
154
|
+
</div>
|
|
155
|
+
<p className="text-[11px] text-text-subtle">
|
|
156
|
+
Images you attached to a message are never touched. A removed tool image is replaced by its
|
|
157
|
+
placeholder text; the chat still shows the file, re-read from disk.
|
|
158
|
+
</p>
|
|
159
|
+
</div>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Toolbar button opening the session's debug facts and transcript maintenance. */
|
|
164
|
+
export function SessionDebugButton({ sessionId, projectName }: { sessionId: string; projectName: string }) {
|
|
165
|
+
const [open, setOpen] = useState(false);
|
|
166
|
+
const [info, setInfo] = useState<string | null>(null);
|
|
167
|
+
const [copied, setCopied] = useState(false);
|
|
168
|
+
const isMobile = useIsMobile();
|
|
169
|
+
|
|
170
|
+
const openPopup = () => {
|
|
171
|
+
setOpen(true);
|
|
172
|
+
setCopied(false);
|
|
173
|
+
setInfo(null);
|
|
174
|
+
api.get<DebugInfo>(
|
|
175
|
+
`${projectUrl(projectName)}/chat/sessions/${sessionId}/debug?project=${encodeURIComponent(projectName)}`,
|
|
176
|
+
).then((data) => {
|
|
177
|
+
const weight = data.jsonlSizeBytes != null
|
|
178
|
+
? ` (${fmtMB(data.jsonlSizeBytes)}${data.jsonlLines != null ? `, ${data.jsonlLines} records` : ""})`
|
|
179
|
+
: "";
|
|
180
|
+
setInfo([
|
|
181
|
+
`PPM Session: ${data.ppmSessionId}`,
|
|
182
|
+
`SDK Session: ${data.sdkSessionId}`,
|
|
183
|
+
data.jsonlPath ? `JSONL: ${data.jsonlPath}${weight}` : `JSONL: not found`,
|
|
184
|
+
data.projectPath ? `Project: ${data.projectPath}` : null,
|
|
185
|
+
...gatherRenderStats(),
|
|
186
|
+
].filter(Boolean).join("\n"));
|
|
187
|
+
}).catch(() => setInfo("Failed to load debug info"));
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const copy = () => {
|
|
191
|
+
if (!info) return;
|
|
192
|
+
copyToClipboard(info).then((ok) => {
|
|
193
|
+
if (!ok) return;
|
|
194
|
+
setCopied(true);
|
|
195
|
+
setTimeout(() => setCopied(false), 1500);
|
|
196
|
+
});
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const body = (
|
|
200
|
+
<div className="flex flex-col gap-3">
|
|
201
|
+
<h2 className="flex items-center gap-2 text-sm font-semibold text-foreground">
|
|
202
|
+
<Bug className="size-4 text-primary" />
|
|
203
|
+
Session debug info
|
|
204
|
+
</h2>
|
|
205
|
+
<pre className="max-h-[35vh] overflow-y-auto whitespace-pre-wrap break-all rounded-md border border-border bg-surface p-2.5 font-mono text-[11px] leading-relaxed text-text-secondary select-text">
|
|
206
|
+
{info ?? "Loading..."}
|
|
207
|
+
</pre>
|
|
208
|
+
<Button size="sm" onClick={copy} disabled={!info} className="w-full">
|
|
209
|
+
{copied ? <><ClipboardCheck className="size-3.5" /> Copied!</> : <><Copy className="size-3.5" /> Copy</>}
|
|
210
|
+
</Button>
|
|
211
|
+
<div className="border-t border-border pt-3">
|
|
212
|
+
{open && <TranscriptImages sessionId={sessionId} projectName={projectName} />}
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
return (
|
|
218
|
+
<>
|
|
219
|
+
<button
|
|
220
|
+
onClick={openPopup}
|
|
221
|
+
className="p-1 rounded text-text-subtle hover:text-text-secondary hover:bg-surface-elevated transition-colors"
|
|
222
|
+
title="Session debug info"
|
|
223
|
+
>
|
|
224
|
+
<Bug className="size-3" />
|
|
225
|
+
</button>
|
|
226
|
+
{isMobile ? (
|
|
227
|
+
<BottomSheet open={open} onClose={() => setOpen(false)} className="popover-solid">
|
|
228
|
+
<div className="max-h-[85vh] overflow-y-auto px-4 pb-4 pt-1">{body}</div>
|
|
229
|
+
</BottomSheet>
|
|
230
|
+
) : (
|
|
231
|
+
<Dialog open={open} onOpenChange={setOpen}>
|
|
232
|
+
<DialogContent className="sm:max-w-lg">
|
|
233
|
+
<DialogTitle className="sr-only">Session debug info</DialogTitle>
|
|
234
|
+
{body}
|
|
235
|
+
</DialogContent>
|
|
236
|
+
</Dialog>
|
|
237
|
+
)}
|
|
238
|
+
</>
|
|
239
|
+
);
|
|
240
|
+
}
|
|
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
|
|
|
2
2
|
import { useShallow } from "zustand/react/shallow";
|
|
3
3
|
import { useBlobUrl } from "@/hooks/use-blob-url";
|
|
4
4
|
import { useImageOverlay } from "@/stores/image-overlay-store";
|
|
5
|
+
import { collectGallery, GALLERY_ITEM_ATTR } from "@/lib/image-gallery";
|
|
5
6
|
import { useTabStore } from "@/stores/tab-store";
|
|
6
7
|
import { copyToClipboard } from "@/lib/clipboard";
|
|
7
8
|
import { basename } from "@/lib/utils";
|
|
@@ -107,8 +108,8 @@ export function ToolImagePreview({
|
|
|
107
108
|
void copyToClipboard(filePath).then(() => setCopied(true));
|
|
108
109
|
}
|
|
109
110
|
|
|
110
|
-
function openFull() {
|
|
111
|
-
if (blobUrl) openOverlay(blobUrl, name);
|
|
111
|
+
function openFull(e: { currentTarget: Element }) {
|
|
112
|
+
if (blobUrl) openOverlay(blobUrl, name, collectGallery(e.currentTarget));
|
|
112
113
|
}
|
|
113
114
|
|
|
114
115
|
// Loading: the same box as the loaded state, so the transcript never shifts under the
|
|
@@ -155,6 +156,7 @@ export function ToolImagePreview({
|
|
|
155
156
|
<img
|
|
156
157
|
src={blobUrl}
|
|
157
158
|
alt={name}
|
|
159
|
+
{...{ [GALLERY_ITEM_ATTR]: "" }}
|
|
158
160
|
onLoad={(e) => measure(e.currentTarget)}
|
|
159
161
|
onError={() => setDecodeFailed(true)}
|
|
160
162
|
className={
|
|
@@ -6,6 +6,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/u
|
|
|
6
6
|
import { Button } from "@/components/ui/button";
|
|
7
7
|
import { ModelSelector } from "@/components/chat/model-selector";
|
|
8
8
|
import { createGroup, type CreateMemberInput } from "@/lib/api-group-chat";
|
|
9
|
+
import { randomId } from "@/lib/utils";
|
|
9
10
|
import type { Group } from "../../../types/group-chat";
|
|
10
11
|
|
|
11
12
|
interface GroupCreateDialogProps {
|
|
@@ -27,7 +28,7 @@ interface DraftMember {
|
|
|
27
28
|
const PALETTE = ["#6366f1", "#ec4899", "#14b8a6", "#f59e0b", "#8b5cf6", "#ef4444"];
|
|
28
29
|
|
|
29
30
|
function newMember(): DraftMember {
|
|
30
|
-
return { key:
|
|
31
|
+
return { key: randomId(), name: "", persona: "", model: null };
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
export function GroupCreateDialog({
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useCallback } from "react";
|
|
2
|
-
import { Terminal, MessageSquare, FilePlus } from "lucide-react";
|
|
2
|
+
import { Terminal, MessageSquare, FilePlus, X } from "lucide-react";
|
|
3
3
|
import { usePanelStore } from "@/stores/panel-store";
|
|
4
4
|
import { useProjectStore } from "@/stores/project-store";
|
|
5
5
|
import { useTabStore, type TabType } from "@/stores/tab-store";
|
|
@@ -8,6 +8,7 @@ import type { SessionInfo } from "../../../types/chat";
|
|
|
8
8
|
import { TabBar } from "./tab-bar";
|
|
9
9
|
import { SplitDropOverlay } from "./split-drop-overlay";
|
|
10
10
|
import { registerPanelSlot } from "./tab-pool";
|
|
11
|
+
import { visibleTabs } from "@/stores/panel-utils";
|
|
11
12
|
import { cn } from "@/lib/utils";
|
|
12
13
|
|
|
13
14
|
const QUICK_OPEN_TABS: { type: TabType; label: string; icon: React.ElementType }[] = [
|
|
@@ -40,6 +41,12 @@ export function EditorPanel({ panelId, projectName }: EditorPanelProps) {
|
|
|
40
41
|
|
|
41
42
|
if (!panel) return null;
|
|
42
43
|
|
|
44
|
+
// Judge emptiness the same way TabBar does. A panel holding only another
|
|
45
|
+
// project's tab shows no tab chips, so treating it as non-empty here would
|
|
46
|
+
// render neither tabs nor the empty state — a blank panel with nothing to
|
|
47
|
+
// close. See visibleTabs().
|
|
48
|
+
const isEmpty = visibleTabs(panel.tabs, projectName).length === 0;
|
|
49
|
+
|
|
43
50
|
return (
|
|
44
51
|
<div
|
|
45
52
|
className={cn(
|
|
@@ -51,18 +58,27 @@ export function EditorPanel({ panelId, projectName }: EditorPanelProps) {
|
|
|
51
58
|
>
|
|
52
59
|
<TabBar panelId={panelId} />
|
|
53
60
|
|
|
54
|
-
<div
|
|
55
|
-
|
|
61
|
+
<div
|
|
62
|
+
className="flex-1 overflow-hidden relative"
|
|
63
|
+
data-panel-drop-zone={panelId}
|
|
64
|
+
// Middle-click an empty panel to close it (matches VS Code).
|
|
65
|
+
onAuxClick={(e) => {
|
|
66
|
+
if (e.button !== 1 || !isEmpty) return;
|
|
67
|
+
e.preventDefault();
|
|
68
|
+
usePanelStore.getState().closePanel(panelId);
|
|
69
|
+
}}
|
|
70
|
+
>
|
|
71
|
+
{isEmpty && <EmptyPanel panelId={panelId} canClose={panelCount > 1} />}
|
|
56
72
|
{/* Always render the slot so TabPool can portal into it immediately.
|
|
57
73
|
Hidden when empty to let EmptyPanel show through. */}
|
|
58
|
-
<div ref={slotCallbackRef} className="absolute inset-0" style={
|
|
74
|
+
<div ref={slotCallbackRef} className="absolute inset-0" style={isEmpty ? { display: "none" } : undefined} />
|
|
59
75
|
<SplitDropOverlay panelId={panelId} />
|
|
60
76
|
</div>
|
|
61
77
|
</div>
|
|
62
78
|
);
|
|
63
79
|
}
|
|
64
80
|
|
|
65
|
-
function EmptyPanel({ panelId }: { panelId: string }) {
|
|
81
|
+
function EmptyPanel({ panelId, canClose }: { panelId: string; canClose: boolean }) {
|
|
66
82
|
const activeProject = useProjectStore((s) => s.activeProject);
|
|
67
83
|
|
|
68
84
|
function openTab(type: TabType) {
|
|
@@ -111,6 +127,16 @@ function EmptyPanel({ panelId }: { panelId: string }) {
|
|
|
111
127
|
})}
|
|
112
128
|
</div>
|
|
113
129
|
|
|
130
|
+
{canClose && (
|
|
131
|
+
<button
|
|
132
|
+
onClick={() => usePanelStore.getState().closePanel(panelId)}
|
|
133
|
+
className="flex items-center gap-1.5 px-4 py-3.5 rounded-md text-xs text-text-subtle hover:text-foreground active:bg-surface-elevated transition-colors"
|
|
134
|
+
>
|
|
135
|
+
<X className="size-3.5" />
|
|
136
|
+
Close Panel
|
|
137
|
+
</button>
|
|
138
|
+
)}
|
|
139
|
+
|
|
114
140
|
<SessionListPanel
|
|
115
141
|
projectName={activeProject?.name}
|
|
116
142
|
onSelectSession={openSession}
|
|
@@ -30,7 +30,11 @@ export function NotificationBellPopover({ expanded }: { expanded: boolean }) {
|
|
|
30
30
|
|
|
31
31
|
const handleGoToSession = (sessionId: string, projectName: string) => {
|
|
32
32
|
const target = projects.find((p) => p.name === projectName);
|
|
33
|
-
|
|
33
|
+
// projectName is a grouping key, and notifications with no project fall into the
|
|
34
|
+
// "Unknown" bucket. Opening a tab stamped with a project that does not exist makes
|
|
35
|
+
// it invisible in every project — and unclosable, which wedges its panel forever.
|
|
36
|
+
if (!target) return;
|
|
37
|
+
setActiveProject(target);
|
|
34
38
|
openTab({
|
|
35
39
|
type: "chat",
|
|
36
40
|
title: "Chat",
|
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
} from "lucide-react";
|
|
17
17
|
import { useTabStore, type TabType } from "@/stores/tab-store";
|
|
18
18
|
import { usePanelStore } from "@/stores/panel-store";
|
|
19
|
-
import { DOCK_PANEL_ID } from "@/stores/panel-utils";
|
|
19
|
+
import { DOCK_PANEL_ID, visibleTabs } from "@/stores/panel-utils";
|
|
20
20
|
import { PanelBottom, Grid2x2 } from "lucide-react";
|
|
21
21
|
import { useProjectStore } from "@/stores/project-store";
|
|
22
22
|
import { useFileStore, type FileNode } from "@/stores/file-store";
|
|
@@ -85,7 +85,7 @@ export const TabBar = memo(function TabBar({ panelId }: TabBarProps) {
|
|
|
85
85
|
const panel = usePanelStore((s) => panelId ? s.panels[panelId] : s.panels[s.focusedPanelId]);
|
|
86
86
|
const projectName = activeProject?.name ?? null;
|
|
87
87
|
// Filter out cross-project tabs (race condition in openTab during project switch)
|
|
88
|
-
const tabs = (panel?.tabs ?? []
|
|
88
|
+
const tabs = visibleTabs(panel?.tabs ?? [], projectName);
|
|
89
89
|
const activeTabId = panel?.activeTabId ?? null;
|
|
90
90
|
const effectivePanelId = panel?.id ?? usePanelStore.getState().focusedPanelId;
|
|
91
91
|
|