diff --git a/src/web/public/app.js b/src/web/public/app.js index 8dd00d49..acfac800 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -249,6 +249,9 @@ const _SSE_HANDLER_MAP = [ [SSE_EVENTS.ORCHESTRATOR_TASK_FAILED, '_onOrchestratorTaskFailed'], [SSE_EVENTS.ORCHESTRATOR_COMPLETED, '_onOrchestratorCompleted'], [SSE_EVENTS.ORCHESTRATOR_ERROR, '_onOrchestratorError'], + + // Clipboard + [SSE_EVENTS.CLIPBOARD_WRITE, '_onClipboardWrite'], ]; diff --git a/src/web/public/constants.js b/src/web/public/constants.js index dd954e1d..25566321 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -292,6 +292,9 @@ const SSE_EVENTS = { ORCHESTRATOR_COMPLETED: 'orchestrator:completed', ORCHESTRATOR_ERROR: 'orchestrator:error', + // Clipboard + CLIPBOARD_WRITE: 'clipboard:write', + // Cases CASE_CREATED: 'case:created', CASE_LINKED: 'case:linked', diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js index 00cd7bca..81bc4e94 100644 --- a/src/web/public/panels-ui.js +++ b/src/web/public/panels-ui.js @@ -3246,4 +3246,68 @@ Object.assign(CodemanApp.prototype, { } } }, + + // ─── Clipboard ────────────────────────────────────────────────────────────── + + async _onClipboardWrite(data) { + const text = data?.text; + if (typeof text !== 'string') return; + try { + await navigator.clipboard.writeText(text); + this.showToast(`Copied to clipboard (${text.length} chars)`, 'success'); + } catch { + this._showClipboardFallback(text); + } + }, + + _showClipboardFallback(text) { + const overlay = document.createElement('div'); + overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.6);z-index:10000;display:flex;align-items:center;justify-content:center'; + + const modal = document.createElement('div'); + modal.style.cssText = 'background:#1e1e2e;border:1px solid #444;border-radius:8px;padding:16px;max-width:600px;width:90%;max-height:60vh;display:flex;flex-direction:column;gap:12px'; + + const header = document.createElement('div'); + header.style.cssText = 'display:flex;justify-content:space-between;align-items:center'; + const title = document.createElement('span'); + title.style.cssText = 'color:#cdd6f4;font-weight:600'; + title.textContent = 'Clipboard (browser blocked auto-copy)'; + const closeBtn = document.createElement('button'); + closeBtn.style.cssText = 'background:none;border:none;color:#cdd6f4;font-size:18px;cursor:pointer'; + closeBtn.textContent = '\u00d7'; + header.appendChild(title); + header.appendChild(closeBtn); + + const textarea = document.createElement('textarea'); + textarea.readOnly = true; + textarea.style.cssText = 'background:#181825;color:#cdd6f4;border:1px solid #555;border-radius:4px;padding:8px;font-family:monospace;font-size:13px;resize:none;height:200px;width:100%'; + textarea.value = text; + + const copyBtn = document.createElement('button'); + copyBtn.style.cssText = 'background:#89b4fa;color:#1e1e2e;border:none;border-radius:4px;padding:8px 16px;cursor:pointer;font-weight:600'; + copyBtn.textContent = 'Copy to Clipboard'; + + modal.appendChild(header); + modal.appendChild(textarea); + modal.appendChild(copyBtn); + overlay.appendChild(modal); + document.body.appendChild(overlay); + + copyBtn.onclick = async () => { + try { + await navigator.clipboard.writeText(text); + this.showToast('Copied to clipboard', 'success'); + overlay.remove(); + } catch { + textarea.select(); + document.execCommand('copy'); + this.showToast('Copied (fallback)', 'success'); + overlay.remove(); + } + }; + + const close = () => overlay.remove(); + closeBtn.onclick = close; + overlay.onclick = (e) => { if (e.target === overlay) close(); }; + }, }); diff --git a/src/web/routes/clipboard-routes.ts b/src/web/routes/clipboard-routes.ts new file mode 100644 index 00000000..f36b87b6 --- /dev/null +++ b/src/web/routes/clipboard-routes.ts @@ -0,0 +1,24 @@ +/** + * @fileoverview Clipboard routes. + * Accepts text via POST and broadcasts to connected browsers for clipboard write. + */ + +import { FastifyInstance } from 'fastify'; +import { SseEvent } from '../sse-events.js'; +import type { EventPort } from '../ports/index.js'; + +export function registerClipboardRoutes(app: FastifyInstance, ctx: EventPort): void { + app.post('/api/clipboard', async (req) => { + const body = req.body as { text?: string; sessionId?: string }; + const text = body?.text; + if (typeof text !== 'string' || text.length === 0) { + return { success: false, error: 'Missing or empty "text" field' }; + } + ctx.broadcast(SseEvent.ClipboardWrite, { + text, + sessionId: body.sessionId ?? null, + timestamp: Date.now(), + }); + return { success: true }; + }); +} diff --git a/src/web/routes/index.ts b/src/web/routes/index.ts index 8603528e..2c2eead1 100644 --- a/src/web/routes/index.ts +++ b/src/web/routes/index.ts @@ -15,4 +15,5 @@ export { registerRespawnRoutes } from './respawn-routes.js'; export { registerRalphRoutes } from './ralph-routes.js'; export { registerPlanRoutes } from './plan-routes.js'; export { registerOrchestratorRoutes } from './orchestrator-routes.js'; +export { registerClipboardRoutes } from './clipboard-routes.js'; export { registerWsRoutes } from './ws-routes.js'; diff --git a/src/web/server.ts b/src/web/server.ts index 5b2c7e96..0ccb3df6 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -112,6 +112,7 @@ import { registerRespawnRoutes, registerRalphRoutes, registerPlanRoutes, + registerClipboardRoutes, registerOrchestratorRoutes, registerWsRoutes, } from './routes/index.js'; @@ -650,6 +651,7 @@ export class WebServer extends EventEmitter { registerRespawnRoutes(this.app, ctx); registerRalphRoutes(this.app, ctx); registerPlanRoutes(this.app, ctx); + registerClipboardRoutes(this.app, ctx); registerOrchestratorRoutes(this.app, ctx); registerWsRoutes(this.app, ctx); } diff --git a/src/web/sse-events.ts b/src/web/sse-events.ts index d83893fa..b6c8d7a7 100644 --- a/src/web/sse-events.ts +++ b/src/web/sse-events.ts @@ -319,6 +319,11 @@ export const OrchestratorCompleted = 'orchestrator:completed' as const; /** Orchestrator error. */ export const OrchestratorError = 'orchestrator:error' as const; +// ─── Clipboard ────────────────────────────────────────────────────────────── + +/** Clipboard content pushed to browser. */ +export const ClipboardWrite = 'clipboard:write' as const; + // ─── Cases ─────────────────────────────────────────────────────────────────── /** New case directory created. */ @@ -486,6 +491,9 @@ export const SseEvent = { OrchestratorCompleted, OrchestratorError, + // Clipboard + ClipboardWrite, + // Cases CaseCreated, CaseLinked,