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
+87 -7
View File
@@ -67,6 +67,12 @@ type ApiProxyRequestPayload = {
bodyBase64?: string;
};
type ApiSessionMessageRequestPayload = {
path?: string;
headers?: Record<string, string>;
bodyText?: string;
};
type ApiProxyResponsePayload = {
status: number;
headers: Record<string, string>;
@@ -760,6 +766,23 @@ const collectHeaders = (headers: Headers): Record<string, string> => {
return result;
};
const buildUnavailableApiResponse = (): ApiProxyResponsePayload => {
const body = JSON.stringify({ error: 'OpenCode API unavailable' });
return {
status: 503,
headers: { 'content-type': 'application/json' },
bodyBase64: base64EncodeUtf8(body),
};
};
const sanitizeForwardHeaders = (input: Record<string, string> | undefined): Record<string, string> => {
const headers: Record<string, string> = { ...(input || {}) };
delete headers['content-length'];
delete headers['host'];
delete headers['connection'];
return headers;
};
export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeContext): Promise<BridgeResponse> {
const { id, type, payload } = message;
@@ -768,12 +791,7 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
case 'api:proxy': {
const apiUrl = ctx?.manager?.getApiUrl();
if (!apiUrl) {
const body = JSON.stringify({ error: 'OpenCode API unavailable' });
const data: ApiProxyResponsePayload = {
status: 503,
headers: { 'content-type': 'application/json' },
bodyBase64: base64EncodeUtf8(body),
};
const data = buildUnavailableApiResponse();
return { id, type, success: true, data };
}
@@ -788,7 +806,7 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
const base = `${apiUrl.replace(/\/+$/, '')}/`;
const targetUrl = new URL(normalizedPath.replace(/^\/+/, ''), base).toString();
const requestHeaders: Record<string, string> = { ...(headers || {}) };
const requestHeaders: Record<string, string> = sanitizeForwardHeaders(headers);
// Ensure SSE requests are negotiated correctly.
if (normalizedPath === '/event' || normalizedPath === '/global/event') {
@@ -830,6 +848,68 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
}
}
case 'api:session:message': {
const apiUrl = ctx?.manager?.getApiUrl();
if (!apiUrl) {
const data = buildUnavailableApiResponse();
return { id, type, success: true, data };
}
const { path: requestPath, headers, bodyText } = (payload || {}) as ApiSessionMessageRequestPayload;
const normalizedPath =
typeof requestPath === 'string' && requestPath.trim().length > 0
? requestPath.trim().startsWith('/')
? requestPath.trim()
: `/${requestPath.trim()}`
: '/';
if (!/^\/session\/[^/]+\/message(?:\?.*)?$/.test(normalizedPath)) {
const body = JSON.stringify({ error: 'Invalid session message proxy path' });
const data: ApiProxyResponsePayload = {
status: 400,
headers: { 'content-type': 'application/json' },
bodyBase64: base64EncodeUtf8(body),
};
return { id, type, success: true, data };
}
const base = `${apiUrl.replace(/\/+$/, '')}/`;
const targetUrl = new URL(normalizedPath.replace(/^\/+/, ''), base).toString();
const requestHeaders: Record<string, string> = sanitizeForwardHeaders(headers);
try {
const response = await fetch(targetUrl, {
method: 'POST',
headers: requestHeaders,
body: typeof bodyText === 'string' ? bodyText : '',
signal: AbortSignal.timeout(45000),
});
const arrayBuffer = await response.arrayBuffer();
const data: ApiProxyResponsePayload = {
status: response.status,
headers: collectHeaders(response.headers),
bodyBase64: Buffer.from(arrayBuffer).toString('base64'),
};
return { id, type, success: true, data };
} catch (error) {
const isTimeout =
error instanceof Error &&
((error as Error & { name?: string }).name === 'TimeoutError' ||
(error as Error & { name?: string }).name === 'AbortError');
const body = JSON.stringify({
error: isTimeout ? 'OpenCode message forward timed out' : error instanceof Error ? error.message : 'OpenCode message forward failed',
});
const data: ApiProxyResponsePayload = {
status: isTimeout ? 504 : 503,
headers: { 'content-type': 'application/json' },
bodyBase64: base64EncodeUtf8(body),
};
return { id, type, success: true, data };
}
}
case 'files:list': {
const { path: dirPath } = payload as { path: string };
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir();