Phase 6: Comms v2 — send email, compose, schedule, meeting prep

- arc.php: comms_sent / comms_sent_get / comms_sent_delete + outbox backend
- chat.php: Tier 0.9f-0.9h — send_email, compose_email, schedule_event, meeting_prep voice detection
- index.html: COMMS tab SEND REPLY button, COMPOSE modal, OUTBOX section, onArcJobStarted routes comms jobs to COMMS tab
- admin/index.php: OUTBOX nav + tab, send_reply/compose_email/outbox_list/outbox_delete PHP actions, outboxCompose() modal, triageSendReply() inline

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 05:05:00 +00:00
parent f15225994a
commit 8229f52b8b
4 changed files with 465 additions and 11 deletions
+23
View File
@@ -213,6 +213,29 @@ switch ($action) {
echo json_encode(arc_request('GET', '/guardian/chat' . $qs));
break;
// ── COMMS v2 ──────────────────────────────────────────────────────────────
// GET /api/arc?action=comms_sent&limit=50&status=sent
case 'comms_sent':
$limit = min((int)($_GET['limit'] ?? 50), 200);
$status = $_GET['status'] ?? '';
$qs = http_build_query(array_filter(['limit' => $limit, 'status' => $status]));
echo json_encode(arc_request('GET', '/comms/sent' . ($qs ? "?{$qs}" : '')));
break;
// GET /api/arc?action=comms_sent_get&id=123
case 'comms_sent_get':
$id = (int)($_GET['id'] ?? 0);
if (!$id) { http_response_code(400); echo json_encode(['error' => 'Missing id']); break; }
echo json_encode(arc_request('GET', "/comms/sent/{$id}"));
break;
// DELETE /api/arc?action=comms_sent_delete&id=123
case 'comms_sent_delete':
$id = (int)($_GET['id'] ?? 0);
if (!$id) { http_response_code(400); echo json_encode(['error' => 'Missing id']); break; }
echo json_encode(arc_request('DELETE', "/comms/sent/{$id}"));
break;
default:
http_response_code(404);
echo json_encode(['error' => "Unknown arc action: {$action}"]);
+94
View File
@@ -1261,6 +1261,100 @@ if (!$reply) {
}
}
// ── Tier 0.9f: Comms v2 — send_email, compose_email ─────────────────────────
if (!$reply) {
// "reply to [name/id] saying..." or "send [name] a reply..."
if (preg_match('/^(?:jarvis[,\s]+)?(?:reply\s+to|send\s+(?:a\s+)?reply\s+to)\s+(.+?)\s+(?:saying|that|:)\s+(.+)/i', $message, $m)) {
$target = trim($m[1]);
$content = trim($m[2]);
$arcRes = arcSubmitJob('send_email', [
'target' => $target,
'content' => $content,
], $sessionId);
if (isset($arcRes['job_id'])) {
$arcJobId = $arcRes['job_id'];
$reply = "◈ COMMS PROTOCOL — Sending reply to **{$target}** (Job #{$arcJobId}). Drafting and transmitting now, {$userAddr}.";
$source = 'arc:send_email';
} else {
$reply = "Comms Protocol is offline, {$userAddr}. Arc Reactor may be unavailable.";
$source = 'arc:offline';
}
}
// "send email to [name] about [subject]" or "compose email to..."
elseif (preg_match('/^(?:jarvis[,\s]+)?(?:send\s+(?:an?\s+)?email\s+to|compose\s+(?:an?\s+)?email\s+to|write\s+(?:an?\s+)?email\s+to|draft\s+(?:an?\s+)?email\s+to)\s+(.+?)(?:\s+(?:about|regarding|re:?)\s+(.+))?$/i', $message, $m)) {
$recipient = trim($m[1]);
$instructions = isset($m[2]) ? trim($m[2]) : $message;
$arcRes = arcSubmitJob('compose_email', [
'recipient' => $recipient,
'instructions' => $instructions,
'auto_send' => false,
], $sessionId);
if (isset($arcRes['job_id'])) {
$arcJobId = $arcRes['job_id'];
$reply = "◈ COMMS PROTOCOL — Composing email to **{$recipient}** (Job #{$arcJobId}). I'll draft it and show you before sending, {$userAddr}. Check the COMMS tab.";
$source = 'arc:compose_email';
} else {
$reply = "Comms Protocol is offline, {$userAddr}.";
$source = 'arc:offline';
}
}
}
// ── Tier 0.9g: Comms v2 — schedule_event ─────────────────────────────────────
if (!$reply) {
$schedulePatterns = [
'/^(?:jarvis[,\s]+)?(?:schedule|book|set\s+up|create)\s+(?:a\s+)?(?:meeting|call|appointment|event|session)\s+(?:with|for|about)?\s*(.+)/i',
'/^(?:jarvis[,\s]+)?(?:add\s+(?:a\s+)?(?:meeting|call|appointment|event)\s+(?:to\s+my\s+calendar)?\s*(?:with|for|about)?\s*(.+))/i',
'/^(?:jarvis[,\s]+)?(?:put\s+(?:a\s+)?(?:meeting|call|appointment)\s+(?:on\s+(?:my\s+)?calendar|in\s+my\s+schedule)(?:\s+(?:with|for|about)?\s+(.+))?)/i',
];
foreach ($schedulePatterns as $pat) {
if (preg_match($pat, $message, $m)) {
$details = trim($m[1] ?? $message);
$arcRes = arcSubmitJob('schedule_event', [
'request' => $message,
'details' => $details,
'provider' => 'claude',
], $sessionId);
if (isset($arcRes['job_id'])) {
$arcJobId = $arcRes['job_id'];
$reply = "◈ SCHEDULING PROTOCOL — Processing your calendar request (Job #{$arcJobId}). I'm parsing the details and creating the appointment now, {$userAddr}.";
$source = 'arc:schedule_event';
} else {
$reply = "Scheduling Protocol is offline, {$userAddr}. Arc Reactor may be unavailable.";
$source = 'arc:offline';
}
break;
}
}
}
// ── Tier 0.9h: Comms v2 — meeting_prep ────────────────────────────────────────
if (!$reply) {
$meetingPrepPatterns = [
'/^(?:jarvis[,\s]+)?(?:prep(?:are)?(?:\s+me)?\s+for|brief(?:ing)?\s+(?:me\s+)?(?:for|on|about)?)\s+(?:my\s+)?(?:next\s+)?(?:meeting|call|appointment)/i',
'/^(?:jarvis[,\s]+)?(?:what(?:\'s|\s+do\s+i\s+need\s+to\s+know)?\s+(?:is\s+)?(?:my\s+)?(?:next\s+)?meeting)/i',
'/^(?:jarvis[,\s]+)?(?:meeting\s+prep|pre[- ]meeting\s+(?:brief|notes|prep))/i',
'/^(?:jarvis[,\s]+)?(?:get\s+(?:me\s+)?ready\s+for\s+my\s+(?:next\s+)?(?:meeting|call))/i',
];
foreach ($meetingPrepPatterns as $pat) {
if (preg_match($pat, $message)) {
$arcRes = arcSubmitJob('meeting_prep', [
'provider' => 'claude',
'research' => true,
], $sessionId);
if (isset($arcRes['job_id'])) {
$arcJobId = $arcRes['job_id'];
$reply = "◈ MISSION PROTOCOL — Preparing your meeting briefing (Job #{$arcJobId}). I'm pulling your next appointment details and researching the participants, {$userAddr}. Stand by.";
$source = 'arc:meeting_prep';
} else {
$reply = "Mission Protocol is offline, {$userAddr}. Arc Reactor may be unavailable.";
$source = 'arc:offline';
}
break;
}
}
}
// ── Tier 1: Intent Engine (instant, no LLM) ───────────────────────────────
if (!$reply) {
$matched = KBEngine::match($message);
+150 -1
View File
@@ -550,6 +550,49 @@ if ($action) {
$raw = curl_exec($ch); curl_close($ch);
j(json_decode($raw, true) ?: ['error' => 'Arc Reactor unreachable']);
// ── OUTBOX ────────────────────────────────────────────────────────────
case 'outbox_list':
$limit = min((int)($_GET['limit'] ?? 50), 200);
$status = $_GET['status'] ?? '';
$qs = http_build_query(array_filter(['limit' => $limit, 'status' => $status]));
$ch = curl_init('http://127.0.0.1:7474/comms/sent' . ($qs ? "?{$qs}" : ''));
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>5]);
$raw = curl_exec($ch); curl_close($ch);
j(json_decode($raw, true) ?: []);
case 'outbox_delete':
$id = (int)($_GET['id'] ?? 0); if (!$id) bad('Missing id');
$ch = curl_init('http://127.0.0.1:7474/comms/sent/' . $id);
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>5, CURLOPT_CUSTOMREQUEST=>'DELETE']);
$raw = curl_exec($ch); curl_close($ch);
j(json_decode($raw, true) ?: ['error'=>'failed']);
case 'send_reply':
$id = (int)($_GET['id'] ?? 0); if (!$id) bad('Missing triage id');
$content = $_GET['content'] ?? '';
$ch = curl_init('http://127.0.0.1:7474/job');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 5, CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode(['type'=>'send_email','payload'=>['triage_id'=>$id,'content'=>$content],'priority'=>8,'created_by'=>'admin']),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
]);
$raw = curl_exec($ch); curl_close($ch);
j(json_decode($raw, true) ?: ['error' => 'Arc Reactor unreachable']);
case 'compose_email':
$to = $_GET['to'] ?? ''; if (!$to) bad('Missing recipient');
$subject = $_GET['subject'] ?? '';
$body = $_GET['body'] ?? '';
$account = $_GET['account'] ?? 'gmail';
$ch = curl_init('http://127.0.0.1:7474/job');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 5, CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode(['type'=>'compose_email','payload'=>['recipient'=>$to,'subject'=>$subject,'instructions'=>$body,'account'=>$account,'auto_send'=>false],'priority'=>7,'created_by'=>'admin']),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
]);
$raw = curl_exec($ch); curl_close($ch);
j(json_decode($raw, true) ?: ['error' => 'Arc Reactor unreachable']);
// ── VISION PROTOCOL ──────────────────────────────────────────────────
case 'vision_list':
$limit = min((int)($_GET['limit'] ?? 30), 100);
@@ -876,6 +919,7 @@ select.filter-sel:focus{border-color:var(--cyan)}
<div class="nav-section">COMMUNICATIONS</div>
<div class="nav-item" data-tab="email" onclick="nav(this)">📧 EMAIL</div>
<div class="nav-item" data-tab="triage" onclick="nav(this)"> GMAIL TRIAGE</div>
<div class="nav-item" data-tab="outbox" onclick="nav(this)"> OUTBOX</div>
<div class="nav-section">PLANNER</div>
<div class="nav-item" data-tab="tasks" onclick="nav(this)">📋 TASKS</div>
<div class="nav-item" data-tab="appointments" onclick="nav(this)">📅 APPOINTMENTS</div>
@@ -1264,6 +1308,23 @@ select.filter-sel:focus{border-color:var(--cyan)}
<div class="tbl-wrap" id="triage-tbl"><div class="loading">LOADING TRIAGE DATA...</div></div>
</div>
<!-- OUTBOX -->
<div class="tab" id="tab-outbox">
<div class="page-title"> COMMS OUTBOX SENT &amp; QUEUED</div>
<div style="display:flex;gap:10px;margin-bottom:16px;align-items:center;flex-wrap:wrap">
<button class="btn btn-sm btn-green" onclick="outboxCompose()">+ COMPOSE</button>
<button class="btn btn-sm" onclick="loadOutbox()"> REFRESH</button>
<select id="outbox-status" class="inp" style="width:auto;padding:4px 8px;font-size:0.65rem" onchange="loadOutbox()">
<option value="">ALL</option>
<option value="sent">SENT</option>
<option value="queued">QUEUED</option>
<option value="failed">FAILED</option>
</select>
<div id="outbox-count" style="margin-left:auto;font-family:var(--mono);font-size:0.65rem;color:var(--dim)"></div>
</div>
<div class="tbl-wrap" id="outbox-tbl"><div class="loading">LOADING OUTBOX...</div></div>
</div>
</div><!-- /content -->
</div><!-- /main -->
</div><!-- /app -->
@@ -1376,6 +1437,7 @@ function loadTab(tab) {
users: loadUsers,
email: ()=>{ loadEmailInbox(); loadEmailActionItems(); },
triage: loadTriage,
outbox: loadOutbox,
vision: loadVision,
guardian: loadGuardian,
tasks: loadTasks,
@@ -2574,6 +2636,7 @@ async function loadTriage() {
const catBadge = `<span style="color:${catColor};font-weight:700">${(it.category||'').toUpperCase()}</span>`;
const hasDraft = it.draft_reply && it.draft_reply.trim().length > 5;
const draftBtn = hasDraft ? `<button class="btn btn-xs" style="border-color:var(--cyan);color:var(--cyan)" onclick="triageViewDraft(${it.id})">VIEW DRAFT</button> ` : '';
const sendBtn = hasDraft ? `<button class="btn btn-xs btn-green" onclick="triageSendReply(${it.id})">◈ SEND</button> ` : '';
return `<tr>
<td style="width:60px">${catBadge}</td>
<td style="width:30px;text-align:center;color:${it.priority>=8?'var(--red)':it.priority>=5?'var(--orange)':'var(--text-dim)'}">${it.priority||0}</td>
@@ -2581,7 +2644,7 @@ async function loadTriage() {
<td style="max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(it.subject||'')}</td>
<td style="max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text-dim);font-size:0.62rem">${esc(it.summary||'')}</td>
<td style="white-space:nowrap">
${draftBtn}
${sendBtn}${draftBtn}
<button class="btn btn-xs btn-green" onclick="triageMarkDone(${it.id})"> DONE</button>
<button class="btn btn-xs" onclick="triageDismiss(${it.id})"></button>
</td>
@@ -2627,6 +2690,92 @@ function triageViewDraft(id) {
});
}
async function triageSendReply(id) {
if (!confirm('Send the drafted reply for this email now?')) return;
const d = await api('send_reply', {id});
if (d.job_id) {
toast('Send job dispatched — Job #' + d.job_id, 'ok');
setTimeout(() => { loadTriage(); loadOutbox(); }, 3000);
} else {
toast('Send failed: ' + (d.error || 'Arc Reactor offline'), 'err');
}
}
// ── OUTBOX ───────────────────────────────────────────────────────────────────
async function loadOutbox() {
const el = document.getElementById('outbox-tbl');
if (!el) return;
const status = document.getElementById('outbox-status')?.value || '';
const d = await api('outbox_list', {limit: 100, status});
const sent = Array.isArray(d) ? d : (d.sent || []);
document.getElementById('outbox-count').textContent = sent.length + ' MESSAGES';
if (!sent.length) { el.innerHTML = '<div class="loading">No messages in outbox.</div>'; return; }
const statusColor = {sent:'var(--green)',failed:'var(--red)',queued:'var(--orange)'};
const rows = sent.map(m => {
const sc = m.status || 'sent';
const ts = m.sent_at ? new Date(m.sent_at + 'Z').toLocaleString() : '—';
const sCol = statusColor[sc] || 'var(--text-dim)';
return `<tr>
<td style="color:${sCol};font-size:0.6rem;font-weight:700">${sc.toUpperCase()}</td>
<td style="max-width:140px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:0.65rem">${esc(m.to_email||m.to_name||'')}</td>
<td style="max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(m.subject||'(no subject)')}</td>
<td style="font-size:0.6rem;color:var(--dim)">${m.account||'gmail'}</td>
<td style="font-size:0.6rem;color:var(--dim)">${ts}</td>
<td style="white-space:nowrap">
<button class="btn btn-xs" onclick="outboxViewBody(${m.id})">VIEW</button>
<button class="btn btn-xs btn-red" onclick="outboxDelete(${m.id})">DEL</button>
</td>
</tr>`;
}).join('');
el.innerHTML = `<table><thead><tr><th>STATUS</th><th>TO</th><th>SUBJECT</th><th>ACCOUNT</th><th>SENT AT</th><th>ACTIONS</th></tr></thead><tbody>${rows}</tbody></table>`;
}
async function outboxDelete(id) {
if (!confirm('Delete this sent message record?')) return;
const d = await api('outbox_delete', {id});
if (d.ok || d.deleted) { toast('Deleted', 'ok'); loadOutbox(); }
else toast('Delete failed: ' + (d.error||''), 'err');
}
async function outboxViewBody(id) {
const d = await api('outbox_list', {limit: 200});
const sent = Array.isArray(d) ? d : (d.sent || []);
const m = sent.find(x => x.id == id);
if (!m) return;
openModal('SENT MESSAGE — ' + esc(m.subject||''), `
<div style="font-family:var(--mono);font-size:0.65rem;color:var(--text-dim);margin-bottom:8px">TO: ${esc(m.to_email||'')} · ACCOUNT: ${m.account||'gmail'}</div>
<pre style="font-size:0.65rem;white-space:pre-wrap;max-height:300px;overflow-y:auto;background:rgba(0,212,255,0.03);border:1px solid var(--border);border-radius:3px;padding:8px">${esc(m.body||'(no body)')}</pre>
`, null, null);
}
function outboxCompose() {
openModal('COMPOSE MESSAGE', `
<div style="display:flex;flex-direction:column;gap:8px">
<select id="oc-account" class="inp" style="padding:4px 8px;font-size:0.65rem">
<option value="gmail">Gmail</option>
<option value="icloud">iCloud</option>
</select>
<input id="oc-to" class="inp" placeholder="To: email address" type="email">
<input id="oc-subject" class="inp" placeholder="Subject">
<textarea id="oc-body" class="inp" rows="5" style="resize:vertical" placeholder="Describe what to say — AI will draft the full message"></textarea>
</div>
`, async () => {
const to = document.getElementById('oc-to')?.value.trim();
const subject = document.getElementById('oc-subject')?.value.trim();
const body = document.getElementById('oc-body')?.value.trim();
const account = document.getElementById('oc-account')?.value;
if (!to || !body) { toast('Please fill To and message description', 'err'); return; }
const d = await api('compose_email', {to, subject, body, account});
if (d.job_id) {
toast('Compose job dispatched — Job #' + d.job_id, 'ok');
closeModal();
setTimeout(loadOutbox, 5000);
} else {
toast('Failed: ' + (d.error||'Arc Reactor offline'), 'err');
}
}, 'DISPATCH');
}
// ── PLANNER ─────────────────────────────────────────────────────────────────
const _PRI_COLOR = {urgent:'var(--red)',high:'var(--orange)',normal:'var(--text)',low:'var(--border2)'};
+197 -9
View File
@@ -928,6 +928,26 @@ body::after{
.comms-triage-btn{width:100%;background:rgba(0,212,255,0.06);border:1px solid var(--panel-border);border-radius:4px;padding:5px;color:var(--cyan);font-family:var(--font-display);font-size:0.52rem;letter-spacing:2px;cursor:pointer;margin-bottom:7px}
.comms-triage-btn:hover{background:rgba(0,212,255,0.12)}
.comms-prio{font-family:var(--font-mono);font-size:0.52rem;color:var(--text-dim);flex-shrink:0}
.comms-section-label{font-family:var(--font-display);font-size:0.5rem;letter-spacing:2px;color:var(--text-dim);border-bottom:1px solid var(--panel-border);padding:6px 0 4px;margin:8px 0 6px}
.comms-compose-btn{width:100%;background:rgba(0,212,255,0.08);border:1px solid rgba(0,212,255,0.3);border-radius:4px;padding:5px;color:var(--cyan);font-family:var(--font-display);font-size:0.52rem;letter-spacing:2px;cursor:pointer;margin-bottom:5px}
.comms-compose-btn:hover{background:rgba(0,212,255,0.15)}
.comms-send-btn{flex:1;background:rgba(0,212,255,0.1);border:1px solid rgba(0,212,255,0.4);border-radius:3px;padding:3px 6px;color:var(--cyan);font-family:var(--font-display);font-size:0.5rem;letter-spacing:1px;cursor:pointer}
.comms-send-btn:hover{background:rgba(0,212,255,0.2)}
.comms-send-btn:disabled{opacity:0.4;cursor:not-allowed}
.comms-outbox-card{background:rgba(0,212,255,0.03);border:1px solid rgba(0,212,255,0.1);border-radius:3px;padding:6px 9px;margin-bottom:5px}
.comms-outbox-to{font-family:var(--font-mono);font-size:0.52rem;color:var(--text-dim)}
.comms-outbox-subj{font-family:var(--font-display);font-size:0.55rem;color:var(--cyan);margin:2px 0}
.comms-outbox-status{font-family:var(--font-mono);font-size:0.48rem;padding:1px 4px;border-radius:2px}
.comms-outbox-status.sent{color:#00ff88;border:1px solid rgba(0,255,136,0.3)}
.comms-outbox-status.failed{color:#ff2244;border:1px solid rgba(255,34,68,0.3)}
.comms-outbox-status.queued{color:#ffd700;border:1px solid rgba(255,215,0,0.3)}
/* compose modal */
.comms-compose-modal{position:fixed;inset:0;background:rgba(0,0,0,0.7);display:flex;align-items:center;justify-content:center;z-index:9000}
.comms-compose-inner{background:#0a0e14;border:1px solid var(--cyan);border-radius:6px;padding:16px;width:min(90vw,480px);max-height:80vh;overflow-y:auto}
.comms-compose-title{font-family:var(--font-display);font-size:0.65rem;letter-spacing:2px;color:var(--cyan);margin-bottom:12px}
.comms-compose-field{width:100%;background:rgba(0,212,255,0.04);border:1px solid var(--panel-border);border-radius:3px;padding:6px 8px;color:var(--text);font-family:var(--font-mono);font-size:0.6rem;box-sizing:border-box;margin-bottom:7px}
.comms-compose-field:focus{outline:none;border-color:var(--cyan)}
.comms-compose-actions{display:flex;gap:6px;margin-top:8px}
/* ── INTEL PROTOCOL — research result cards ──────────────────────── */
.intel-card{background:rgba(0,212,255,0.04);border:1px solid var(--panel-border);border-radius:var(--r);margin-bottom:8px;overflow:hidden}
.intel-card-head{display:flex;align-items:center;gap:8px;padding:8px 10px;cursor:pointer;user-select:none}
@@ -1175,6 +1195,8 @@ body::after{
</div>
<div id="tab-comms" class="tab-pane" style="overflow-y:auto;flex:1;padding:4px 0">
<div id="comms-list"><div class="loading-shimmer"></div></div>
<div class="comms-section-label" style="margin:12px 4px 6px">◈ OUTBOX — SENT &amp; QUEUED</div>
<div id="comms-outbox"><div class="loading-shimmer"></div></div>
</div>
<div id="tab-guardian" class="tab-pane" style="overflow-y:auto;flex:1;padding:4px 0">
<div id="guardian-list"><div class="loading-shimmer"></div></div>
@@ -3097,7 +3119,7 @@ function switchTab(name) {
if (name === 'news') loadNews();
if (name === 'agents') loadAgents();
if (name === 'intel') loadIntel();
if (name === 'comms') loadComms();
if (name === 'comms') { loadComms(); loadCommsOutbox(); }
if (name === 'guardian') loadGuardian();
if (name === 'alerts') loadAlerts();
}
@@ -3721,8 +3743,8 @@ function intelPrompt() {
// Called when arc_job is returned from chat response
function onArcJobStarted(jobId, jobType) {
if (jobType === 'arc:gmail_triage') {
// Route to COMMS tab
const commsTypes = ['arc:gmail_triage', 'arc:send_email', 'arc:compose_email', 'arc:schedule_event', 'arc:meeting_prep'];
if (commsTypes.includes(jobType)) {
const commsBtn = document.getElementById('tab-btn-comms');
if (commsBtn) commsBtn.click();
startCommsPolling();
@@ -3757,7 +3779,10 @@ async function loadComms() {
const catOrder = {urgent:0, action:1, reply:2, meeting:3, info:4, promo:5, spam:6};
const catIcons = {urgent:'🔴', action:'⚡', reply:'◈', meeting:'📅', info:'', promo:'📢', spam:'🗑'};
let html = '<button class="comms-triage-btn" onclick="commsTriageNow()">◈ TRIAGE INBOX NOW</button>';
let html = '<div style="display:flex;gap:5px;margin-bottom:5px">';
html += '<button class="comms-triage-btn" style="flex:3;margin-bottom:0" onclick="commsTriageNow()">◈ TRIAGE INBOX</button>';
html += '<button class="comms-compose-btn" style="flex:2;margin-bottom:0" onclick="commsShowCompose()">+ COMPOSE</button>';
html += '</div>';
html += '<div class="comms-header-bar">';
for (const [f, label] of [['priority','PRIORITY'],['urgent','URGENT'],['action','ACTION'],['all','ALL']]) {
html += `<div class="comms-filter-btn${_commsFilter===f?' active':''}" onclick="commsSetFilter('${f}')">${label}</div>`;
@@ -3780,9 +3805,10 @@ async function loadComms() {
<div class="comms-card-body">
<div class="comms-card-from">FROM: ${escHtml((item.from_name||item.from_email||'').substring(0,50))}</div>
<div class="comms-card-summary">${escHtml(item.summary||'')}</div>
${hasReply ? `<div class="comms-draft-label">DRAFT REPLY</div><div class="comms-draft">${escHtml(item.draft_reply)}</div>` : ''}
${hasReply ? `<div class="comms-draft-label">DRAFT REPLY</div><div class="comms-draft" id="comms-draft-${item.id}">${escHtml(item.draft_reply)}</div>` : ''}
<div style="display:flex;gap:5px;margin-top:8px">
${hasReply ? `<button onclick="commsCopyReply(${item.id})" style="flex:1;background:rgba(0,212,255,0.08);border:1px solid var(--panel-border);border-radius:3px;padding:3px 6px;color:var(--cyan);font-family:var(--font-display);font-size:0.5rem;letter-spacing:1px;cursor:pointer">COPY REPLY</button>` : ''}
${hasReply ? `<button class="comms-send-btn" id="comms-send-${item.id}" onclick="commsSendReply(${item.id})">◈ SEND REPLY</button>` : ''}
${hasReply ? `<button onclick="commsCopyReply(${item.id})" style="flex:1;background:rgba(0,212,255,0.05);border:1px solid var(--panel-border);border-radius:3px;padding:3px 6px;color:var(--text-dim);font-family:var(--font-display);font-size:0.5rem;letter-spacing:1px;cursor:pointer">COPY</button>` : ''}
<button onclick="commsDismiss(${item.id})" style="flex:1;background:rgba(255,255,255,0.03);border:1px solid var(--panel-border);border-radius:3px;padding:3px 6px;color:var(--text-dim);font-family:var(--font-display);font-size:0.5rem;letter-spacing:1px;cursor:pointer">DISMISS</button>
</div>
</div>
@@ -3815,11 +3841,173 @@ async function commsDismiss(id) {
}
async function commsCopyReply(id) {
const draft = document.querySelector(`#comms-card-${id} .comms-draft`);
const draft = document.querySelector(`#comms-draft-${id}`);
if (draft) {
navigator.clipboard.writeText(draft.innerText).catch(() => {});
const btn = document.querySelector(`#comms-card-${id} [onclick*="commsCopyReply"]`);
if (btn) { btn.textContent = 'COPIED!'; setTimeout(() => btn.textContent = 'COPY REPLY', 1500); }
if (btn) { btn.textContent = 'COPIED!'; setTimeout(() => btn.textContent = 'COPY', 1500); }
}
}
async function commsSendReply(id) {
const btn = document.getElementById('comms-send-' + id);
const draft = document.getElementById('comms-draft-' + id);
if (!btn || !draft) return;
btn.disabled = true;
btn.textContent = '◈ SENDING…';
try {
const res = await api('arc', 'POST', {
action: 'job_create',
type: 'send_email',
payload: { triage_id: id, content: draft.innerText },
priority: 8,
});
if (res.job_id) {
btn.textContent = '◈ SENT ✓';
btn.style.color = '#00ff88';
setTimeout(() => loadComms(), 3000);
loadCommsOutbox();
} else {
btn.disabled = false;
btn.textContent = '◈ SEND REPLY';
alert('Send failed: ' + (res.error || 'unknown error'));
}
} catch(e) {
btn.disabled = false;
btn.textContent = '◈ SEND REPLY';
}
}
function commsShowCompose() {
const existing = document.getElementById('comms-compose-modal');
if (existing) existing.remove();
const modal = document.createElement('div');
modal.className = 'comms-compose-modal';
modal.id = 'comms-compose-modal';
modal.innerHTML = `
<div class="comms-compose-inner">
<div class="comms-compose-title">◈ COMPOSE MESSAGE</div>
<select id="cc-account" class="comms-compose-field" style="cursor:pointer">
<option value="gmail">Gmail</option>
<option value="icloud">iCloud</option>
</select>
<input id="cc-to" class="comms-compose-field" placeholder="To: email address" type="email">
<input id="cc-subject" class="comms-compose-field" placeholder="Subject">
<textarea id="cc-instructions" class="comms-compose-field" rows="4" placeholder="Describe what to say (AI will draft it)"></textarea>
<div id="cc-preview" style="display:none">
<div class="comms-draft-label">DRAFTED MESSAGE</div>
<div class="comms-draft" id="cc-preview-body" style="max-height:200px"></div>
</div>
<div class="comms-compose-actions">
<button class="comms-send-btn" style="flex:1" onclick="commsComposeDraft()">◈ DRAFT</button>
<button class="comms-send-btn" style="flex:1;display:none" id="cc-send-btn" onclick="commsComposeAndSend()">◈ SEND NOW</button>
<button onclick="document.getElementById('comms-compose-modal').remove()" style="flex:1;background:rgba(255,255,255,0.03);border:1px solid var(--panel-border);border-radius:3px;padding:3px 6px;color:var(--text-dim);font-family:var(--font-display);font-size:0.5rem;letter-spacing:1px;cursor:pointer">CANCEL</button>
</div>
<div id="cc-status" style="font-family:var(--font-mono);font-size:0.55rem;color:var(--cyan);margin-top:6px;min-height:14px"></div>
</div>`;
document.body.appendChild(modal);
modal.addEventListener('click', e => { if (e.target === modal) modal.remove(); });
}
let _ccDraftedBody = '';
async function commsComposeDraft() {
const to = document.getElementById('cc-to')?.value.trim();
const subject = document.getElementById('cc-subject')?.value.trim();
const instructions = document.getElementById('cc-instructions')?.value.trim();
const account = document.getElementById('cc-account')?.value;
const status = document.getElementById('cc-status');
if (!to || !instructions) { if (status) status.textContent = 'Please fill in To and message description.'; return; }
if (status) status.textContent = '◈ DRAFTING…';
try {
const res = await api('arc', 'POST', {
action: 'job_create', type: 'compose_email',
payload: { recipient: to, subject, instructions, account, auto_send: false },
priority: 7,
});
if (!res.job_id) throw new Error(res.error || 'No job');
// poll for result
let attempts = 0;
const poll = async () => {
const job = await api('arc?action=job_get&id=' + res.job_id);
if (job.status === 'done' && job.result?.drafted_body) {
_ccDraftedBody = job.result.drafted_body;
document.getElementById('cc-preview-body').textContent = _ccDraftedBody;
document.getElementById('cc-preview').style.display = 'block';
document.getElementById('cc-send-btn').style.display = '';
if (status) status.textContent = '◈ DRAFT READY — Review and send';
} else if (job.status === 'failed') {
if (status) status.textContent = '✗ Draft failed: ' + (job.error || 'unknown');
} else if (attempts++ < 20) {
setTimeout(poll, 1500);
} else {
if (status) status.textContent = '◈ Job still running — check INTEL tab';
}
};
setTimeout(poll, 1500);
} catch(e) {
if (status) status.textContent = '✗ Error: ' + e.message;
}
}
async function commsComposeAndSend() {
const to = document.getElementById('cc-to')?.value.trim();
const subject = document.getElementById('cc-subject')?.value.trim();
const account = document.getElementById('cc-account')?.value;
const status = document.getElementById('cc-status');
const btn = document.getElementById('cc-send-btn');
if (!to || !_ccDraftedBody) return;
if (btn) { btn.disabled = true; btn.textContent = '◈ SENDING…'; }
if (status) status.textContent = '◈ TRANSMITTING…';
try {
const res = await api('arc', 'POST', {
action: 'job_create', type: 'send_email',
payload: { to_email: to, subject, body: _ccDraftedBody, account },
priority: 9,
});
if (res.job_id) {
if (status) status.textContent = '◈ SENT ✓ (Job #' + res.job_id + ')';
setTimeout(() => {
document.getElementById('comms-compose-modal')?.remove();
loadCommsOutbox();
}, 1500);
} else {
if (btn) { btn.disabled = false; btn.textContent = '◈ SEND NOW'; }
if (status) status.textContent = '✗ Send failed: ' + (res.error || 'unknown');
}
} catch(e) {
if (btn) { btn.disabled = false; btn.textContent = '◈ SEND NOW'; }
if (status) status.textContent = '✗ Error: ' + e.message;
}
}
async function loadCommsOutbox() {
const el = document.getElementById('comms-outbox');
if (!el) return;
try {
const data = await api('arc?action=comms_sent&limit=20');
const sent = Array.isArray(data) ? data : (data.sent || []);
if (!sent.length) {
el.innerHTML = '<div class="comms-empty" style="padding:10px">No sent messages yet</div>';
return;
}
const statusColor = {sent:'#00ff88', failed:'#ff2244', queued:'#ffd700'};
let html = '';
for (const m of sent) {
const ts = m.sent_at ? new Date(m.sent_at + 'Z').toLocaleString() : '—';
const sc = m.status || 'sent';
html += `<div class="comms-outbox-card">
<div style="display:flex;justify-content:space-between;align-items:center">
<div class="comms-outbox-to">TO: ${escHtml((m.to_email||'').substring(0,40))}</div>
<span class="comms-outbox-status ${sc}">${sc.toUpperCase()}</span>
</div>
<div class="comms-outbox-subj">${escHtml((m.subject||'(no subject)').substring(0,60))}</div>
<div style="font-family:var(--font-mono);font-size:0.48rem;color:var(--text-dim)">${ts} · ${m.account||'gmail'}</div>
</div>`;
}
el.innerHTML = html;
} catch(e) {
el.innerHTML = '<div class="comms-empty" style="padding:10px">OUTBOX OFFLINE</div>';
}
}
@@ -3831,7 +4019,7 @@ function commsTriageNow() {
function startCommsPolling() {
if (_commsPollTimer) return;
_commsPollTimer = setInterval(() => {
if (document.getElementById('tab-comms')?.classList.contains('active')) loadComms();
if (document.getElementById('tab-comms')?.classList.contains('active')) { loadComms(); loadCommsOutbox(); }
}, 8000);
}