Skip to content

Commit bb83408

Browse files
committed
kebab menu, preview urls in messages
1 parent 8650843 commit bb83408

13 files changed

Lines changed: 284 additions & 66 deletions

frontend/app/aipanel/ai-utils.ts

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,4 +184,158 @@ export const validateFileSize = (file: File): FileSizeError | null => {
184184
export const formatFileSizeError = (error: FileSizeError): string => {
185185
const typeLabel = error.fileType === 'image' ? 'Image' : error.fileType === 'pdf' ? 'PDF' : 'Text file';
186186
return `${typeLabel} "${error.fileName}" is too large (${formatFileSize(error.fileSize)}). Maximum size is ${formatFileSize(error.maxSize)}.`;
187+
};
188+
189+
/**
190+
* Resize an image to have a maximum edge of 4096px and convert to WebP format
191+
* Returns the optimized image if it's smaller than the original, otherwise returns the original
192+
*/
193+
export const resizeImage = async (file: File): Promise<File> => {
194+
// Only process actual image files (not SVG)
195+
if (!file.type.startsWith('image/') || file.type === 'image/svg+xml') {
196+
return file;
197+
}
198+
199+
const MAX_EDGE = 4096;
200+
const WEBP_QUALITY = 0.8;
201+
202+
return new Promise((resolve) => {
203+
const img = new Image();
204+
const url = URL.createObjectURL(file);
205+
206+
img.onload = async () => {
207+
URL.revokeObjectURL(url);
208+
209+
let { width, height } = img;
210+
211+
// Check if resizing is needed
212+
if (width <= MAX_EDGE && height <= MAX_EDGE) {
213+
// Image is already small enough, just try WebP conversion
214+
const canvas = document.createElement('canvas');
215+
canvas.width = width;
216+
canvas.height = height;
217+
const ctx = canvas.getContext('2d');
218+
ctx?.drawImage(img, 0, 0);
219+
220+
canvas.toBlob(
221+
(blob) => {
222+
if (blob && blob.size < file.size) {
223+
const webpFile = new File([blob], file.name.replace(/\.[^.]+$/, '.webp'), {
224+
type: 'image/webp',
225+
});
226+
console.log(`Image resized (no dimension change): ${file.name} - Original: ${formatFileSize(file.size)}, WebP: ${formatFileSize(blob.size)}`);
227+
resolve(webpFile);
228+
} else {
229+
console.log(`Image kept original (WebP not smaller): ${file.name} - ${formatFileSize(file.size)}`);
230+
resolve(file);
231+
}
232+
},
233+
'image/webp',
234+
WEBP_QUALITY
235+
);
236+
return;
237+
}
238+
239+
// Calculate new dimensions while maintaining aspect ratio
240+
if (width > height) {
241+
height = Math.round((height * MAX_EDGE) / width);
242+
width = MAX_EDGE;
243+
} else {
244+
width = Math.round((width * MAX_EDGE) / height);
245+
height = MAX_EDGE;
246+
}
247+
248+
// Create canvas and resize
249+
const canvas = document.createElement('canvas');
250+
canvas.width = width;
251+
canvas.height = height;
252+
const ctx = canvas.getContext('2d');
253+
ctx?.drawImage(img, 0, 0, width, height);
254+
255+
// Convert to WebP
256+
canvas.toBlob(
257+
(blob) => {
258+
if (blob && blob.size < file.size) {
259+
const webpFile = new File([blob], file.name.replace(/\.[^.]+$/, '.webp'), {
260+
type: 'image/webp',
261+
});
262+
console.log(`Image resized: ${file.name} (${img.width}x${img.height}${width}x${height}) - Original: ${formatFileSize(file.size)}, WebP: ${formatFileSize(blob.size)}`);
263+
resolve(webpFile);
264+
} else {
265+
console.log(`Image kept original (WebP not smaller): ${file.name} (${img.width}x${img.height}${width}x${height}) - ${formatFileSize(file.size)}`);
266+
resolve(file);
267+
}
268+
},
269+
'image/webp',
270+
WEBP_QUALITY
271+
);
272+
};
273+
274+
img.onerror = () => {
275+
URL.revokeObjectURL(url);
276+
resolve(file);
277+
};
278+
279+
img.src = url;
280+
});
281+
};
282+
283+
/**
284+
* Create a 128x128 preview data URL for an image file
285+
*/
286+
export const createImagePreview = async (file: File): Promise<string | null> => {
287+
if (!file.type.startsWith('image/') || file.type === 'image/svg+xml') {
288+
return null;
289+
}
290+
291+
const PREVIEW_SIZE = 128;
292+
const WEBP_QUALITY = 0.8;
293+
294+
return new Promise((resolve) => {
295+
const img = new Image();
296+
const url = URL.createObjectURL(file);
297+
298+
img.onload = () => {
299+
URL.revokeObjectURL(url);
300+
301+
let { width, height } = img;
302+
303+
if (width > height) {
304+
height = Math.round((height * PREVIEW_SIZE) / width);
305+
width = PREVIEW_SIZE;
306+
} else {
307+
width = Math.round((width * PREVIEW_SIZE) / height);
308+
height = PREVIEW_SIZE;
309+
}
310+
311+
const canvas = document.createElement('canvas');
312+
canvas.width = width;
313+
canvas.height = height;
314+
const ctx = canvas.getContext('2d');
315+
ctx?.drawImage(img, 0, 0, width, height);
316+
317+
canvas.toBlob(
318+
(blob) => {
319+
if (blob) {
320+
const reader = new FileReader();
321+
reader.onloadend = () => {
322+
resolve(reader.result as string);
323+
};
324+
reader.readAsDataURL(blob);
325+
} else {
326+
resolve(null);
327+
}
328+
},
329+
'image/webp',
330+
WEBP_QUALITY
331+
);
332+
};
333+
334+
img.onerror = () => {
335+
URL.revokeObjectURL(url);
336+
resolve(null);
337+
};
338+
339+
img.src = url;
340+
});
187341
};

frontend/app/aipanel/aimessage.tsx

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,21 @@ const UserMessageFiles = memo(({ fileParts }: UserMessageFilesProps) => {
3333
{fileParts.map((file, index) => (
3434
<div key={index} className="relative bg-gray-700 rounded-lg p-2 min-w-20 flex-shrink-0">
3535
<div className="flex flex-col items-center text-center">
36-
<div className="w-12 h-12 mb-1 flex items-center justify-center bg-gray-600 rounded">
37-
<i
38-
className={cn(
39-
"fa text-lg text-gray-300",
40-
getFileIcon(file.data?.filename || "", file.data?.mimetype || "")
41-
)}
42-
></i>
36+
<div className="w-12 h-12 mb-1 flex items-center justify-center bg-gray-600 rounded overflow-hidden">
37+
{file.data?.previewurl ? (
38+
<img
39+
src={file.data.previewurl}
40+
alt={file.data?.filename || "File"}
41+
className="w-full h-full object-cover"
42+
/>
43+
) : (
44+
<i
45+
className={cn(
46+
"fa text-lg text-gray-300",
47+
getFileIcon(file.data?.filename || "", file.data?.mimetype || "")
48+
)}
49+
></i>
50+
)}
4351
</div>
4452
<div
4553
className="text-[10px] text-gray-200 truncate w-full max-w-16"

frontend/app/aipanel/aipanel.tsx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ const AIPanelComponent = memo(({ className, onClose }: AIPanelProps) => {
121121
mimetype: normalizedMimeType,
122122
url: dataUrl,
123123
size: droppedFile.file.size,
124+
previewurl: droppedFile.previewUrl,
124125
});
125126

126127
uiMessageParts.push({
@@ -129,6 +130,7 @@ const AIPanelComponent = memo(({ className, onClose }: AIPanelProps) => {
129130
filename: droppedFile.name,
130131
mimetype: normalizedMimeType,
131132
size: droppedFile.file.size,
133+
previewurl: droppedFile.previewUrl,
132134
},
133135
});
134136
}
@@ -193,7 +195,7 @@ const AIPanelComponent = memo(({ className, onClose }: AIPanelProps) => {
193195
}
194196
};
195197

196-
const handleDrop = (e: React.DragEvent) => {
198+
const handleDrop = async (e: React.DragEvent) => {
197199
e.preventDefault();
198200
e.stopPropagation();
199201
setIsDragOver(false);
@@ -207,11 +209,14 @@ const AIPanelComponent = memo(({ className, onClose }: AIPanelProps) => {
207209
model.setError(formatFileSizeError(sizeError));
208210
return;
209211
}
210-
model.addFile(file);
212+
await model.addFile(file);
211213
}
212214

213215
if (acceptableFiles.length < files.length) {
214-
console.warn(`${files.length - acceptableFiles.length} files were rejected due to unsupported file types`);
216+
const rejectedCount = files.length - acceptableFiles.length;
217+
const rejectedFiles = files.filter(f => !isAcceptableFile(f));
218+
const fileNames = rejectedFiles.map(f => f.name).join(", ");
219+
model.setError(`${rejectedCount} file${rejectedCount > 1 ? 's' : ''} rejected (unsupported type): ${fileNames}. Supported: images, PDFs, and text/code files.`);
215220
}
216221
};
217222

@@ -285,7 +290,7 @@ const AIPanelComponent = memo(({ className, onClose }: AIPanelProps) => {
285290
</div>
286291
</div>
287292
)}
288-
<AIPanelHeader onClose={onClose} model={model} />
293+
<AIPanelHeader onClose={onClose} model={model} onClearChat={clearChat} />
289294

290295
<div key="main-content" className="flex-1 flex flex-col min-h-0">
291296
{!telemetryEnabled ? (

frontend/app/aipanel/aipanelheader.tsx

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,32 @@
11
// Copyright 2025, Command Line Inc.
22
// SPDX-License-Identifier: Apache-2.0
33

4+
import { ContextMenuModel } from "@/app/store/contextmenu";
45
import { useAtom } from "jotai";
56
import { memo } from "react";
67
import { WaveAIModel } from "./waveai-model";
78

89
interface AIPanelHeaderProps {
910
onClose?: () => void;
1011
model: WaveAIModel;
12+
onClearChat?: () => void;
1113
}
1214

13-
export const AIPanelHeader = memo(({ onClose, model }: AIPanelHeaderProps) => {
15+
export const AIPanelHeader = memo(({ onClose, model, onClearChat }: AIPanelHeaderProps) => {
1416
const [widgetAccess, setWidgetAccess] = useAtom(model.widgetAccess);
1517

18+
const handleKebabClick = (e: React.MouseEvent) => {
19+
const menu: ContextMenuItem[] = [
20+
{
21+
label: "Clear Chat",
22+
click: () => {
23+
onClearChat?.();
24+
},
25+
},
26+
];
27+
ContextMenuModel.showContextMenu(menu, e);
28+
};
29+
1630
return (
1731
<div className="@container py-2 pl-3 pr-1 @xs:p-2 @xs:pl-4 border-b border-gray-600 flex items-center justify-between min-w-0">
1832
<h2 className="text-white text-sm @xs:text-lg font-semibold flex items-center gap-2 flex-shrink-0 whitespace-nowrap">
@@ -51,6 +65,14 @@ export const AIPanelHeader = memo(({ onClose, model }: AIPanelHeaderProps) => {
5165
</button>
5266
</div>
5367

68+
<button
69+
onClick={handleKebabClick}
70+
className="text-gray-400 hover:text-white cursor-pointer transition-colors p-1 rounded flex-shrink-0 ml-2 focus:outline-none"
71+
title="More options"
72+
>
73+
<i className="fa fa-ellipsis-vertical"></i>
74+
</button>
75+
5476
{onClose && (
5577
<button
5678
onClick={onClose}

frontend/app/aipanel/aipanelinput.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ export const AIPanelInput = memo(
7676
fileInputRef.current?.click();
7777
};
7878

79-
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
79+
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
8080
const files = Array.from(e.target.files || []);
8181
const acceptableFiles = files.filter(isAcceptableFile);
8282

@@ -89,7 +89,7 @@ export const AIPanelInput = memo(
8989
}
9090
return;
9191
}
92-
model.addFile(file);
92+
await model.addFile(file);
9393
}
9494

9595
if (acceptableFiles.length < files.length) {

frontend/app/aipanel/aitypes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ type WaveUIDataTypes = {
88
filename: string;
99
size: number;
1010
mimetype: string;
11+
previewurl?: string;
1112
};
1213
};
1314

frontend/app/aipanel/waveai-model.tsx

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { globalStore } from "@/app/store/jotaiStore";
55
import { workspaceLayoutModel } from "@/app/workspace/workspace-layout-model";
66
import * as jotai from "jotai";
77
import type React from "react";
8+
import { createImagePreview, resizeImage } from "./ai-utils";
89
import type { AIPanelInputRef } from "./aipanelinput";
910

1011
export interface DroppedFile {
@@ -40,18 +41,24 @@ export class WaveAIModel {
4041
WaveAIModel.instance = null;
4142
}
4243

43-
addFile(file: File): DroppedFile {
44+
async addFile(file: File): Promise<DroppedFile> {
45+
// Resize images before storing
46+
const processedFile = await resizeImage(file);
47+
4448
const droppedFile: DroppedFile = {
4549
id: crypto.randomUUID(),
46-
file,
47-
name: file.name,
48-
type: file.type,
49-
size: file.size,
50+
file: processedFile,
51+
name: processedFile.name,
52+
type: processedFile.type,
53+
size: processedFile.size,
5054
};
5155

52-
// Create preview URL for images
53-
if (file.type.startsWith("image/")) {
54-
droppedFile.previewUrl = URL.createObjectURL(file);
56+
// Create 128x128 preview data URL for images
57+
if (processedFile.type.startsWith("image/")) {
58+
const previewDataUrl = await createImagePreview(processedFile);
59+
if (previewDataUrl) {
60+
droppedFile.previewUrl = previewDataUrl;
61+
}
5562
}
5663

5764
const currentFiles = globalStore.get(this.droppedFiles);
@@ -62,13 +69,6 @@ export class WaveAIModel {
6269

6370
removeFile(fileId: string) {
6471
const currentFiles = globalStore.get(this.droppedFiles);
65-
const fileToRemove = currentFiles.find((f) => f.id === fileId);
66-
67-
// Cleanup preview URL if it exists
68-
if (fileToRemove?.previewUrl) {
69-
URL.revokeObjectURL(fileToRemove.previewUrl);
70-
}
71-
7272
const updatedFiles = currentFiles.filter((f) => f.id !== fileId);
7373
globalStore.set(this.droppedFiles, updatedFiles);
7474
}

frontend/types/custom.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,7 @@ declare global {
467467
data?: string; // base64 encoded data
468468
url?: string;
469469
size?: number;
470+
previewurl?: string;
470471
};
471472
}
472473

0 commit comments

Comments
 (0)