Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/web/public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
];


Expand Down
3 changes: 3 additions & 0 deletions src/web/public/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
64 changes: 64 additions & 0 deletions src/web/public/panels-ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(); };
},
});
24 changes: 24 additions & 0 deletions src/web/routes/clipboard-routes.ts
Original file line number Diff line number Diff line change
@@ -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 };
});
}
1 change: 1 addition & 0 deletions src/web/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
2 changes: 2 additions & 0 deletions src/web/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ import {
registerRespawnRoutes,
registerRalphRoutes,
registerPlanRoutes,
registerClipboardRoutes,
registerOrchestratorRoutes,
registerWsRoutes,
} from './routes/index.js';
Expand Down Expand Up @@ -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);
}
Expand Down
8 changes: 8 additions & 0 deletions src/web/sse-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -486,6 +491,9 @@ export const SseEvent = {
OrchestratorCompleted,
OrchestratorError,

// Clipboard
ClipboardWrite,

// Cases
CaseCreated,
CaseLinked,
Expand Down