@hienlh/ppm 0.17.33 → 0.17.36
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-CZc98en9.js → ai-resource-editor-aT2nC1lK.js} +1 -1
- package/dist/web/assets/{audio-preview-XkBaDV-d.js → audio-preview-DPS0VpgU.js} +1 -1
- package/dist/web/assets/chat-tab-k7e8hE9y.js +13 -0
- package/dist/web/assets/{code-editor-DeqsPj8M.js → code-editor-C8ClI1kO.js} +2 -2
- package/dist/web/assets/{conflict-editor-CqORnyRu.js → conflict-editor-Dag7buRT.js} +1 -1
- package/dist/web/assets/{csv-preview-i79R-2TA.js → csv-preview-6JQDYpOU.js} +1 -1
- package/dist/web/assets/{database-viewer-ClrtBc_A.js → database-viewer-BFzNauDX.js} +1 -1
- package/dist/web/assets/{diff-viewer-CWUjtM3T.js → diff-viewer-Dub9yryk.js} +1 -1
- package/dist/web/assets/{docx-preview-CFjtBdLf.js → docx-preview-Bj2Xzdef.js} +1 -1
- package/dist/web/assets/{edit-diff-preview-BSjgyGAL.js → edit-diff-preview-D1TEK7Jz.js} +1 -1
- package/dist/web/assets/{extension-webview-DwRdXtK4.js → extension-webview-DUqZPzfp.js} +1 -1
- package/dist/web/assets/{git-log-panel-DI_uaWVT.js → git-log-panel-90_p5Qdn.js} +1 -1
- package/dist/web/assets/{glide-data-grid-Q2kMqNeo.js → glide-data-grid-BSSyHBRx.js} +1 -1
- package/dist/web/assets/group-chat-tab-j1CoraSo.js +1 -0
- package/dist/web/assets/image-gallery-CObzTisL.js +1 -0
- package/dist/web/assets/{image-preview-QyeYNiJt.js → image-preview-ChhxwmZU.js} +1 -1
- package/dist/web/assets/index-BA8NcuLe.js +50 -0
- package/dist/web/assets/index-eW0VEaRF.css +2 -0
- package/dist/web/assets/{keybindings-store-55cr_nFT.js → keybindings-store-Bp-oUCTT.js} +1 -1
- package/dist/web/assets/markdown-renderer-BaPAXsZW.js +3 -0
- package/dist/web/assets/markdown-renderer-CUHI3gc6.js +1 -0
- package/dist/web/assets/{notification-store-Bwzk7bz9.js → notification-store-COr3urzj.js} +1 -1
- package/dist/web/assets/panel-store-CpKvXNnm.js +1 -0
- package/dist/web/assets/{pdf-preview-Dd65pYcK.js → pdf-preview-cJC8Cm9R.js} +1 -1
- package/dist/web/assets/{postgres-viewer-BAeRjowr.js → postgres-viewer-uMVBwY8R.js} +1 -1
- package/dist/web/assets/{settings-store-DFMz_Oer.js → settings-store-CBLPPIts.js} +2 -2
- package/dist/web/assets/{settings-tab-D_Vz5vjU.js → settings-tab-BdEa4MLs.js} +1 -1
- package/dist/web/assets/{sql-query-editor-RrxjEhFI.js → sql-query-editor-R1WsE3n7.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-DULpl_6h.js → sqlite-viewer-CppHnW_J.js} +1 -1
- package/dist/web/assets/{system-monitor-tab-DHOIu_5Y.js → system-monitor-tab-BzK46Q3D.js} +1 -1
- package/dist/web/assets/{tab-store-CNxCwdXw.js → tab-store-BQmDVh9c.js} +1 -1
- package/dist/web/assets/{terminal-tab-B64jdELv.js → terminal-tab-2CWIYa05.js} +1 -1
- package/dist/web/assets/tool-cards-DS4vQ3qy.js +7 -0
- package/dist/web/assets/use-blob-url-jSRmyXVg.js +1 -0
- package/dist/web/assets/{use-monaco-theme-Bf_8qog4.js → use-monaco-theme-CEAY5h6Y.js} +1 -1
- package/dist/web/assets/{video-preview-C-ZB6w-Z.js → video-preview-C7RTSYae.js} +1 -1
- package/dist/web/index.html +5 -5
- package/dist/web/sw.js +1 -1
- package/docker-compose.test.yml +5 -1
- package/package.json +3 -2
- package/src/providers/claude-agent-sdk.ts +4 -3
- package/src/server/routes/chat.ts +66 -1
- package/src/server/routes/fs-browse.ts +13 -2
- package/src/services/image-dimensions.ts +67 -0
- package/src/services/jsonl-transcript-parser.ts +2 -1
- package/src/services/transcript-images.ts +174 -0
- package/src/shared/image-extensions.ts +19 -0
- package/src/shared/tool-result-content.ts +81 -0
- package/src/web/components/chat/chat-history-bar.tsx +3 -104
- package/src/web/components/chat/image-preview-geometry.ts +83 -0
- package/src/web/components/chat/image-preview-states.tsx +51 -0
- 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-cards.tsx +27 -2
- package/src/web/components/chat/tool-image-preview.tsx +227 -0
- package/src/web/components/editor/audio-preview.tsx +1 -1
- package/src/web/components/editor/image-preview.tsx +1 -1
- package/src/web/components/editor/video-preview.tsx +1 -1
- 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/{components/editor → hooks}/use-blob-url.ts +14 -3
- package/src/web/hooks/use-image-transform.ts +276 -0
- 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/src/web/styles/globals.css +72 -0
- package/dist/web/assets/chat-tab-Cpxrbm1O.js +0 -13
- package/dist/web/assets/group-chat-tab-2NJcUl_4.js +0 -1
- package/dist/web/assets/index-BI35sAVB.js +0 -50
- package/dist/web/assets/index-BW2cOdzp.css +0 -2
- package/dist/web/assets/markdown-renderer-ClDzjZ5T.js +0 -1
- package/dist/web/assets/markdown-renderer-DoofKFOC.js +0 -3
- package/dist/web/assets/panel-store-D-RxcUgH.js +0 -1
- package/dist/web/assets/tool-cards-DT3R2C18.js +0 -7
- package/dist/web/assets/use-blob-url-kvtWmJtt.js +0 -1
- package/skills-lock.json +0 -11
- package/spike-memory-region-dump.ps1 +0 -69
- /package/dist/web/assets/{api-themes-BpoKUPkQ.js → api-themes-BLUwA0Gh.js} +0 -0
- /package/dist/web/assets/{csv-parser-BSnYgPcx.js → csv-parser-DVHBcvuE.js} +0 -0
- /package/dist/web/assets/{lib-DA3QSc5_.js → lib-DTaN9riD.js} +0 -0
- /package/dist/web/assets/{libesm-Dd8zbj1v.js → libesm-DdtEjhIi.js} +0 -0
- /package/dist/web/assets/{vendor-xterm-CsgYwG03.js → vendor-xterm-QDr0jvpJ.js} +0 -0
|
@@ -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
|
+
}
|
|
@@ -67,6 +67,9 @@ import type { ChatEvent } from "../../../types/chat";
|
|
|
67
67
|
import { useShallow } from "zustand/react/shallow";
|
|
68
68
|
import { useTabStore } from "@/stores/tab-store";
|
|
69
69
|
import { basename } from "@/lib/utils";
|
|
70
|
+
import { isImageExtension } from "../../../shared/image-extensions";
|
|
71
|
+
import { resultHasImagePlaceholder } from "../../../shared/tool-result-content";
|
|
72
|
+
import { ToolImagePreview } from "./tool-image-preview";
|
|
70
73
|
|
|
71
74
|
/** Extract tool name and input from a ChatEvent */
|
|
72
75
|
function extractToolInfo(tool: ChatEvent): { toolName: string; input: Record<string, unknown> } {
|
|
@@ -84,6 +87,18 @@ function extractToolInfo(tool: ChatEvent): { toolName: string; input: Record<str
|
|
|
84
87
|
return { toolName, input };
|
|
85
88
|
}
|
|
86
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Path of the image a Read call targeted, or null when it is not a displayable image read.
|
|
92
|
+
* Only absolute paths qualify, matching what useBlobUrl can resolve to the external
|
|
93
|
+
* raw-file endpoint — and what the Read tool always supplies.
|
|
94
|
+
*/
|
|
95
|
+
function imageReadPath(tool: ChatEvent): string | null {
|
|
96
|
+
if (tool.type !== "tool_use" || tool.tool !== "Read") return null;
|
|
97
|
+
const path = (tool.input as Record<string, unknown> | undefined)?.file_path;
|
|
98
|
+
if (typeof path !== "string" || !/^(\/|[A-Za-z]:[/\\])/.test(path)) return null;
|
|
99
|
+
return isImageExtension(path) ? path : null;
|
|
100
|
+
}
|
|
101
|
+
|
|
87
102
|
/** Unified tool card: shows tool-specific summary + expandable details */
|
|
88
103
|
export function ToolCard({
|
|
89
104
|
tool,
|
|
@@ -103,7 +118,10 @@ export function ToolCard({
|
|
|
103
118
|
// a click — unless the edit failed (diff was never applied, so it's noise).
|
|
104
119
|
const t = tool.type === "tool_use" ? tool.tool : (tool as any).tool;
|
|
105
120
|
const failed = result?.type === "tool_result" && !!(result as any).isError;
|
|
106
|
-
|
|
121
|
+
if ((t === "Edit" || t === "MultiEdit") && !failed) return true;
|
|
122
|
+
// Same reasoning for reading an image: the thumbnail is the point of the card,
|
|
123
|
+
// so show it without requiring a click.
|
|
124
|
+
return !!imageReadPath(tool);
|
|
107
125
|
});
|
|
108
126
|
|
|
109
127
|
if (tool.type === "error") {
|
|
@@ -127,6 +145,10 @@ export function ToolCard({
|
|
|
127
145
|
// File-mutation tools show their change via the inline diff/content preview — the SDK's
|
|
128
146
|
// "file updated successfully" boilerplate is noise, so suppress it (but keep error output).
|
|
129
147
|
const isFileMutation = ["Edit", "MultiEdit", "Write", "NotebookEdit"].includes(toolName);
|
|
148
|
+
const imagePath = imageReadPath(tool);
|
|
149
|
+
// Hide the text output only when the result really carried an image. Extension is not
|
|
150
|
+
// enough: an SVG or an undecodable format comes back as text that must stay visible.
|
|
151
|
+
const resultIsImage = hasResult && resultHasImagePlaceholder(String((result as any).output ?? ""));
|
|
130
152
|
|
|
131
153
|
// Read partial output for streaming Bash/PowerShell tools
|
|
132
154
|
const toolUseId = tool.type === "tool_use" ? (tool as any).toolUseId as string | undefined : undefined;
|
|
@@ -194,7 +216,10 @@ export function ToolCard({
|
|
|
194
216
|
{hasChildren && (
|
|
195
217
|
<SubagentChildren events={children!} projectName={projectName} />
|
|
196
218
|
)}
|
|
197
|
-
{
|
|
219
|
+
{imagePath && (
|
|
220
|
+
<ToolImagePreview filePath={imagePath} projectName={projectName ?? ""} />
|
|
221
|
+
)}
|
|
222
|
+
{hasResult && !(isFileMutation && !isError) && !(resultIsImage && !isError) && (
|
|
198
223
|
<ToolResultView toolName={toolName} output={(result as any).output} />
|
|
199
224
|
)}
|
|
200
225
|
</div>
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { useShallow } from "zustand/react/shallow";
|
|
3
|
+
import { useBlobUrl } from "@/hooks/use-blob-url";
|
|
4
|
+
import { useImageOverlay } from "@/stores/image-overlay-store";
|
|
5
|
+
import { collectGallery, GALLERY_ITEM_ATTR } from "@/lib/image-gallery";
|
|
6
|
+
import { useTabStore } from "@/stores/tab-store";
|
|
7
|
+
import { copyToClipboard } from "@/lib/clipboard";
|
|
8
|
+
import { basename } from "@/lib/utils";
|
|
9
|
+
import { ImagePreviewFailure, ImagePreviewSkeleton } from "./image-preview-states";
|
|
10
|
+
import {
|
|
11
|
+
cacheNatural,
|
|
12
|
+
dimensionsLabel,
|
|
13
|
+
formatBytes,
|
|
14
|
+
getCachedNatural,
|
|
15
|
+
previewLayout,
|
|
16
|
+
TILE_WIDTH,
|
|
17
|
+
type NaturalSize,
|
|
18
|
+
} from "./image-preview-geometry";
|
|
19
|
+
|
|
20
|
+
/** Ratio the skeleton assumes before the image has ever been measured. */
|
|
21
|
+
const FALLBACK_ASPECT = 4 / 5;
|
|
22
|
+
|
|
23
|
+
const LABEL = "text-[var(--img-meta-label)] @max-[470px]:hidden";
|
|
24
|
+
const VALUE = "truncate text-text @max-[470px]:text-text-2";
|
|
25
|
+
const GHOST =
|
|
26
|
+
"h-[30px] @max-[470px]:h-[36px] shrink-0 rounded-[7px] px-[9px] font-mono text-[11.5px] " +
|
|
27
|
+
"text-text-2 bg-[var(--img-ghost)] border border-[var(--img-ghost-border)] backdrop-blur-[6px] " +
|
|
28
|
+
"transition-colors can-hover:hover:bg-[var(--img-ghost-hover)] " +
|
|
29
|
+
"can-hover:hover:border-[var(--img-ghost-hover-border)] can-hover:hover:text-text";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The image a Read call opened, shown on a stage lit by a blurred copy of itself, beside
|
|
33
|
+
* the file facts needed to decide whether to open it. Fetched as a blob with an
|
|
34
|
+
* Authorization header, so no auth token ends up in an image URL.
|
|
35
|
+
*/
|
|
36
|
+
export function ToolImagePreview({
|
|
37
|
+
filePath,
|
|
38
|
+
projectName,
|
|
39
|
+
}: {
|
|
40
|
+
filePath: string;
|
|
41
|
+
projectName: string;
|
|
42
|
+
}) {
|
|
43
|
+
const [attempt, setAttempt] = useState(0);
|
|
44
|
+
const { blobUrl, blob, error, errorCode } = useBlobUrl(filePath, projectName, undefined, attempt);
|
|
45
|
+
const [natural, setNatural] = useState<NaturalSize | undefined>(() => getCachedNatural(filePath));
|
|
46
|
+
const [decodeFailed, setDecodeFailed] = useState(false);
|
|
47
|
+
const [copied, setCopied] = useState(false);
|
|
48
|
+
const openOverlay = useImageOverlay((s) => s.open);
|
|
49
|
+
const { openTab } = useTabStore(useShallow((s) => ({ openTab: s.openTab })));
|
|
50
|
+
const name = basename(filePath);
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (!copied) return;
|
|
54
|
+
const timer = setTimeout(() => setCopied(false), 1200);
|
|
55
|
+
return () => clearTimeout(timer);
|
|
56
|
+
}, [copied]);
|
|
57
|
+
|
|
58
|
+
const measure = (img: HTMLImageElement) => {
|
|
59
|
+
const size = { w: img.naturalWidth, h: img.naturalHeight };
|
|
60
|
+
if (!size.w || !size.h) return setDecodeFailed(true);
|
|
61
|
+
cacheNatural(filePath, size);
|
|
62
|
+
setNatural(size);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const retry = () => {
|
|
66
|
+
setDecodeFailed(false);
|
|
67
|
+
setAttempt((n) => n + 1);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
if (error || decodeFailed) {
|
|
71
|
+
return (
|
|
72
|
+
<div className="border-t border-border pt-1.5">
|
|
73
|
+
<ImagePreviewFailure code={decodeFailed ? undefined : errorCode ?? undefined} onRetry={retry} />
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const layout = natural ? previewLayout(natural, TILE_WIDTH) : null;
|
|
79
|
+
const skeletonAspect = layout?.aspect ?? FALLBACK_ASPECT;
|
|
80
|
+
|
|
81
|
+
const actions = (
|
|
82
|
+
<div className="flex flex-wrap gap-[6px]">
|
|
83
|
+
<button type="button" onClick={openInEditor} className={GHOST}>
|
|
84
|
+
<span className="@max-[470px]:hidden">open in editor</span>
|
|
85
|
+
<span className="hidden @max-[470px]:inline">open</span>
|
|
86
|
+
</button>
|
|
87
|
+
<button type="button" onClick={copyPath} className={GHOST}>
|
|
88
|
+
{copied ? "copied" : "copy path"}
|
|
89
|
+
</button>
|
|
90
|
+
<button type="button" onClick={openFull} className={GHOST}>
|
|
91
|
+
full size
|
|
92
|
+
</button>
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
function openInEditor() {
|
|
97
|
+
if (!projectName) return;
|
|
98
|
+
openTab({
|
|
99
|
+
type: "editor",
|
|
100
|
+
title: name,
|
|
101
|
+
metadata: { filePath, projectName },
|
|
102
|
+
projectId: projectName,
|
|
103
|
+
closable: true,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function copyPath() {
|
|
108
|
+
void copyToClipboard(filePath).then(() => setCopied(true));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function openFull(e: { currentTarget: Element }) {
|
|
112
|
+
if (blobUrl) openOverlay(blobUrl, name, collectGallery(e.currentTarget));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Loading: the same box as the loaded state, so the transcript never shifts under the
|
|
116
|
+
// reader once the image resolves.
|
|
117
|
+
if (!blobUrl || !layout || !natural) {
|
|
118
|
+
return (
|
|
119
|
+
<div className="@container border-t border-border pt-1.5">
|
|
120
|
+
{blobUrl && (
|
|
121
|
+
<img
|
|
122
|
+
src={blobUrl}
|
|
123
|
+
alt=""
|
|
124
|
+
className="hidden"
|
|
125
|
+
onLoad={(e) => measure(e.currentTarget)}
|
|
126
|
+
onError={() => setDecodeFailed(true)}
|
|
127
|
+
/>
|
|
128
|
+
)}
|
|
129
|
+
<ImagePreviewSkeleton aspect={skeletonAspect} actions={actions} />
|
|
130
|
+
</div>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const metadata = (
|
|
135
|
+
<>
|
|
136
|
+
<dt className={LABEL}>dimensions</dt>
|
|
137
|
+
<dd className={VALUE}>{dimensionsLabel(natural, layout.fit)}</dd>
|
|
138
|
+
{blob && (
|
|
139
|
+
<>
|
|
140
|
+
<dt className={LABEL}>size</dt>
|
|
141
|
+
<dd className={VALUE}>
|
|
142
|
+
{formatBytes(blob.size)}
|
|
143
|
+
{blob.type ? ` · ${blob.type}` : ""}
|
|
144
|
+
</dd>
|
|
145
|
+
</>
|
|
146
|
+
)}
|
|
147
|
+
<dt className={LABEL}>path</dt>
|
|
148
|
+
{/* rtl puts the ellipsis at the head of the path, keeping the filename readable */}
|
|
149
|
+
<dd dir="rtl" className={`${VALUE} text-left`}>
|
|
150
|
+
{filePath}
|
|
151
|
+
</dd>
|
|
152
|
+
</>
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
const picture = (
|
|
156
|
+
<img
|
|
157
|
+
src={blobUrl}
|
|
158
|
+
alt={name}
|
|
159
|
+
{...{ [GALLERY_ITEM_ATTR]: "" }}
|
|
160
|
+
onLoad={(e) => measure(e.currentTarget)}
|
|
161
|
+
onError={() => setDecodeFailed(true)}
|
|
162
|
+
className={
|
|
163
|
+
layout.fit === "cover"
|
|
164
|
+
? "size-full object-cover"
|
|
165
|
+
: layout.fit === "contain"
|
|
166
|
+
? "size-full object-contain"
|
|
167
|
+
: "max-h-full max-w-full"
|
|
168
|
+
}
|
|
169
|
+
/>
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
return (
|
|
173
|
+
<div className="@container border-t border-border pt-1.5">
|
|
174
|
+
<div className="relative overflow-hidden rounded-lg border border-border p-[14px] @max-[470px]:p-[11px]">
|
|
175
|
+
<img
|
|
176
|
+
aria-hidden="true"
|
|
177
|
+
alt=""
|
|
178
|
+
src={blobUrl}
|
|
179
|
+
className="absolute inset-0 size-full object-cover"
|
|
180
|
+
style={{ filter: "var(--img-backdrop)", transform: "scale(1.3)" }}
|
|
181
|
+
/>
|
|
182
|
+
<div className="absolute inset-0" style={{ background: "var(--img-veil)" }} />
|
|
183
|
+
|
|
184
|
+
{layout.variant === "band" ? (
|
|
185
|
+
<div className="relative grid gap-[11px]">
|
|
186
|
+
<button
|
|
187
|
+
type="button"
|
|
188
|
+
onClick={openFull}
|
|
189
|
+
title={`Open ${name}`}
|
|
190
|
+
className="img-checkerboard w-full cursor-zoom-in overflow-hidden rounded-lg border border-[var(--img-plate-border)]"
|
|
191
|
+
style={{ aspectRatio: layout.aspect }}
|
|
192
|
+
>
|
|
193
|
+
{picture}
|
|
194
|
+
</button>
|
|
195
|
+
<dl className="grid grid-cols-[auto_auto_1fr] gap-x-[18px] font-mono text-[11.5px] [&_dt]:hidden">
|
|
196
|
+
{metadata}
|
|
197
|
+
</dl>
|
|
198
|
+
{actions}
|
|
199
|
+
</div>
|
|
200
|
+
) : (
|
|
201
|
+
<div className="relative grid grid-cols-[132px_1fr] items-start gap-[14px] @max-[470px]:grid-cols-[64px_1fr] @max-[470px]:gap-[11px]">
|
|
202
|
+
<button
|
|
203
|
+
type="button"
|
|
204
|
+
onClick={openFull}
|
|
205
|
+
title={`Open ${name}`}
|
|
206
|
+
className={`cursor-zoom-in overflow-hidden rounded-lg border border-[var(--img-plate-border)] bg-[var(--img-plate)] shadow-[var(--img-plate-shadow)] ${
|
|
207
|
+
layout.fit === "cover" ? "" : "img-checkerboard flex items-center justify-center"
|
|
208
|
+
}`}
|
|
209
|
+
style={{ aspectRatio: layout.aspect }}
|
|
210
|
+
>
|
|
211
|
+
{picture}
|
|
212
|
+
</button>
|
|
213
|
+
<div className="flex min-w-0 flex-col gap-[9px]">
|
|
214
|
+
<p className="truncate text-[13px] font-semibold text-text @max-[470px]:hidden">
|
|
215
|
+
{name}
|
|
216
|
+
</p>
|
|
217
|
+
<dl className="grid grid-cols-[auto_1fr] gap-x-[14px] gap-y-[4px] font-mono text-[11.5px] @max-[470px]:grid-cols-[1fr]">
|
|
218
|
+
{metadata}
|
|
219
|
+
</dl>
|
|
220
|
+
{actions}
|
|
221
|
+
</div>
|
|
222
|
+
</div>
|
|
223
|
+
)}
|
|
224
|
+
</div>
|
|
225
|
+
</div>
|
|
226
|
+
);
|
|
227
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Loader2, FileWarning, Music } from "lucide-react";
|
|
2
|
-
import { useBlobUrl } from "
|
|
2
|
+
import { useBlobUrl } from "@/hooks/use-blob-url";
|
|
3
3
|
import { basename } from "@/lib/utils";
|
|
4
4
|
|
|
5
5
|
export function AudioPreview({ filePath, projectName }: { filePath: string; projectName: string }) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Loader2, FileWarning } from "lucide-react";
|
|
2
|
-
import { useBlobUrl } from "
|
|
2
|
+
import { useBlobUrl } from "@/hooks/use-blob-url";
|
|
3
3
|
|
|
4
4
|
export function ImagePreview({ filePath, projectName }: { filePath: string; projectName: string }) {
|
|
5
5
|
const { blobUrl, error } = useBlobUrl(filePath, projectName);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Loader2, FileWarning } from "lucide-react";
|
|
2
|
-
import { useBlobUrl } from "
|
|
2
|
+
import { useBlobUrl } from "@/hooks/use-blob-url";
|
|
3
3
|
|
|
4
4
|
export function VideoPreview({ filePath, projectName }: { filePath: string; projectName: string }) {
|
|
5
5
|
const { blobUrl, error } = useBlobUrl(filePath, projectName);
|
|
@@ -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",
|