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
+9
View File
@@ -107,6 +107,15 @@ export async function proxyApiRequest(options: {
return sendBridgeMessageWithOptions<ProxiedApiResponse>('api:proxy', options, { timeoutMs: 0 });
}
export async function proxySessionMessageRequest(options: {
path: string;
headers?: Record<string, string>;
bodyText: string;
}): Promise<ProxiedApiResponse> {
// Keep parity with server-side direct forwarder: let extension host control timeout.
return sendBridgeMessageWithOptions<ProxiedApiResponse>('api:session:message', options, { timeoutMs: 0 });
}
export type ProxiedSseStartResponse = {
status: number;
headers: Record<string, string>;
+39 -1
View File
@@ -1,5 +1,5 @@
import { createVSCodeAPIs } from './api';
import { onCommand, onThemeChange, proxyApiRequest, sendBridgeMessage, startSseProxy, stopSseProxy } from './api/bridge';
import { onCommand, onThemeChange, proxyApiRequest, proxySessionMessageRequest, sendBridgeMessage, startSseProxy, stopSseProxy } from './api/bridge';
import type { RuntimeAPIs } from '@openchamber/ui/lib/api/types';
import {
buildVSCodeThemeFromPalette,
@@ -343,7 +343,35 @@ const extractBodyBase64 = async (input: RequestInfo | URL, init: RequestInit | u
return undefined;
};
const extractBodyText = async (input: RequestInfo | URL, init: RequestInit | undefined, method: string): Promise<string> => {
if (method === 'GET' || method === 'HEAD') return '';
if (input instanceof Request) {
const cloned = input.clone();
return await cloned.text();
}
const body = init?.body;
if (!body) return '';
if (typeof body === 'string') {
return body;
}
if (body instanceof URLSearchParams) {
return body.toString();
}
if (body instanceof Blob) {
return await body.text();
}
console.warn('[OpenChamber] Unsupported request body type for direct session proxy:', body);
return '';
};
const isSseApiPath = (pathname: string) => pathname === '/api/event' || pathname === '/api/global/event';
const isSessionMessageApiPath = (pathname: string) => /^\/api\/session\/[^/]+\/message$/.test(pathname);
const handleLocalApiRequest = async (url: URL, init?: RequestInit) => {
const pathname = url.pathname;
@@ -761,6 +789,16 @@ window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
return new Response(stream, { status: start.status || 200, headers: start.headers || { 'content-type': 'text/event-stream' } });
}
if (method === 'POST' && isSessionMessageApiPath(targetUrl.pathname)) {
const bodyText = await extractBodyText(input, init, method);
const proxied = await proxySessionMessageRequest({ path: suffixPath, headers, bodyText });
const body = proxied.bodyBase64 ? decodeBase64(proxied.bodyBase64) : new Uint8Array();
const response = new Response(body, { status: proxied.status, headers: proxied.headers });
recordBootstrapFetch(targetUrl.pathname, response.ok);
maybeHideLoadingOverlay();
return response;
}
const bodyBase64 = await extractBodyBase64(input, init, method);
const proxied = await proxyApiRequest({ method, path: suffixPath, headers, bodyBase64 });
const body = proxied.bodyBase64 ? decodeBase64(proxied.bodyBase64) : new Uint8Array();