Skip to content

Commit 546bbcb

Browse files
authored
Merge pull request #68 from aakhter/feat/clipboard-api
feat: add clipboard API for remote browser clipboard access
2 parents 774d5ff + 9b4aab2 commit 546bbcb

7 files changed

Lines changed: 105 additions & 0 deletions

File tree

src/web/public/app.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,9 @@ const _SSE_HANDLER_MAP = [
249249
[SSE_EVENTS.ORCHESTRATOR_TASK_FAILED, '_onOrchestratorTaskFailed'],
250250
[SSE_EVENTS.ORCHESTRATOR_COMPLETED, '_onOrchestratorCompleted'],
251251
[SSE_EVENTS.ORCHESTRATOR_ERROR, '_onOrchestratorError'],
252+
253+
// Clipboard
254+
[SSE_EVENTS.CLIPBOARD_WRITE, '_onClipboardWrite'],
252255
];
253256

254257

src/web/public/constants.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,9 @@ const SSE_EVENTS = {
292292
ORCHESTRATOR_COMPLETED: 'orchestrator:completed',
293293
ORCHESTRATOR_ERROR: 'orchestrator:error',
294294

295+
// Clipboard
296+
CLIPBOARD_WRITE: 'clipboard:write',
297+
295298
// Cases
296299
CASE_CREATED: 'case:created',
297300
CASE_LINKED: 'case:linked',

src/web/public/panels-ui.js

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3246,4 +3246,68 @@ Object.assign(CodemanApp.prototype, {
32463246
}
32473247
}
32483248
},
3249+
3250+
// ─── Clipboard ──────────────────────────────────────────────────────────────
3251+
3252+
async _onClipboardWrite(data) {
3253+
const text = data?.text;
3254+
if (typeof text !== 'string') return;
3255+
try {
3256+
await navigator.clipboard.writeText(text);
3257+
this.showToast(`Copied to clipboard (${text.length} chars)`, 'success');
3258+
} catch {
3259+
this._showClipboardFallback(text);
3260+
}
3261+
},
3262+
3263+
_showClipboardFallback(text) {
3264+
const overlay = document.createElement('div');
3265+
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';
3266+
3267+
const modal = document.createElement('div');
3268+
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';
3269+
3270+
const header = document.createElement('div');
3271+
header.style.cssText = 'display:flex;justify-content:space-between;align-items:center';
3272+
const title = document.createElement('span');
3273+
title.style.cssText = 'color:#cdd6f4;font-weight:600';
3274+
title.textContent = 'Clipboard (browser blocked auto-copy)';
3275+
const closeBtn = document.createElement('button');
3276+
closeBtn.style.cssText = 'background:none;border:none;color:#cdd6f4;font-size:18px;cursor:pointer';
3277+
closeBtn.textContent = '\u00d7';
3278+
header.appendChild(title);
3279+
header.appendChild(closeBtn);
3280+
3281+
const textarea = document.createElement('textarea');
3282+
textarea.readOnly = true;
3283+
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%';
3284+
textarea.value = text;
3285+
3286+
const copyBtn = document.createElement('button');
3287+
copyBtn.style.cssText = 'background:#89b4fa;color:#1e1e2e;border:none;border-radius:4px;padding:8px 16px;cursor:pointer;font-weight:600';
3288+
copyBtn.textContent = 'Copy to Clipboard';
3289+
3290+
modal.appendChild(header);
3291+
modal.appendChild(textarea);
3292+
modal.appendChild(copyBtn);
3293+
overlay.appendChild(modal);
3294+
document.body.appendChild(overlay);
3295+
3296+
copyBtn.onclick = async () => {
3297+
try {
3298+
await navigator.clipboard.writeText(text);
3299+
this.showToast('Copied to clipboard', 'success');
3300+
overlay.remove();
3301+
} catch {
3302+
textarea.select();
3303+
document.execCommand('copy');
3304+
this.showToast('Copied (fallback)', 'success');
3305+
overlay.remove();
3306+
}
3307+
};
3308+
3309+
const close = () => overlay.remove();
3310+
closeBtn.onclick = close;
3311+
overlay.onclick = (e) => { if (e.target === overlay) close(); };
3312+
},
32493313
});

src/web/routes/clipboard-routes.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/**
2+
* @fileoverview Clipboard routes.
3+
* Accepts text via POST and broadcasts to connected browsers for clipboard write.
4+
*/
5+
6+
import { FastifyInstance } from 'fastify';
7+
import { SseEvent } from '../sse-events.js';
8+
import type { EventPort } from '../ports/index.js';
9+
10+
export function registerClipboardRoutes(app: FastifyInstance, ctx: EventPort): void {
11+
app.post('/api/clipboard', async (req) => {
12+
const body = req.body as { text?: string; sessionId?: string };
13+
const text = body?.text;
14+
if (typeof text !== 'string' || text.length === 0) {
15+
return { success: false, error: 'Missing or empty "text" field' };
16+
}
17+
ctx.broadcast(SseEvent.ClipboardWrite, {
18+
text,
19+
sessionId: body.sessionId ?? null,
20+
timestamp: Date.now(),
21+
});
22+
return { success: true };
23+
});
24+
}

src/web/routes/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,5 @@ export { registerRespawnRoutes } from './respawn-routes.js';
1515
export { registerRalphRoutes } from './ralph-routes.js';
1616
export { registerPlanRoutes } from './plan-routes.js';
1717
export { registerOrchestratorRoutes } from './orchestrator-routes.js';
18+
export { registerClipboardRoutes } from './clipboard-routes.js';
1819
export { registerWsRoutes } from './ws-routes.js';

src/web/server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ import {
112112
registerRespawnRoutes,
113113
registerRalphRoutes,
114114
registerPlanRoutes,
115+
registerClipboardRoutes,
115116
registerOrchestratorRoutes,
116117
registerWsRoutes,
117118
} from './routes/index.js';
@@ -650,6 +651,7 @@ export class WebServer extends EventEmitter {
650651
registerRespawnRoutes(this.app, ctx);
651652
registerRalphRoutes(this.app, ctx);
652653
registerPlanRoutes(this.app, ctx);
654+
registerClipboardRoutes(this.app, ctx);
653655
registerOrchestratorRoutes(this.app, ctx);
654656
registerWsRoutes(this.app, ctx);
655657
}

src/web/sse-events.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,11 @@ export const OrchestratorCompleted = 'orchestrator:completed' as const;
319319
/** Orchestrator error. */
320320
export const OrchestratorError = 'orchestrator:error' as const;
321321

322+
// ─── Clipboard ──────────────────────────────────────────────────────────────
323+
324+
/** Clipboard content pushed to browser. */
325+
export const ClipboardWrite = 'clipboard:write' as const;
326+
322327
// ─── Cases ───────────────────────────────────────────────────────────────────
323328

324329
/** New case directory created. */
@@ -486,6 +491,9 @@ export const SseEvent = {
486491
OrchestratorCompleted,
487492
OrchestratorError,
488493

494+
// Clipboard
495+
ClipboardWrite,
496+
489497
// Cases
490498
CaseCreated,
491499
CaseLinked,

0 commit comments

Comments
 (0)