mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-01 04:59:41 +02:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab7c502c2a | ||
|
|
546bbcbe7c | ||
|
|
774d5ff321 | ||
|
|
98ceb5da1d | ||
|
|
34fb5e49f8 | ||
|
|
002cf81b1e | ||
|
|
9b4aab2502 | ||
|
|
829c797726 | ||
|
|
85da3bb898 | ||
|
|
29b2653801 |
@@ -1,5 +1,23 @@
|
||||
# aicodeman
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Community contributions from @aakhter:
|
||||
- **feat (#66): Tab reorder shortcuts** — `Ctrl+Shift+{` and `Ctrl+Shift+}` move the active session tab left/right, matching WezTerm convention. Order persists across reloads via `saveSessionOrder()`.
|
||||
- **feat (#67): Active tab visibility + Alt+N badges** — active tab now has a bright green border with color-matched glow, and the first 9 tabs display number badges hinting at the `Alt+N` switch shortcut. Badges update on reorder/rerender.
|
||||
- **feat (#68): Clipboard API** — new `POST /api/clipboard` accepting `{text}` broadcasts a `clipboard:write` SSE event; connected browsers attempt `navigator.clipboard.writeText()` with a manual-copy modal fallback when the page isn't focused. Auth-protected via the standard middleware. Useful for pushing snippets from remote sessions to the user's local clipboard.
|
||||
- **fix (#65): Android Shift+key double character** — pressing `Shift+A` on attached Android keyboards no longer produces "AA". Tracks xterm-handled keydown timestamps and skips the orphaned-input listener for 50ms after a real keydown, while still catching Gboard symbol-keyboard inputs (keyCode 229).
|
||||
|
||||
## 0.5.13
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix "Case path not found" error in Quick Start when `~/codeman-cases/` does not exist (issue #64). Two bugs in `session-ui.js`:
|
||||
- `runClaude()` auto-create read `createCaseData.case`, but `POST /api/cases` returns `{ success, data: { case } }` — corrected to `createCaseData.data.case`.
|
||||
- `runShell()` had no auto-create logic and would immediately throw on a missing case directory — now mirrors `runClaude()`'s create-on-demand flow.
|
||||
|
||||
## 0.5.12
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -52,7 +52,7 @@ When user says "COM":
|
||||
4. **Sync CLAUDE.md version**: Update the `**Version**` line below to match the new version from `package.json`
|
||||
5. **Commit and deploy**: `git add -A && git commit -m "chore: version packages" && git push && npm run build && systemctl --user restart codeman-web`
|
||||
|
||||
**Version**: 0.5.12 (must match `package.json`)
|
||||
**Version**: 0.6.0 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "0.5.12",
|
||||
"version": "0.6.0",
|
||||
"description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.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'],
|
||||
];
|
||||
|
||||
|
||||
@@ -639,6 +642,8 @@ class CodemanApp {
|
||||
{ key: '=', altKey: '+', ctrl: true, action: () => this.increaseFontSize() },
|
||||
{ key: '-', ctrl: true, action: () => this.decreaseFontSize() },
|
||||
{ key: 'V', ctrl: true, shift: true, action: () => VoiceInput.toggle() },
|
||||
{ key: '{', ctrl: true, shift: true, action: () => this.moveActiveTabLeft() },
|
||||
{ key: '}', ctrl: true, shift: true, action: () => this.moveActiveTabRight() },
|
||||
];
|
||||
|
||||
// Use capture to handle before terminal
|
||||
@@ -1757,6 +1762,17 @@ class CodemanApp {
|
||||
else if (wantIdle && !hasIdle) { tab.classList.add('tab-alert-idle'); tab.classList.remove('tab-alert-action'); }
|
||||
else if (!alertType && (hasAction || hasIdle)) { tab.classList.remove('tab-alert-action', 'tab-alert-idle'); }
|
||||
|
||||
// Inject tab-number badge if missing (added after initial render)
|
||||
if (!tab.querySelector('.tab-number')) {
|
||||
const idx = this.sessionOrder.indexOf(id);
|
||||
if (idx >= 0 && idx < 9) {
|
||||
const numSpan = document.createElement('span');
|
||||
numSpan.className = 'tab-number';
|
||||
numSpan.textContent = String(idx + 1);
|
||||
tab.insertBefore(numSpan, tab.firstChild);
|
||||
}
|
||||
}
|
||||
|
||||
// Update status indicator
|
||||
const statusEl = tab.querySelector('.tab-status');
|
||||
if (statusEl && !statusEl.classList.contains(status)) {
|
||||
@@ -1849,6 +1865,7 @@ class CodemanApp {
|
||||
// Reorder to put active tab first
|
||||
tabOrder = [this.activeSessionId, ...this.sessionOrder.filter(id => id !== this.activeSessionId)];
|
||||
}
|
||||
let _tabIdx = 0;
|
||||
for (const id of tabOrder) {
|
||||
const session = this.sessions.get(id);
|
||||
if (!session) continue; // Skip if session was removed
|
||||
@@ -1875,6 +1892,7 @@ class CodemanApp {
|
||||
|
||||
const endedAttr = session._ended ? ' data-ended="1"' : '';
|
||||
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}" data-id="${id}" data-color="${color}"${endedAttr} onclick="app.selectSession('${escapeHtml(id)}')" oncontextmenu="event.preventDefault(); app.startInlineRename('${escapeHtml(id)}')" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${session.workingDir ? `title="${escapeHtml(session.workingDir)}"` : ''}>
|
||||
${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''}
|
||||
<span class="tab-status ${status}" aria-hidden="true"></span>
|
||||
<span class="tab-info">
|
||||
<span class="tab-name-row">
|
||||
@@ -1888,6 +1906,7 @@ class CodemanApp {
|
||||
<span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions('${escapeHtml(id)}')" title="Session options" aria-label="Session options" tabindex="0">⚙</span>
|
||||
<span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession('${escapeHtml(id)}')" title="Close session" aria-label="Close session" tabindex="0">×</span>
|
||||
</div>`);
|
||||
_tabIdx++;
|
||||
}
|
||||
|
||||
container.innerHTML = parts.join('');
|
||||
@@ -2106,6 +2125,24 @@ class CodemanApp {
|
||||
});
|
||||
}
|
||||
|
||||
moveActiveTabLeft() {
|
||||
if (!this.activeSessionId) return;
|
||||
const idx = this.sessionOrder.indexOf(this.activeSessionId);
|
||||
if (idx <= 0) return;
|
||||
[this.sessionOrder[idx - 1], this.sessionOrder[idx]] = [this.sessionOrder[idx], this.sessionOrder[idx - 1]];
|
||||
this.saveSessionOrder();
|
||||
this._fullRenderSessionTabs();
|
||||
}
|
||||
|
||||
moveActiveTabRight() {
|
||||
if (!this.activeSessionId) return;
|
||||
const idx = this.sessionOrder.indexOf(this.activeSessionId);
|
||||
if (idx === -1 || idx >= this.sessionOrder.length - 1) return;
|
||||
[this.sessionOrder[idx], this.sessionOrder[idx + 1]] = [this.sessionOrder[idx + 1], this.sessionOrder[idx]];
|
||||
this.saveSessionOrder();
|
||||
this._fullRenderSessionTabs();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Session Lifecycle — select, close, navigate
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(); };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -287,8 +287,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
});
|
||||
const createCaseData = await createCaseRes.json();
|
||||
if (!createCaseData.success) throw new Error(createCaseData.error || 'Failed to create case');
|
||||
// Use the newly created case data (API returns { success, case: { name, path } })
|
||||
caseData = createCaseData.case;
|
||||
// API returns { success, data: { case: { name, path } } }
|
||||
caseData = createCaseData.data.case;
|
||||
}
|
||||
|
||||
const workingDir = caseData.path;
|
||||
@@ -425,7 +425,21 @@ Object.assign(CodemanApp.prototype, {
|
||||
try {
|
||||
// Get the case path
|
||||
const caseRes = await fetch(`/api/cases/${caseName}`);
|
||||
const caseData = await caseRes.json();
|
||||
let caseData = await caseRes.json();
|
||||
|
||||
// Create the case if it doesn't exist
|
||||
if (!caseData.path) {
|
||||
const createCaseRes = await fetch('/api/cases', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: caseName, description: '' })
|
||||
});
|
||||
const createCaseData = await createCaseRes.json();
|
||||
if (!createCaseData.success) throw new Error(createCaseData.error || 'Failed to create case');
|
||||
// API returns { success, data: { case: { name, path } } }
|
||||
caseData = createCaseData.data.case;
|
||||
}
|
||||
|
||||
const workingDir = caseData.path;
|
||||
if (!workingDir) throw new Error('Case path not found');
|
||||
|
||||
|
||||
+36
-12
@@ -258,15 +258,16 @@ body {
|
||||
}
|
||||
|
||||
.session-tab.active {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-color: var(--glass-border);
|
||||
color: var(--text);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
background: rgba(34, 197, 94, 0.15) !important;
|
||||
border: 2px solid #00FF66 !important;
|
||||
color: #fff !important;
|
||||
box-shadow: none !important;
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
/* Tab switch feedback: bright green glow on the newly-active tab */
|
||||
.session-tab.tab-glow {
|
||||
animation: tab-glow 0.35s ease-out forwards;
|
||||
animation: tab-glow 0.35s ease-out;
|
||||
}
|
||||
|
||||
@keyframes tab-glow {
|
||||
@@ -304,13 +305,36 @@ body {
|
||||
.session-tab[data-color="pink"] { border-left: 3px solid var(--session-pink); }
|
||||
|
||||
/* Active tabs with color get subtle background tint */
|
||||
.session-tab.active[data-color="red"] { background: rgba(239, 68, 68, 0.1); }
|
||||
.session-tab.active[data-color="orange"] { background: rgba(249, 115, 22, 0.1); }
|
||||
.session-tab.active[data-color="yellow"] { background: rgba(234, 179, 8, 0.1); }
|
||||
.session-tab.active[data-color="green"] { background: rgba(34, 197, 94, 0.1); }
|
||||
.session-tab.active[data-color="blue"] { background: rgba(59, 130, 246, 0.1); }
|
||||
.session-tab.active[data-color="purple"] { background: rgba(168, 85, 247, 0.1); }
|
||||
.session-tab.active[data-color="pink"] { background: rgba(236, 72, 153, 0.1); }
|
||||
.session-tab.active[data-color="red"] { background: rgba(239, 68, 68, 0.15); border-color: rgba(239, 68, 68, 0.5); box-shadow: 0 0 10px rgba(239, 68, 68, 0.3); }
|
||||
.session-tab.active[data-color="orange"] { background: rgba(249, 115, 22, 0.15); border-color: rgba(249, 115, 22, 0.5); box-shadow: 0 0 10px rgba(249, 115, 22, 0.3); }
|
||||
.session-tab.active[data-color="yellow"] { background: rgba(234, 179, 8, 0.15); border-color: rgba(234, 179, 8, 0.5); box-shadow: 0 0 10px rgba(234, 179, 8, 0.3); }
|
||||
.session-tab.active[data-color="green"] { background: rgba(34, 197, 94, 0.15); border-color: rgba(34, 197, 94, 0.5); box-shadow: 0 0 10px rgba(34, 197, 94, 0.3); }
|
||||
.session-tab.active[data-color="blue"] { background: rgba(59, 130, 246, 0.15); border-color: rgba(59, 130, 246, 0.5); box-shadow: 0 0 10px rgba(59, 130, 246, 0.3); }
|
||||
.session-tab.active[data-color="purple"] { background: rgba(168, 85, 247, 0.15); border-color: rgba(168, 85, 247, 0.5); box-shadow: 0 0 10px rgba(168, 85, 247, 0.3); }
|
||||
.session-tab.active[data-color="pink"] { background: rgba(236, 72, 153, 0.15); border-color: rgba(236, 72, 153, 0.5); box-shadow: 0 0 10px rgba(236, 72, 153, 0.3); }
|
||||
|
||||
/* Tab number indicator (Alt+N shortcut hint) */
|
||||
.session-tab .tab-number {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 3px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border: 1px solid rgba(255, 255, 255, 0.35);
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.session-tab.active .tab-number {
|
||||
background: rgba(0, 255, 102, 0.2) !important;
|
||||
border-color: rgba(0, 255, 102, 0.5) !important;
|
||||
color: #00FF66 !important;
|
||||
}
|
||||
|
||||
.session-tab .tab-info {
|
||||
display: flex;
|
||||
|
||||
@@ -122,21 +122,45 @@ Object.assign(CodemanApp.prototype, {
|
||||
// On Android Chrome, typing symbols (e.g., "/" from Gboard's symbol keyboard)
|
||||
// sends keyCode 229 + input event WITHOUT compositionstart/end wrapping.
|
||||
// The custom key handler above returns false for keyCode 229, telling xterm
|
||||
// to ignore the keydown. This listener catches those orphaned input events.
|
||||
// to ignore the keydown. xterm.js expects the character to arrive via
|
||||
// composition events, but since there's no composition, the character is lost.
|
||||
// This listener catches those orphaned input events and forwards them to onData.
|
||||
{
|
||||
const xtermTextarea = container.querySelector('.xterm-helper-textarea');
|
||||
if (xtermTextarea && MobileDetection.isTouchDevice()) {
|
||||
let composing = false;
|
||||
let lastKeydownHandled = 0;
|
||||
xtermTextarea.addEventListener('compositionstart', () => { composing = true; });
|
||||
xtermTextarea.addEventListener('compositionend', () => { composing = false; });
|
||||
// Track when xterm handles a keydown normally (non-229 keyCode).
|
||||
// If xterm processed the keydown, it will emit onData itself --
|
||||
// the input event handler below must NOT re-send the character.
|
||||
xtermTextarea.addEventListener('keydown', (e) => {
|
||||
if (!e.isComposing && e.keyCode !== 229) {
|
||||
lastKeydownHandled = Date.now();
|
||||
}
|
||||
});
|
||||
xtermTextarea.addEventListener('input', (e) => {
|
||||
// Only handle insertText events outside of composition -- these are
|
||||
// the ones xterm.js misses on Android virtual keyboards.
|
||||
if (composing || e.isComposing) return;
|
||||
if (e.inputType !== 'insertText' || !e.data) return;
|
||||
// If xterm just handled a keydown (within 50ms), it already sent the
|
||||
// char via onData. Skip to avoid double-send (e.g., Shift+A => AA).
|
||||
if (Date.now() - lastKeydownHandled < 50) return;
|
||||
// xterm.js may have already processed this via its own input handler.
|
||||
// Check if the textarea was cleared by xterm (value is empty or just
|
||||
// whitespace) -- if so, xterm handled it and we should not double-send.
|
||||
// Use a microtask to check after xterm's own handlers have run.
|
||||
const data = e.data;
|
||||
Promise.resolve().then(() => {
|
||||
// If xterm cleared the textarea, it processed the input -- skip.
|
||||
const val = xtermTextarea.value;
|
||||
if (!val || val.trim() === '') return;
|
||||
// xterm didn't process it -- forward to terminal as if typed.
|
||||
// Emit via onData path by writing to terminal's input handler.
|
||||
this.terminal._core.coreService.triggerDataEvent(data, true);
|
||||
// Clear the textarea to prevent xterm from processing it later.
|
||||
xtermTextarea.value = '';
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user