@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,174 @@
|
|
|
1
|
+
import { readImageDimensions } from "./image-dimensions.ts";
|
|
2
|
+
import { base64ByteLength, imagePlaceholderText } from "../shared/tool-result-content.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Auditing and removal of image payloads inside a Claude Code session transcript.
|
|
6
|
+
*
|
|
7
|
+
* A transcript is replayed in full on every resume, so each image it carries is re-sent for
|
|
8
|
+
* the rest of the session's life. Past a few images the API refuses any that exceed
|
|
9
|
+
* MANY_IMAGE_DIMENSION_LIMIT on a side and reports that it removed them — so those payloads
|
|
10
|
+
* cost bandwidth and produce an error while contributing nothing to the model's context.
|
|
11
|
+
*
|
|
12
|
+
* Only images inside tool results are touched. An image the user attached to a message may
|
|
13
|
+
* no longer exist anywhere else, whereas a tool result can be produced again by re-reading
|
|
14
|
+
* the file it came from.
|
|
15
|
+
*
|
|
16
|
+
* The payload lives at `message.content[].content[]`. A record also carries an image-shaped
|
|
17
|
+
* echo at the top-level `toolUseResult` field, but that one holds no base64 and is not part
|
|
18
|
+
* of an API request, so it is left as the CLI wrote it.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Per-image dimension cap the API applies once a request carries several images. */
|
|
22
|
+
export const MANY_IMAGE_DIMENSION_LIMIT = 2000;
|
|
23
|
+
|
|
24
|
+
/** Base64 characters decoded to inspect a header — a JPEG frame can sit behind EXIF. */
|
|
25
|
+
const HEADER_CHARS = 4096;
|
|
26
|
+
|
|
27
|
+
export type StripMode = "oversized" | "all";
|
|
28
|
+
|
|
29
|
+
export interface TranscriptImageAudit {
|
|
30
|
+
/** Images found in tool results. */
|
|
31
|
+
total: number;
|
|
32
|
+
/** How many exceed the dimension cap and are therefore already being dropped. */
|
|
33
|
+
oversized: number;
|
|
34
|
+
/** Decoded size of all of them. */
|
|
35
|
+
bytes: number;
|
|
36
|
+
/** Decoded size of the oversized ones. */
|
|
37
|
+
oversizedBytes: number;
|
|
38
|
+
/** Longest side seen, or 0 when no dimensions could be read. */
|
|
39
|
+
largestSide: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface StripResult {
|
|
43
|
+
text: string;
|
|
44
|
+
removed: number;
|
|
45
|
+
bytesFreed: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface ImageBlock {
|
|
49
|
+
source?: { data?: unknown };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type Visitor = (image: ImageBlock, replace: (block: unknown) => void) => void;
|
|
53
|
+
|
|
54
|
+
function isImageBlock(value: unknown): value is ImageBlock {
|
|
55
|
+
return !!value && typeof value === "object" && (value as { type?: unknown }).type === "image";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Walk a tool result's content, offering each image block and a way to replace it. */
|
|
59
|
+
function visitImages(node: unknown, visit: Visitor): void {
|
|
60
|
+
if (!node || typeof node !== "object") return;
|
|
61
|
+
if (Array.isArray(node)) {
|
|
62
|
+
for (let i = 0; i < node.length; i++) {
|
|
63
|
+
const child = node[i];
|
|
64
|
+
if (isImageBlock(child)) {
|
|
65
|
+
visit(child, (block) => {
|
|
66
|
+
node[i] = block;
|
|
67
|
+
});
|
|
68
|
+
} else {
|
|
69
|
+
visitImages(child, visit);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
for (const value of Object.values(node)) visitImages(value, visit);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Images carried by tool results only — attachments on user messages are left alone. */
|
|
78
|
+
function visitToolResultImages(record: unknown, visit: Visitor): void {
|
|
79
|
+
const content = (record as { message?: { content?: unknown } } | null)?.message?.content;
|
|
80
|
+
if (!Array.isArray(content)) return;
|
|
81
|
+
for (const block of content) {
|
|
82
|
+
if ((block as { type?: unknown } | null)?.type !== "tool_result") continue;
|
|
83
|
+
visitImages((block as { content?: unknown }).content, visit);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function imageData(image: ImageBlock): string {
|
|
88
|
+
const data = image.source?.data;
|
|
89
|
+
return typeof data === "string" ? data : "";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Longest side in pixels, or 0 when the format or header could not be read. */
|
|
93
|
+
function longestSide(data: string): number {
|
|
94
|
+
if (!data) return 0;
|
|
95
|
+
const slice = data.slice(0, HEADER_CHARS);
|
|
96
|
+
// Base64 decodes in four-character groups; a partial group would corrupt the tail.
|
|
97
|
+
const whole = slice.slice(0, slice.length - (slice.length % 4));
|
|
98
|
+
const size = readImageDimensions(Buffer.from(whole, "base64"));
|
|
99
|
+
return size ? Math.max(size.w, size.h) : 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function auditTranscriptImages(text: string): TranscriptImageAudit {
|
|
103
|
+
const audit: TranscriptImageAudit = {
|
|
104
|
+
total: 0,
|
|
105
|
+
oversized: 0,
|
|
106
|
+
bytes: 0,
|
|
107
|
+
oversizedBytes: 0,
|
|
108
|
+
largestSide: 0,
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
for (const line of text.split("\n")) {
|
|
112
|
+
if (!line) continue;
|
|
113
|
+
let record: unknown;
|
|
114
|
+
try {
|
|
115
|
+
record = JSON.parse(line);
|
|
116
|
+
} catch {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
visitToolResultImages(record, (image) => {
|
|
120
|
+
const data = imageData(image);
|
|
121
|
+
const bytes = base64ByteLength(data);
|
|
122
|
+
const side = longestSide(data);
|
|
123
|
+
audit.total++;
|
|
124
|
+
audit.bytes += bytes;
|
|
125
|
+
if (side > audit.largestSide) audit.largestSide = side;
|
|
126
|
+
if (side > MANY_IMAGE_DIMENSION_LIMIT) {
|
|
127
|
+
audit.oversized++;
|
|
128
|
+
audit.oversizedBytes += bytes;
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return audit;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Replace image payloads with the standard placeholder text.
|
|
138
|
+
*
|
|
139
|
+
* `oversized` leaves an image whose dimensions cannot be read, since removing something
|
|
140
|
+
* that may well be within the cap would lose context for no benefit.
|
|
141
|
+
*/
|
|
142
|
+
export function stripTranscriptImages(text: string, mode: StripMode): StripResult {
|
|
143
|
+
let removed = 0;
|
|
144
|
+
let bytesFreed = 0;
|
|
145
|
+
let changed = false;
|
|
146
|
+
|
|
147
|
+
const lines = text.split("\n").map((line) => {
|
|
148
|
+
if (!line) return line;
|
|
149
|
+
let record: unknown;
|
|
150
|
+
try {
|
|
151
|
+
record = JSON.parse(line);
|
|
152
|
+
} catch {
|
|
153
|
+
// A line that does not parse is left byte-for-byte as it was found.
|
|
154
|
+
return line;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
let touched = false;
|
|
158
|
+
visitToolResultImages(record, (image, replace) => {
|
|
159
|
+
const data = imageData(image);
|
|
160
|
+
if (mode === "oversized" && longestSide(data) <= MANY_IMAGE_DIMENSION_LIMIT) return;
|
|
161
|
+
const bytes = base64ByteLength(data);
|
|
162
|
+
replace({ type: "text", text: imagePlaceholderText(bytes || undefined) });
|
|
163
|
+
removed++;
|
|
164
|
+
bytesFreed += bytes;
|
|
165
|
+
touched = true;
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
if (!touched) return line;
|
|
169
|
+
changed = true;
|
|
170
|
+
return JSON.stringify(record);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
return { text: changed ? lines.join("\n") : text, removed, bytesFreed };
|
|
174
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image file extensions, shared by the raw-file route and the chat image renderer so the
|
|
3
|
+
* server never refuses a path the UI is about to render (or the reverse).
|
|
4
|
+
*
|
|
5
|
+
* SVG is deliberately absent. The raw-file route serves files inline with their real
|
|
6
|
+
* Content-Type and accepts a `?token=` query parameter, so an SVG streamed from an arbitrary
|
|
7
|
+
* path could execute script in the app's own origin. `Read` also returns SVG as text, so
|
|
8
|
+
* there is nothing to display anyway.
|
|
9
|
+
*/
|
|
10
|
+
const IMAGE_EXTENSIONS = new Set([
|
|
11
|
+
".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".avif", ".ico",
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
/** Check whether a path names an image file, by extension. */
|
|
15
|
+
export function isImageExtension(path: string): boolean {
|
|
16
|
+
const dot = path.lastIndexOf(".");
|
|
17
|
+
if (dot === -1) return false;
|
|
18
|
+
return IMAGE_EXTENSIONS.has(path.slice(dot).toLowerCase());
|
|
19
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serialization of a tool_result `content` value for transport to the chat UI.
|
|
3
|
+
*
|
|
4
|
+
* Tools that read images return base64 content blocks. Stringifying those verbatim sends
|
|
5
|
+
* ~1.37 bytes of text per image byte over the websocket, into the replay buffer, and on into
|
|
6
|
+
* the DOM, where nothing truncates it. Swap image blocks for a short placeholder instead.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Rewrite image blocks to placeholders, then stringify. Non-image input is untouched. */
|
|
10
|
+
export function stringifyToolResultContent(content: unknown): string {
|
|
11
|
+
if (typeof content === "string") return content;
|
|
12
|
+
if (!Array.isArray(content)) return JSON.stringify(content) ?? "";
|
|
13
|
+
|
|
14
|
+
let hasImage = false;
|
|
15
|
+
const rewritten = content.map((block) => {
|
|
16
|
+
if (!isImageBlock(block)) return block;
|
|
17
|
+
hasImage = true;
|
|
18
|
+
return { type: "text", text: imagePlaceholder(block) };
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// Only rebuild when an image was actually present, so consumers that parse the block
|
|
22
|
+
// array (e.g. the Agent/Task result renderer) see a byte-identical string otherwise.
|
|
23
|
+
return JSON.stringify(hasImage ? rewritten : content) ?? "";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isImageBlock(block: unknown): block is { source?: { data?: unknown } } {
|
|
27
|
+
return !!block && typeof block === "object" && (block as { type?: unknown }).type === "image";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function imagePlaceholder(block: { source?: { data?: unknown } }): string {
|
|
31
|
+
const data = block.source?.data;
|
|
32
|
+
if (typeof data !== "string" || data.length === 0) return imagePlaceholderText();
|
|
33
|
+
return imagePlaceholderText(base64ByteLength(data));
|
|
34
|
+
}
|
|
35
|
+
|
|
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 {
|
|
48
|
+
const padding = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0;
|
|
49
|
+
return Math.max(0, Math.floor((data.length * 3) / 4) - padding);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function formatBytes(bytes: number): string {
|
|
53
|
+
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
54
|
+
if (bytes >= 1024) return `${Math.round(bytes / 1024)}KB`;
|
|
55
|
+
return `${bytes}B`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A whole text block that is exactly a placeholder this module produced. */
|
|
59
|
+
const PLACEHOLDER_BLOCK = /^\[image(?: · [\d.]+(?:B|KB|MB))?\]$/;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Whether a serialized tool result actually carried an image.
|
|
63
|
+
*
|
|
64
|
+
* A file extension cannot answer this: SVG comes back as text, and formats the API cannot
|
|
65
|
+
* decode come back as text or as an error. Callers that hide textual output in favour of a
|
|
66
|
+
* rendered image must key off the real result rather than the requested path.
|
|
67
|
+
*/
|
|
68
|
+
export function resultHasImagePlaceholder(output: string): boolean {
|
|
69
|
+
if (!output.startsWith("[")) return false;
|
|
70
|
+
let blocks: unknown;
|
|
71
|
+
try {
|
|
72
|
+
blocks = JSON.parse(output);
|
|
73
|
+
} catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
if (!Array.isArray(blocks)) return false;
|
|
77
|
+
return blocks.some((block) => {
|
|
78
|
+
const b = block as { type?: unknown; text?: unknown } | null;
|
|
79
|
+
return b?.type === "text" && typeof b.text === "string" && PLACEHOLDER_BLOCK.test(b.text);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
@@ -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 */}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Geometry and formatting for the Read-tool image result panel.
|
|
3
|
+
*
|
|
4
|
+
* The tile's aspect ratio follows the image so nothing is cropped, but only within a
|
|
5
|
+
* clamp: very tall captures would become unreadable slivers, and panoramas would waste
|
|
6
|
+
* the width they could use.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const TILE_MIN_RATIO = 0.62;
|
|
10
|
+
export const TILE_MAX_RATIO = 2.4;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Tile width in px; the narrow value applies under a 470px container.
|
|
14
|
+
*
|
|
15
|
+
* The rendered widths come from Tailwind classes in tool-image-preview.tsx, which cannot
|
|
16
|
+
* read these constants — change both together. Layout is computed at the wide width, and
|
|
17
|
+
* a native-size image that no longer fits the narrow tile is capped by the box rather than
|
|
18
|
+
* overflowing it, so the two staying in sync only affects which images read as native.
|
|
19
|
+
*/
|
|
20
|
+
export const TILE_WIDTH = 132;
|
|
21
|
+
export const TILE_WIDTH_NARROW = 64;
|
|
22
|
+
|
|
23
|
+
export interface NaturalSize {
|
|
24
|
+
w: number;
|
|
25
|
+
h: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type ImageFit =
|
|
29
|
+
/** Box matches the image, so cover crops nothing. */
|
|
30
|
+
| "cover"
|
|
31
|
+
/** Taller than the clamp: fit inside the box on the checkerboard. */
|
|
32
|
+
| "contain"
|
|
33
|
+
/** Smaller than the box: native size, centered, never upscaled. */
|
|
34
|
+
| "native";
|
|
35
|
+
|
|
36
|
+
export interface PreviewLayout {
|
|
37
|
+
/** `band` drops the two-column split for panoramas. */
|
|
38
|
+
variant: "split" | "band";
|
|
39
|
+
/** width / height of the rendered box. */
|
|
40
|
+
aspect: number;
|
|
41
|
+
fit: ImageFit;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function previewLayout(natural: NaturalSize, tileWidth: number): PreviewLayout {
|
|
45
|
+
const ratio = natural.w / natural.h;
|
|
46
|
+
|
|
47
|
+
if (ratio > TILE_MAX_RATIO) {
|
|
48
|
+
return { variant: "band", aspect: ratio, fit: "contain" };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const aspect = Math.max(TILE_MIN_RATIO, ratio);
|
|
52
|
+
const boxHeight = tileWidth / aspect;
|
|
53
|
+
if (natural.w < tileWidth && natural.h < boxHeight) {
|
|
54
|
+
return { variant: "split", aspect, fit: "native" };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return { variant: "split", aspect, fit: ratio < TILE_MIN_RATIO ? "contain" : "cover" };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function formatBytes(bytes: number): string {
|
|
61
|
+
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
62
|
+
if (bytes >= 1024) return `${Math.round(bytes / 1024)} KB`;
|
|
63
|
+
return `${bytes} B`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function dimensionsLabel(natural: NaturalSize, fit: ImageFit): string {
|
|
67
|
+
const base = `${natural.w} × ${natural.h} px`;
|
|
68
|
+
return fit === "native" ? `${base} · shown 1:1` : base;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Natural sizes survive unmount so a card that scrolls out and back does not resolve
|
|
73
|
+
* its height a second time — a late height change mid-transcript jumps the scroll.
|
|
74
|
+
*/
|
|
75
|
+
const naturalSizes = new Map<string, NaturalSize>();
|
|
76
|
+
|
|
77
|
+
export function getCachedNatural(path: string): NaturalSize | undefined {
|
|
78
|
+
return naturalSizes.get(path);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function cacheNatural(path: string, size: NaturalSize): void {
|
|
82
|
+
naturalSizes.set(path, size);
|
|
83
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Fragment, type ReactNode } from "react";
|
|
2
|
+
import { AlertCircle } from "lucide-react";
|
|
3
|
+
|
|
4
|
+
/** Metadata label styling, shared with the loaded panel so the skeleton lines up. */
|
|
5
|
+
const LABEL = "text-[var(--img-meta-label)] @max-[470px]:hidden";
|
|
6
|
+
|
|
7
|
+
/** Widths that keep the placeholder bars from looking like a uniform block. */
|
|
8
|
+
const BAR_WIDTHS = ["60%", "45%", "80%"];
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Loading state for the image panel.
|
|
12
|
+
*
|
|
13
|
+
* Occupies the same box as the loaded panel — same tile ratio, same three metadata rows,
|
|
14
|
+
* same action row — so the transcript does not shift under the reader when the image lands.
|
|
15
|
+
* `aspect` should come from the cached natural size when the file has been seen before.
|
|
16
|
+
*/
|
|
17
|
+
export function ImagePreviewSkeleton({ aspect, actions }: { aspect: number; actions: ReactNode }) {
|
|
18
|
+
return (
|
|
19
|
+
<div className="grid grid-cols-[132px_1fr] items-start gap-[14px] overflow-hidden rounded-lg border border-border bg-panel p-[14px] @max-[470px]:grid-cols-[64px_1fr] @max-[470px]:gap-[11px] @max-[470px]:p-[11px]">
|
|
20
|
+
<div className="img-skeleton rounded-lg" style={{ aspectRatio: aspect }} />
|
|
21
|
+
<div className="flex min-w-0 flex-col gap-[9px]">
|
|
22
|
+
<div className="img-skeleton h-[13px] w-2/5 rounded @max-[470px]:hidden" />
|
|
23
|
+
{/* Labels stay as text so only the values arrive later — the row keeps its box. */}
|
|
24
|
+
<dl className="grid grid-cols-[auto_1fr] gap-x-[14px] gap-y-[4px] font-mono text-[11.5px] @max-[470px]:grid-cols-[1fr]">
|
|
25
|
+
{["dimensions", "size", "path"].map((label, i) => (
|
|
26
|
+
<Fragment key={label}>
|
|
27
|
+
<dt className={LABEL}>{label}</dt>
|
|
28
|
+
<dd className="flex items-center">
|
|
29
|
+
<span className="img-skeleton h-[11px] rounded" style={{ width: BAR_WIDTHS[i] }} />
|
|
30
|
+
</dd>
|
|
31
|
+
</Fragment>
|
|
32
|
+
))}
|
|
33
|
+
</dl>
|
|
34
|
+
{actions}
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Failure state: a compact chip, since there is nothing to show and no space to justify. */
|
|
41
|
+
export function ImagePreviewFailure({ code, onRetry }: { code?: string; onRetry: () => void }) {
|
|
42
|
+
return (
|
|
43
|
+
<div className="flex items-center gap-2 rounded-lg border border-border bg-panel px-2.5 py-2 font-mono text-[12px] text-text-2">
|
|
44
|
+
<AlertCircle className="size-3 shrink-0" />
|
|
45
|
+
<span className="truncate">preview unavailable{code ? ` · ${code}` : ""}</span>
|
|
46
|
+
<button type="button" onClick={onRetry} className="ml-auto shrink-0 text-accent">
|
|
47
|
+
retry
|
|
48
|
+
</button>
|
|
49
|
+
</div>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
@@ -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
|
) : (
|