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
+328 -53
View File
@@ -1,5 +1,4 @@
import express from 'express';
import { createProxyMiddleware } from 'http-proxy-middleware';
import path from 'path';
import { spawn, spawnSync } from 'child_process';
import fs from 'fs';
@@ -4630,6 +4629,157 @@ function setupProxy(app) {
next();
});
const isSseApiPath = (path) => path === '/event' || path === '/global/event';
const forwardSseRequest = async (req, res) => {
const startedAt = Date.now();
const upstreamPath = req.originalUrl.replace(/^\/api/, '');
const targetUrl = buildOpenCodeUrl(upstreamPath, '');
const authHeaders = getOpenCodeAuthHeaders();
const requestHeaders = {
...(typeof req.headers.accept === 'string' ? { accept: req.headers.accept } : { accept: 'text/event-stream' }),
'cache-control': 'no-cache',
connection: 'keep-alive',
...(authHeaders.Authorization ? { Authorization: authHeaders.Authorization } : {}),
};
const controller = new AbortController();
let connectTimer = null;
let idleTimer = null;
let heartbeatTimer = null;
let endedBy = 'upstream-end';
const cleanup = () => {
if (connectTimer) {
clearTimeout(connectTimer);
connectTimer = null;
}
if (idleTimer) {
clearTimeout(idleTimer);
idleTimer = null;
}
if (heartbeatTimer) {
clearInterval(heartbeatTimer);
heartbeatTimer = null;
}
req.off('close', onClientClose);
};
const resetIdleTimeout = () => {
if (idleTimer) {
clearTimeout(idleTimer);
}
idleTimer = setTimeout(() => {
endedBy = 'idle-timeout';
controller.abort();
}, 5 * 60 * 1000);
};
const onClientClose = () => {
endedBy = 'client-disconnect';
controller.abort();
};
req.on('close', onClientClose);
try {
connectTimer = setTimeout(() => {
endedBy = 'connect-timeout';
controller.abort();
}, 10 * 1000);
const upstreamResponse = await fetch(targetUrl, {
method: 'GET',
headers: requestHeaders,
signal: controller.signal,
});
if (connectTimer) {
clearTimeout(connectTimer);
connectTimer = null;
}
if (!upstreamResponse.ok || !upstreamResponse.body) {
const body = await upstreamResponse.text().catch(() => '');
cleanup();
if (!res.headersSent) {
if (upstreamResponse.headers.has('content-type')) {
res.setHeader('content-type', upstreamResponse.headers.get('content-type'));
}
res.status(upstreamResponse.status).send(body);
}
return;
}
const upstreamContentType = upstreamResponse.headers.get('content-type') || 'text/event-stream';
res.status(upstreamResponse.status);
res.setHeader('content-type', upstreamContentType);
res.setHeader('cache-control', 'no-cache');
res.setHeader('connection', 'keep-alive');
res.setHeader('x-accel-buffering', 'no');
res.setHeader('x-content-type-options', 'nosniff');
if (typeof res.flushHeaders === 'function') {
res.flushHeaders();
}
resetIdleTimeout();
heartbeatTimer = setInterval(() => {
if (res.writableEnded || controller.signal.aborted) {
return;
}
try {
res.write(': ping\n\n');
resetIdleTimeout();
} catch {
}
}, 30 * 1000);
const reader = upstreamResponse.body.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
endedBy = endedBy === 'upstream-end' ? 'upstream-finished' : endedBy;
break;
}
if (controller.signal.aborted) {
break;
}
if (value && value.length > 0) {
res.write(Buffer.from(value));
resetIdleTimeout();
}
}
} finally {
try {
reader.releaseLock();
} catch {
}
}
cleanup();
if (!res.writableEnded) {
res.end();
}
console.log(`SSE forward ${upstreamPath} closed (${endedBy}) in ${Date.now() - startedAt}ms`);
} catch (error) {
cleanup();
const isTimeout = error?.name === 'TimeoutError' || error?.name === 'AbortError';
if (!res.headersSent) {
res.status(isTimeout ? 504 : 503).json({
error: isTimeout ? 'OpenCode SSE forward timed out' : 'OpenCode SSE forward failed',
});
} else if (!res.writableEnded) {
res.end();
}
console.warn(`SSE forward ${upstreamPath} failed (${endedBy}):`, error?.message || error);
}
};
app.get('/api/event', forwardSseRequest);
app.get('/api/global/event', forwardSseRequest);
app.use('/api', (_req, _res, next) => {
ensureOpenCodeApiPrefix();
next();
@@ -4651,66 +4801,162 @@ function setupProxy(app) {
});
const proxyMiddleware = createProxyMiddleware({
target: openCodePort ? `http://localhost:${openCodePort}` : 'http://127.0.0.1:0',
router: () => {
if (!openCodePort) {
return 'http://127.0.0.1:0';
}
return `http://localhost:${openCodePort}`;
},
changeOrigin: true,
pathRewrite: (path) => {
if (!path.startsWith('/api')) {
return path;
const hopByHopRequestHeaders = new Set([
'host',
'connection',
'content-length',
'transfer-encoding',
'keep-alive',
'te',
'trailer',
'upgrade',
]);
const hopByHopResponseHeaders = new Set([
'connection',
'content-length',
'transfer-encoding',
'keep-alive',
'te',
'trailer',
'upgrade',
'www-authenticate',
]);
const collectForwardHeaders = (req) => {
const authHeaders = getOpenCodeAuthHeaders();
const headers = {};
for (const [key, value] of Object.entries(req.headers || {})) {
if (!value) continue;
const lowerKey = key.toLowerCase();
if (hopByHopRequestHeaders.has(lowerKey)) continue;
headers[lowerKey] = Array.isArray(value) ? value.join(', ') : String(value);
}
if (authHeaders.Authorization) {
headers.Authorization = authHeaders.Authorization;
}
return headers;
};
const collectRequestBodyBuffer = async (req) => {
if (Buffer.isBuffer(req.body)) {
return req.body;
}
if (typeof req.body === 'string') {
return Buffer.from(req.body);
}
if (req.body && typeof req.body === 'object') {
return Buffer.from(JSON.stringify(req.body));
}
if (req.readableEnded) {
return Buffer.alloc(0);
}
return await new Promise((resolve, reject) => {
const chunks = [];
req.on('data', (chunk) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
req.on('end', () => resolve(Buffer.concat(chunks)));
req.on('error', reject);
});
};
const forwardGenericApiRequest = async (req, res) => {
try {
const upstreamPath = req.originalUrl.replace(/^\/api/, '');
const targetUrl = buildOpenCodeUrl(upstreamPath, '');
const headers = collectForwardHeaders(req);
const method = String(req.method || 'GET').toUpperCase();
const hasBody = method !== 'GET' && method !== 'HEAD';
const bodyBuffer = hasBody ? await collectRequestBodyBuffer(req) : null;
const upstreamResponse = await fetch(targetUrl, {
method,
headers,
body: hasBody ? bodyBuffer : undefined,
signal: AbortSignal.timeout(LONG_REQUEST_TIMEOUT_MS),
});
for (const [key, value] of upstreamResponse.headers.entries()) {
const lowerKey = key.toLowerCase();
if (hopByHopResponseHeaders.has(lowerKey)) {
continue;
}
res.setHeader(key, value);
}
const suffix = path.slice(4) || '/';
const upstreamBody = Buffer.from(await upstreamResponse.arrayBuffer());
res.status(upstreamResponse.status).send(upstreamBody);
} catch (error) {
if (!res.headersSent) {
const isTimeout = error?.name === 'TimeoutError' || error?.name === 'AbortError';
res.status(isTimeout ? 504 : 503).json({
error: isTimeout ? 'OpenCode request timed out' : 'OpenCode service unavailable',
});
}
}
};
return suffix;
},
ws: false,
// v3.x API: callbacks go in 'on' object
on: {
error: (err, req, res) => {
console.error(`Proxy error: ${err.message}`);
if (!res.headersSent) {
res.status(503).json({ error: 'OpenCode service unavailable' });
}
},
proxyReq: (proxyReq, req, res) => {
console.log(`Proxying ${req.method} ${req.path} to OpenCode`);
const authHeaders = getOpenCodeAuthHeaders();
if (authHeaders.Authorization) {
proxyReq.setHeader('Authorization', authHeaders.Authorization);
}
// Dedicated forwarder for large session message payloads.
// This avoids edge-cases in generic proxy streaming for multi-file attachments.
app.post('/api/session/:sessionId/message', express.raw({ type: '*/*', limit: '50mb' }), async (req, res) => {
try {
const upstreamPath = req.originalUrl.replace(/^\/api/, '');
const targetUrl = buildOpenCodeUrl(upstreamPath, '');
const authHeaders = getOpenCodeAuthHeaders();
if (req.headers.accept && req.headers.accept.includes('text/event-stream')) {
proxyReq.setHeader('Accept', 'text/event-stream');
proxyReq.setHeader('Cache-Control', 'no-cache');
proxyReq.setHeader('Connection', 'keep-alive');
}
},
proxyRes: (proxyRes, req, res) => {
// Strip WWW-Authenticate to prevent browser's native Basic Auth popup
if (proxyRes.headers['www-authenticate']) {
delete proxyRes.headers['www-authenticate'];
}
const headers = {
...(typeof req.headers['content-type'] === 'string' ? { 'content-type': req.headers['content-type'] } : { 'content-type': 'application/json' }),
...(typeof req.headers.accept === 'string' ? { accept: req.headers.accept } : {}),
...(authHeaders.Authorization ? { Authorization: authHeaders.Authorization } : {}),
};
if (req.url?.includes('/event')) {
proxyRes.headers['Access-Control-Allow-Origin'] = '*';
proxyRes.headers['Access-Control-Allow-Headers'] = 'Cache-Control, Accept';
proxyRes.headers['Content-Type'] = 'text/event-stream';
proxyRes.headers['Cache-Control'] = 'no-cache';
proxyRes.headers['Connection'] = 'keep-alive';
proxyRes.headers['X-Accel-Buffering'] = 'no';
proxyRes.headers['X-Content-Type-Options'] = 'nosniff';
}
const bodyBuffer = Buffer.isBuffer(req.body)
? req.body
: Buffer.from(typeof req.body === 'string' ? req.body : '');
const upstreamResponse = await fetch(targetUrl, {
method: 'POST',
headers,
body: bodyBuffer,
signal: AbortSignal.timeout(45000),
});
const upstreamBody = Buffer.from(await upstreamResponse.arrayBuffer());
if (upstreamResponse.headers.has('content-type')) {
res.setHeader('content-type', upstreamResponse.headers.get('content-type'));
}
res.status(upstreamResponse.status).send(upstreamBody);
} catch (error) {
if (!res.headersSent) {
const isTimeout = error?.name === 'TimeoutError' || error?.name === 'AbortError';
res.status(isTimeout ? 504 : 503).json({
error: isTimeout ? 'OpenCode message forward timed out' : 'OpenCode message forward failed',
});
}
}
});
app.use('/api', proxyMiddleware);
app.use('/api', (req, res, next) => {
if (isSseApiPath(req.path)) {
return next();
}
if (req.method === 'POST' && /\/session\/[^/]+\/message$/.test(req.path || '')) {
return next();
}
return forwardGenericApiRequest(req, res);
});
}
function startHealthMonitoring() {
@@ -8478,7 +8724,36 @@ async function main(options = {}) {
})
.join('\n\n');
const prompt = `You are drafting git commit notes for this codebase. Respond in JSON of the shape {"subject": string, "highlights": string[]} (ONLY the JSON in response, no markdown wrappers or anything except JSON) with these rules:\n- subject follows our convention: type[optional-scope]: summary (examples: "feat: add diff virtualization", "fix(chat): restore enter key handling")\n- allowed types: feat, fix, chore, style, refactor, perf, docs, test, build, ci (choose the best match or fallback to chore)\n- summary must be imperative, concise, <= 70 characters, no trailing punctuation\n- scope is optional; include only when obvious from filenames/folders; do not invent scopes\n- focus on the most impactful user-facing change; if multiple capabilities ship together, align the subject with the dominant theme and use highlights to cover the other major outcomes\n- highlights array should contain 2-3 plain sentences (<= 90 chars each) that describe distinct features or UI changes users will notice (e.g. "Add per-file revert action in Changes list"). Avoid subjective benefit statements, marketing tone, repeating the subject, or referencing helper function names. Highlight additions such as new controls/buttons, new actions (e.g. revert), or stored state changes explicitly. Skip highlights if fewer than two meaningful points exist.\n- text must be plain (no markdown bullets); each highlight should start with an uppercase verb\n\nDiff summary:\n${diffSummaries}`;
const prompt = `You are generating a Conventional Commits subject line from the provided diff.
Return EXACTLY one JSON object (no code fences, no extra keys, no extra text):
{"subject": string, "highlights": string[]}
Non-negotiable:
- Output must be valid JSON (double quotes).
- Only claim what is supported by the diff. If unsure, be more general; do not guess.
subject:
- Format: <type>: <summary> (NO scope; never write type(scope))
- Allowed types: feat, fix, refactor, perf, docs, test, build, ci, chore, style, revert
- Choose type (prefer fix when ambiguous):
- fix: any bug/regression/wrong behavior (state, selection, navigation, persistence, crash)
- feat: new user-facing capability or new workflow (not just guardrails/defaults)
- refactor/perf/docs/test/build/ci/style/chore/revert: only when clearly the primary change
- Summary style:
- imperative, present tense, outcome-first
- <= 72 characters, no trailing period
- avoid filenames, internal function names, and implementation details
highlights:
- 0-3 items; it is OK to return [].
- Each item: one plain sentence, <= 90 chars, starts with an Uppercase verb.
- Must add information not already in the subject.
- Prefer user-observable behaviors (UI flow, navigation, selection, default view, persistence).
- No markdown bullets, no file paths, no helper names.
Diff summary (may be truncated):
${diffSummaries}`;
const model = 'gpt-5-nano';