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:
Bohdan Triapitsyn
2026-02-11 19:28:22 +02:00
committed by GitHub
parent 39e625d8ec
commit 844562749d
25 changed files with 1621 additions and 489 deletions
+43 -70
View File
@@ -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,
}),
+21 -2
View File
@@ -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,
})),
}));
+28 -24
View File
@@ -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();
};