feat(ui): enable drag-and-drop attachments and image previews in chat (#390)
* feat(BottomTerminalDock): add close button next to the fullscreen toggle in the dock * style: replace hardcoded gradient with theme value in shine text variant * fix(header): adapt instance button for desktop only * refactor(chat): polish sticky turn UX and message action rows for better readability Switch to stable sticky-only turn behavior and redesign user/assistant action controls (placement, hover rules, ordering, spacing, selection-safe clamp) to reduce visual noise and improve interaction flow. * feat(chat/message): refactor buttons in messages footer * feat: enhance image preview functionality in chat messages - Added a new ImagePreviewDialog component to handle image previews with navigation support. - Updated ToolOutputDialog to utilize the new ImagePreviewDialog for displaying images. - Modified the ToolPopupContent type to include a gallery of images and an index for the current image. - Removed the old inline image display logic from ToolOutputDialog. - Improved file handling in the file store, including better MIME type guessing and handling of server paths. - Introduced a new API endpoint for handling large session message payloads, allowing for better management of multi-file attachments. - Updated the VSCode bridge to support session message requests with appropriate headers and body handling. * feat(proxy): implement SSE forwarding and enhance generic API request handling * feat(chat): support submitting only queued messages * feat: add image preview transition state * fix: default VSCode view to draft and fixed sessions list regression
This commit is contained in:
committed by
GitHub
parent
39e625d8ec
commit
844562749d
@@ -1,6 +1,5 @@
|
||||
import { create } from "zustand";
|
||||
import { devtools, persist, createJSONStorage } from "zustand/middleware";
|
||||
import { opencodeClient } from "@/lib/opencode/client";
|
||||
import type { AttachedFile } from "./types/sessionTypes";
|
||||
import { getSafeStorage } from "./utils/safeStorage";
|
||||
|
||||
@@ -41,7 +40,7 @@ const guessMimeTypeFromName = (filename: string): string => {
|
||||
case "pdf":
|
||||
return "application/pdf";
|
||||
default:
|
||||
return "text/plain";
|
||||
return "application/octet-stream";
|
||||
}
|
||||
};
|
||||
|
||||
@@ -91,29 +90,30 @@ const guessMimeType = (file: File): string => {
|
||||
}
|
||||
};
|
||||
|
||||
const base64ByteLength = (base64: string): number => {
|
||||
const cleaned = base64.replace(/\s+/g, "");
|
||||
if (!cleaned) {
|
||||
return 0;
|
||||
const normalizeServerPath = (inputPath: string): string => inputPath.replace(/\\/g, "/").trim();
|
||||
|
||||
const toFileUrl = (inputPath: string): string => {
|
||||
const normalized = normalizeServerPath(inputPath);
|
||||
if (normalized.startsWith("file://")) {
|
||||
return normalized;
|
||||
}
|
||||
const padding = cleaned.endsWith("==") ? 2 : cleaned.endsWith("=") ? 1 : 0;
|
||||
return Math.floor((cleaned.length * 3) / 4) - padding;
|
||||
|
||||
const withLeadingSlash = normalized.startsWith("/") ? normalized : `/${normalized}`;
|
||||
return `file://${encodeURI(withLeadingSlash)}`;
|
||||
};
|
||||
|
||||
const base64EncodeBytes = (bytes: Uint8Array): string => {
|
||||
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let output = "";
|
||||
for (let i = 0; i < bytes.length; i += 3) {
|
||||
const a = bytes[i] ?? 0;
|
||||
const b = bytes[i + 1];
|
||||
const c = bytes[i + 2];
|
||||
const triple = (a << 16) | ((b ?? 0) << 8) | (c ?? 0);
|
||||
output += alphabet[(triple >> 18) & 63];
|
||||
output += alphabet[(triple >> 12) & 63];
|
||||
output += typeof b === "number" ? alphabet[(triple >> 6) & 63] : "=";
|
||||
output += typeof c === "number" ? alphabet[triple & 63] : "=";
|
||||
const readRawFileAsDataUrl = async (absolutePath: string): Promise<string> => {
|
||||
const response = await fetch(`/api/fs/raw?path=${encodeURIComponent(absolutePath)}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to read raw file: ${response.status}`);
|
||||
}
|
||||
return output;
|
||||
const blob = await response.blob();
|
||||
return await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
};
|
||||
|
||||
export const useFileStore = create<FileStore>()(
|
||||
@@ -196,66 +196,33 @@ export const useFileStore = create<FileStore>()(
|
||||
|
||||
addServerFile: async (path: string, name: string, content?: string) => {
|
||||
|
||||
const normalizedPath = normalizeServerPath(path);
|
||||
const { attachedFiles } = get();
|
||||
const isDuplicate = attachedFiles.some((f) => f.serverPath === path && f.source === "server");
|
||||
const isDuplicate = attachedFiles.some((f) => normalizeServerPath(f.serverPath || "") === normalizedPath && f.source === "server");
|
||||
if (isDuplicate) {
|
||||
console.log(`Server file "${name}" is already attached`);
|
||||
return;
|
||||
}
|
||||
|
||||
let fileContent = content;
|
||||
let encoding: "base64" | undefined;
|
||||
let resolvedMimeType: string | undefined;
|
||||
if (!fileContent) {
|
||||
try {
|
||||
|
||||
const tempClient = opencodeClient.getApiClient();
|
||||
|
||||
const lastSlashIndex = path.lastIndexOf("/");
|
||||
const directory = lastSlashIndex > 0 ? path.substring(0, lastSlashIndex) : "/";
|
||||
const filename = lastSlashIndex > 0 ? path.substring(lastSlashIndex + 1) : path;
|
||||
|
||||
const response = await tempClient.file.read({
|
||||
path: filename,
|
||||
directory: directory,
|
||||
});
|
||||
|
||||
if (response.data && "content" in response.data) {
|
||||
fileContent = response.data.content;
|
||||
encoding = response.data.encoding ?? undefined;
|
||||
resolvedMimeType = response.data.mimeType ?? undefined;
|
||||
} else {
|
||||
fileContent = "";
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to read server file:", error);
|
||||
|
||||
fileContent = `[File: ${name}]`;
|
||||
}
|
||||
}
|
||||
|
||||
const inferredMime = resolvedMimeType || guessMimeTypeFromName(name);
|
||||
const inferredMime = guessMimeTypeFromName(name);
|
||||
const safeMimeType = inferredMime && inferredMime.trim().length > 0 ? inferredMime : "application/octet-stream";
|
||||
|
||||
const base64 = (() => {
|
||||
if (encoding === "base64") {
|
||||
return fileContent || "";
|
||||
const shouldInlineBinary = safeMimeType !== "text/plain" && safeMimeType !== "application/x-directory";
|
||||
|
||||
let dataUrl = toFileUrl(normalizedPath);
|
||||
if (shouldInlineBinary) {
|
||||
try {
|
||||
dataUrl = await readRawFileAsDataUrl(normalizedPath);
|
||||
} catch (error) {
|
||||
console.warn("Failed to inline binary server file, falling back to file://", error);
|
||||
}
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(fileContent || "");
|
||||
return base64EncodeBytes(data);
|
||||
})();
|
||||
|
||||
const sizeBytes = encoding === "base64"
|
||||
? base64ByteLength(base64)
|
||||
: new TextEncoder().encode(fileContent || "").length;
|
||||
|
||||
if (sizeBytes > MAX_ATTACHMENT_SIZE) {
|
||||
throw new Error(`File "${name}" is too large. Maximum size is 50MB.`);
|
||||
}
|
||||
|
||||
const sizeBytes = typeof content === "string"
|
||||
? new TextEncoder().encode(content).length
|
||||
: 0;
|
||||
|
||||
const file = new File([], name, { type: safeMimeType });
|
||||
const dataUrl = `data:${safeMimeType};base64,${base64}`;
|
||||
|
||||
const attachedFile: AttachedFile = {
|
||||
id: `server-file-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
@@ -265,7 +232,7 @@ export const useFileStore = create<FileStore>()(
|
||||
filename: name,
|
||||
size: sizeBytes,
|
||||
source: "server",
|
||||
serverPath: path,
|
||||
serverPath: normalizedPath,
|
||||
};
|
||||
|
||||
set((state) => ({
|
||||
@@ -286,6 +253,12 @@ export const useFileStore = create<FileStore>()(
|
||||
{
|
||||
name: "file-store",
|
||||
storage: createJSONStorage(() => getSafeStorage()),
|
||||
version: 3,
|
||||
migrate: (persistedState) => {
|
||||
const state = persistedState as { attachedFiles?: AttachedFile[] } | undefined;
|
||||
return { attachedFiles: Array.isArray(state?.attachedFiles) ? state.attachedFiles : [] };
|
||||
},
|
||||
// Keep unsent draft attachments across restarts.
|
||||
partialize: (state) => ({
|
||||
attachedFiles: state.attachedFiles,
|
||||
}),
|
||||
|
||||
@@ -102,6 +102,15 @@ const computePartsTextLength = (parts: Part[] | undefined): number => {
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const toFileUrl = (inputPath: string): string => {
|
||||
const normalized = inputPath.replace(/\\/g, "/").trim();
|
||||
if (normalized.startsWith("file://")) {
|
||||
return normalized;
|
||||
}
|
||||
const withLeadingSlash = normalized.startsWith("/") ? normalized : `/${normalized}`;
|
||||
return `file://${encodeURI(withLeadingSlash)}`;
|
||||
};
|
||||
|
||||
const hasFinishStop = (info: { finish?: string } | undefined): boolean => {
|
||||
return info?.finish === "stop";
|
||||
};
|
||||
@@ -663,7 +672,12 @@ export const useMessageStore = create<MessageStore>()(
|
||||
type: "file" as const,
|
||||
mime: file.mimeType,
|
||||
filename: file.filename,
|
||||
url: file.dataUrl,
|
||||
url:
|
||||
file.source === "server" &&
|
||||
file.serverPath &&
|
||||
(file.mimeType === "text/plain" || file.mimeType === "application/x-directory")
|
||||
? toFileUrl(file.serverPath)
|
||||
: file.dataUrl,
|
||||
}));
|
||||
|
||||
set((state) => {
|
||||
@@ -687,7 +701,12 @@ export const useMessageStore = create<MessageStore>()(
|
||||
type: "file" as const,
|
||||
mime: file.mimeType,
|
||||
filename: file.filename,
|
||||
url: file.dataUrl,
|
||||
url:
|
||||
file.source === "server" &&
|
||||
file.serverPath &&
|
||||
(file.mimeType === "text/plain" || file.mimeType === "application/x-directory")
|
||||
? toFileUrl(file.serverPath)
|
||||
: file.dataUrl,
|
||||
})),
|
||||
}));
|
||||
|
||||
|
||||
@@ -56,29 +56,31 @@ const createSafeStorage = (): Storage => {
|
||||
return;
|
||||
} catch {
|
||||
disableStorage();
|
||||
// Prevent stale previous value from surviving when writes fail (e.g. quota).
|
||||
try {
|
||||
baseStorage.removeItem(key);
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
}
|
||||
fallback.setItem(key, value);
|
||||
};
|
||||
|
||||
const safeRemove = (key: string) => {
|
||||
if (storageAvailable) {
|
||||
try {
|
||||
baseStorage.removeItem(key);
|
||||
} catch {
|
||||
disableStorage();
|
||||
}
|
||||
try {
|
||||
baseStorage.removeItem(key);
|
||||
} catch {
|
||||
disableStorage();
|
||||
}
|
||||
fallback.removeItem(key);
|
||||
};
|
||||
|
||||
const safeClear = () => {
|
||||
if (storageAvailable) {
|
||||
try {
|
||||
baseStorage.clear();
|
||||
} catch {
|
||||
disableStorage();
|
||||
}
|
||||
try {
|
||||
baseStorage.clear();
|
||||
} catch {
|
||||
disableStorage();
|
||||
}
|
||||
fallback.clear();
|
||||
};
|
||||
@@ -155,29 +157,31 @@ const createSafeSessionStorage = (): Storage => {
|
||||
return;
|
||||
} catch {
|
||||
disableStorage();
|
||||
// Prevent stale previous value from surviving when writes fail (e.g. quota).
|
||||
try {
|
||||
baseStorage.removeItem(key);
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
}
|
||||
fallback.setItem(key, value);
|
||||
};
|
||||
|
||||
const safeRemove = (key: string) => {
|
||||
if (storageAvailable) {
|
||||
try {
|
||||
baseStorage.removeItem(key);
|
||||
} catch {
|
||||
disableStorage();
|
||||
}
|
||||
try {
|
||||
baseStorage.removeItem(key);
|
||||
} catch {
|
||||
disableStorage();
|
||||
}
|
||||
fallback.removeItem(key);
|
||||
};
|
||||
|
||||
const safeClear = () => {
|
||||
if (storageAvailable) {
|
||||
try {
|
||||
baseStorage.clear();
|
||||
} catch {
|
||||
disableStorage();
|
||||
}
|
||||
try {
|
||||
baseStorage.clear();
|
||||
} catch {
|
||||
disableStorage();
|
||||
}
|
||||
fallback.clear();
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user