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
+328
-53
@@ -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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user