relay-companion 0.1.8 → 0.1.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/overlay/inbox.html +241 -8
- package/overlay/main.cjs +48 -4
- package/overlay/preload.cjs +3 -1
- package/package.json +1 -1
- package/src/materializer.js +7 -2
- package/src/mcp.js +15 -1
- package/src/notifications.js +43 -0
- package/src/task-daemon.js +7 -0
package/overlay/inbox.html
CHANGED
|
@@ -159,7 +159,12 @@
|
|
|
159
159
|
transition:opacity .3s var(--settle) .06s;
|
|
160
160
|
}
|
|
161
161
|
.card.collapsed .scroll { opacity:0; pointer-events:none; transition:opacity .14s var(--settle); }
|
|
162
|
-
.card.peek .scroll {
|
|
162
|
+
.card.peek .scroll {
|
|
163
|
+
opacity:1; pointer-events:auto;
|
|
164
|
+
/* safety net: anything beyond the sized first row fades instead of hard-clipping */
|
|
165
|
+
-webkit-mask-image:linear-gradient(to bottom, #000 calc(100% - 12px), transparent);
|
|
166
|
+
mask-image:linear-gradient(to bottom, #000 calc(100% - 12px), transparent);
|
|
167
|
+
}
|
|
163
168
|
.scroll::-webkit-scrollbar { width:10px; }
|
|
164
169
|
.scroll::-webkit-scrollbar-thumb { background:rgba(31,26,23,.12); border-radius:10px; border:3px solid var(--bg); background-clip:padding-box; }
|
|
165
170
|
.scroll::-webkit-scrollbar-thumb:hover { background:rgba(31,26,23,.2); background-clip:padding-box; }
|
|
@@ -299,6 +304,40 @@
|
|
|
299
304
|
.task-meta .sep { color:var(--muted-3); }
|
|
300
305
|
.task-approve { color:var(--accent); font-weight:600; }
|
|
301
306
|
|
|
307
|
+
/* ---- task detail (in-pill) ---- */
|
|
308
|
+
.td-head { display:flex; align-items:center; gap:8px; padding:10px 16px 2px; }
|
|
309
|
+
.td-back {
|
|
310
|
+
appearance:none; border:0; background:transparent; cursor:pointer; padding:2px 6px 2px 0;
|
|
311
|
+
font-family:var(--sans); font-size:12.5px; font-weight:500; color:var(--muted-2);
|
|
312
|
+
transition:color .15s var(--settle);
|
|
313
|
+
}
|
|
314
|
+
.td-back:hover { color:var(--ink); }
|
|
315
|
+
.td-title { font-family:var(--serif); font-size:16px; font-weight:500; color:var(--ink); padding:2px 16px 0; line-height:1.3; }
|
|
316
|
+
.td-meta { display:flex; align-items:center; gap:7px; padding:6px 16px 10px; font-size:11.5px; color:var(--muted-2); flex-wrap:wrap; }
|
|
317
|
+
.td-state { font-weight:500; }
|
|
318
|
+
.td-state.s-active { color:var(--accent); }
|
|
319
|
+
.td-section {
|
|
320
|
+
font-family:var(--serif); font-size:12.5px; font-weight:500; color:var(--muted-2);
|
|
321
|
+
padding:12px 16px 5px; border-top:1px solid var(--hair);
|
|
322
|
+
}
|
|
323
|
+
.td-block { padding:0 16px 10px; font-size:12px; line-height:1.5; color:var(--ink-2); }
|
|
324
|
+
.td-block .quiet { color:var(--muted-2); }
|
|
325
|
+
.td-msg { padding:8px 16px 10px; border-bottom:1px solid var(--hair-2); }
|
|
326
|
+
.td-msg:last-child { border-bottom:0; }
|
|
327
|
+
.td-msg-top { display:flex; align-items:baseline; gap:7px; }
|
|
328
|
+
.td-msg-sender { font-size:11.5px; font-weight:600; color:var(--ink); }
|
|
329
|
+
.td-msg-kind { font-size:10.5px; color:var(--muted-3); }
|
|
330
|
+
.td-msg-time { margin-left:auto; font-family:var(--mono); font-size:10.5px; color:var(--muted-2); }
|
|
331
|
+
.td-msg-body { font-size:12px; line-height:1.5; color:var(--muted); margin-top:3px; white-space:pre-line; }
|
|
332
|
+
.td-part { display:flex; align-items:baseline; gap:8px; padding:5px 16px; font-size:12px; }
|
|
333
|
+
.td-part-name { color:var(--ink); font-weight:500; }
|
|
334
|
+
.td-part-state { margin-left:auto; font-size:11px; color:var(--muted-2); }
|
|
335
|
+
.td-result { padding:6px 16px 10px; }
|
|
336
|
+
.td-result-title { font-size:12.5px; font-weight:600; color:var(--ink); }
|
|
337
|
+
.td-result-body { font-size:12px; line-height:1.5; color:var(--muted); margin-top:3px; white-space:pre-line; }
|
|
338
|
+
.td-actions { display:flex; gap:16px; padding:10px 16px 14px; align-items:center; border-top:1px solid var(--hair); }
|
|
339
|
+
.td-err { color:#b4332a; font-size:11.5px; padding:8px 16px; }
|
|
340
|
+
|
|
302
341
|
/* ---- empty states ---- */
|
|
303
342
|
.empty {
|
|
304
343
|
height:100%; display:flex; flex-direction:column; align-items:center; justify-content:center;
|
|
@@ -477,6 +516,9 @@
|
|
|
477
516
|
</div>
|
|
478
517
|
</section>
|
|
479
518
|
|
|
519
|
+
<!-- Task detail view (in-pill mirror of the web task page, live-polled) -->
|
|
520
|
+
<section class="view hidden" id="taskDetailView"></section>
|
|
521
|
+
|
|
480
522
|
<!-- Contacts view -->
|
|
481
523
|
<section class="view hidden" id="contactsView">
|
|
482
524
|
<div class="cv-head">
|
|
@@ -524,6 +566,7 @@
|
|
|
524
566
|
const tasksListEl = document.getElementById("tasksList");
|
|
525
567
|
const tasksEmptyEl = document.getElementById("tasksEmpty");
|
|
526
568
|
const tasksBadgeEl = document.getElementById("tasksBadge");
|
|
569
|
+
const taskDetailViewEl = document.getElementById("taskDetailView");
|
|
527
570
|
const contactsViewEl = document.getElementById("contactsView");
|
|
528
571
|
const cvAddEl = document.getElementById("cvAdd");
|
|
529
572
|
const cvListEl = document.getElementById("cvList");
|
|
@@ -664,7 +707,6 @@
|
|
|
664
707
|
clearPeek();
|
|
665
708
|
collapsed = false;
|
|
666
709
|
cardEl.classList.remove("collapsed", "offstage", "bye");
|
|
667
|
-
snapSize(PEEK.w, PEEK.h);
|
|
668
710
|
ghost = true; peeking = true;
|
|
669
711
|
cardEl.classList.add("peek", "ghost");
|
|
670
712
|
const kind = row && row.relayNotificationKind;
|
|
@@ -673,6 +715,8 @@
|
|
|
673
715
|
applyView();
|
|
674
716
|
renderAll();
|
|
675
717
|
scrollEl.scrollTop = 0;
|
|
718
|
+
// Size to the rendered first row (no pill morph — the banner just appears at size).
|
|
719
|
+
snapSize(PEEK.w, peekHeight());
|
|
676
720
|
playTink();
|
|
677
721
|
// Leave after ~7s — but never rug-pull someone mid-reply or with the pointer on
|
|
678
722
|
// the banner; check again shortly instead (macOS banners hold while hovered too).
|
|
@@ -705,6 +749,13 @@
|
|
|
705
749
|
renderAll();
|
|
706
750
|
playTink();
|
|
707
751
|
}
|
|
752
|
+
// The banner must show its WHOLE first row — a clipped preview reads as broken.
|
|
753
|
+
// Measure after render; QR question rows are tall, so allow room before capping.
|
|
754
|
+
function peekHeight() {
|
|
755
|
+
const first = scrollEl.querySelector(".row, .task");
|
|
756
|
+
const rowH = first ? first.offsetHeight : 74;
|
|
757
|
+
return Math.min(44 + rowH + 6, 340);
|
|
758
|
+
}
|
|
708
759
|
// A new relay arrived. If folded, peek open like an iOS banner (~7s) then fold back.
|
|
709
760
|
// The peek routes to the tab the item lives in: task_request / share_approval -> Tasks
|
|
710
761
|
// (the clean split), everything else -> Relays.
|
|
@@ -721,7 +772,7 @@
|
|
|
721
772
|
applyView();
|
|
722
773
|
renderAll();
|
|
723
774
|
scrollEl.scrollTop = 0;
|
|
724
|
-
springTo(PEEK.w,
|
|
775
|
+
springTo(PEEK.w, peekHeight());
|
|
725
776
|
playTink();
|
|
726
777
|
peekTimer = setTimeout(() => {
|
|
727
778
|
peekTimer = null; peeking = false;
|
|
@@ -1058,6 +1109,14 @@
|
|
|
1058
1109
|
function relayActionsHtml(r) {
|
|
1059
1110
|
const k = r.relayNotificationKind;
|
|
1060
1111
|
if (k === "human_question") {
|
|
1112
|
+
// Once the question is answered (here, on the web, or inside a host session),
|
|
1113
|
+
// show what was sent instead of a live reply box that could double-answer.
|
|
1114
|
+
if (r.humanAnswered) {
|
|
1115
|
+
const sent = r.humanAnswerMarkdown ? firstLine(r.humanAnswerMarkdown, 140) : "";
|
|
1116
|
+
return sent
|
|
1117
|
+
? `<div class="row-note">You replied: “${esc(sent)}” — the agent run has resumed.</div>`
|
|
1118
|
+
: `<div class="row-note">Answered — the agent run has resumed.</div>`;
|
|
1119
|
+
}
|
|
1061
1120
|
// relay_answer_human_question requires BOTH taskId and messageId (mcp.js). Only render the
|
|
1062
1121
|
// inline reply box when both are present; otherwise the box would post a dead answer.
|
|
1063
1122
|
if (r.quickReply && r.taskId && r.messageId) {
|
|
@@ -1156,7 +1215,7 @@
|
|
|
1156
1215
|
const text = ta ? ta.value.trim() : "";
|
|
1157
1216
|
if (!text) { showRowError(id, "Write an answer first."); return; }
|
|
1158
1217
|
runRowAction(id, btn, async () => {
|
|
1159
|
-
const res = await window.relay.answer(r.taskId, r.messageId, text);
|
|
1218
|
+
const res = await window.relay.answer(r.taskId, r.messageId, text, r.id);
|
|
1160
1219
|
if (res && res.ok) { if (ta) ta.value = ""; }
|
|
1161
1220
|
return res;
|
|
1162
1221
|
});
|
|
@@ -1214,6 +1273,168 @@
|
|
|
1214
1273
|
}
|
|
1215
1274
|
}
|
|
1216
1275
|
|
|
1276
|
+
// ---------- in-pill task detail (live) ----------
|
|
1277
|
+
// Clicking a task row opens a live detail panel INSIDE the pill (the same data the
|
|
1278
|
+
// web task page renders), polled every 5s while open. "Open in Claude/Codex" keeps
|
|
1279
|
+
// the old materialize-into-host behavior as an explicit action.
|
|
1280
|
+
let taskDetailId = null;
|
|
1281
|
+
let taskDetailData = null;
|
|
1282
|
+
let taskDetailError = "";
|
|
1283
|
+
let taskDetailTimer = null;
|
|
1284
|
+
function showTaskDetail(taskId) {
|
|
1285
|
+
taskDetailId = taskId;
|
|
1286
|
+
taskDetailData = (payload.tasks || []).find((t) => t.id === taskId) || null; // summary while loading
|
|
1287
|
+
taskDetailError = "";
|
|
1288
|
+
activeView = "taskDetail";
|
|
1289
|
+
syncTabs();
|
|
1290
|
+
applyView();
|
|
1291
|
+
renderTaskDetail();
|
|
1292
|
+
refreshTaskDetail();
|
|
1293
|
+
if (taskDetailTimer) clearInterval(taskDetailTimer);
|
|
1294
|
+
taskDetailTimer = setInterval(() => {
|
|
1295
|
+
if (activeView === "taskDetail" && !collapsed) refreshTaskDetail();
|
|
1296
|
+
}, 5000);
|
|
1297
|
+
}
|
|
1298
|
+
function closeTaskDetail() {
|
|
1299
|
+
if (taskDetailTimer) { clearInterval(taskDetailTimer); taskDetailTimer = null; }
|
|
1300
|
+
taskDetailId = null;
|
|
1301
|
+
taskDetailData = null;
|
|
1302
|
+
activeView = "tasks";
|
|
1303
|
+
syncTabs();
|
|
1304
|
+
applyView();
|
|
1305
|
+
renderAll();
|
|
1306
|
+
}
|
|
1307
|
+
async function refreshTaskDetail() {
|
|
1308
|
+
if (!taskDetailId) return;
|
|
1309
|
+
const id = taskDetailId;
|
|
1310
|
+
try {
|
|
1311
|
+
const res = await window.relay.taskStatus(id);
|
|
1312
|
+
if (id !== taskDetailId) return; // navigated away while fetching
|
|
1313
|
+
if (res && res.ok && res.task) {
|
|
1314
|
+
taskDetailData = res.task;
|
|
1315
|
+
taskDetailError = "";
|
|
1316
|
+
} else {
|
|
1317
|
+
taskDetailError = (res && res.error) || "Could not load the task.";
|
|
1318
|
+
}
|
|
1319
|
+
} catch (err) {
|
|
1320
|
+
if (id !== taskDetailId) return;
|
|
1321
|
+
taskDetailError = (err && err.message) || "Could not load the task.";
|
|
1322
|
+
}
|
|
1323
|
+
if (activeView === "taskDetail") renderTaskDetail();
|
|
1324
|
+
}
|
|
1325
|
+
function tdChip(state) {
|
|
1326
|
+
const cls = state === "active" || state === "completing" ? "s-active" : "";
|
|
1327
|
+
return `<span class="td-state ${cls}">${esc(taskStateLabel(state))}</span>`;
|
|
1328
|
+
}
|
|
1329
|
+
function renderTaskDetail() {
|
|
1330
|
+
if (!taskDetailViewEl) return;
|
|
1331
|
+
const t = taskDetailData;
|
|
1332
|
+
if (!t) {
|
|
1333
|
+
taskDetailViewEl.innerHTML = `
|
|
1334
|
+
<div class="td-head"><button class="td-back" type="button" data-td-back="1">‹ Tasks</button></div>
|
|
1335
|
+
<div class="td-block quiet">${taskDetailError ? esc(taskDetailError) : "Loading task…"}</div>`;
|
|
1336
|
+
wireTaskDetail();
|
|
1337
|
+
return;
|
|
1338
|
+
}
|
|
1339
|
+
const participants = Array.isArray(t.participants) ? t.participants : [];
|
|
1340
|
+
const messages = Array.isArray(t.recentMessages) ? t.recentMessages.slice(-5) : [];
|
|
1341
|
+
const results = Array.isArray(t.results) ? t.results : [];
|
|
1342
|
+
const approvals = Array.isArray(t.pendingApprovals) ? t.pendingApprovals : [];
|
|
1343
|
+
const objective = String(t.objective || "").trim();
|
|
1344
|
+
|
|
1345
|
+
let html = `
|
|
1346
|
+
<div class="td-head">
|
|
1347
|
+
<button class="td-back" type="button" data-td-back="1">‹ Tasks</button>
|
|
1348
|
+
<span class="task-time">${esc(timeAgo(t.updatedAt))}</span>
|
|
1349
|
+
</div>
|
|
1350
|
+
<div class="td-title">${esc(t.title || "Untitled task")}</div>
|
|
1351
|
+
<div class="td-meta">${tdChip(t.state)}<span class="sep">·</span><span>${esc(participantSummary(t))}</span></div>`;
|
|
1352
|
+
if (objective) html += `<div class="td-block quiet">${esc(firstLine(objective, 160))}</div>`;
|
|
1353
|
+
if (taskDetailError) html += `<div class="td-err">${esc(taskDetailError)}</div>`;
|
|
1354
|
+
|
|
1355
|
+
if (approvals.length) {
|
|
1356
|
+
html += `<div class="td-section">Waiting on you</div>`;
|
|
1357
|
+
for (const a of approvals) {
|
|
1358
|
+
const preview = firstLine(a.payloadMarkdown || a.bodyMarkdown || "an outbound share", 110);
|
|
1359
|
+
html += `
|
|
1360
|
+
<div class="td-block">
|
|
1361
|
+
${esc(preview)}
|
|
1362
|
+
<div class="rk-actions" style="margin-top:8px">
|
|
1363
|
+
<button class="act-btn accept" type="button" data-td-approve="${esc(a.id)}">Approve</button>
|
|
1364
|
+
<button class="act-btn decline" type="button" data-td-decline="${esc(a.id)}">Decline</button>
|
|
1365
|
+
</div>
|
|
1366
|
+
</div>`;
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
if (results.length) {
|
|
1370
|
+
html += `<div class="td-section">Results visible to you</div>`;
|
|
1371
|
+
for (const r of results) {
|
|
1372
|
+
html += `
|
|
1373
|
+
<div class="td-result">
|
|
1374
|
+
<div class="td-result-title">${esc(r.title || "Result")}</div>
|
|
1375
|
+
<div class="td-result-body">${esc(firstLine(r.bodyMarkdown || "", 400))}</div>
|
|
1376
|
+
</div>`;
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
if (messages.length) {
|
|
1380
|
+
html += `<div class="td-section">Messages</div>`;
|
|
1381
|
+
for (const m of messages) {
|
|
1382
|
+
html += `
|
|
1383
|
+
<div class="td-msg">
|
|
1384
|
+
<div class="td-msg-top">
|
|
1385
|
+
<span class="td-msg-sender">${esc(m.senderLabel || "Agent")}</span>
|
|
1386
|
+
<span class="td-msg-kind">${esc(String(m.kind || "").replaceAll("_", " "))}</span>
|
|
1387
|
+
<span class="td-msg-time">${esc(timeAgo(m.createdAt))}</span>
|
|
1388
|
+
</div>
|
|
1389
|
+
<div class="td-msg-body">${esc(firstLine(m.bodyMarkdown || "", 220))}</div>
|
|
1390
|
+
</div>`;
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
if (participants.length) {
|
|
1394
|
+
html += `<div class="td-section">Participants</div>`;
|
|
1395
|
+
for (const p of participants) {
|
|
1396
|
+
html += `
|
|
1397
|
+
<div class="td-part">
|
|
1398
|
+
<span class="td-part-name">${esc(p.displayName || p.email || "Participant")}</span>
|
|
1399
|
+
<span class="td-part-state">${esc(String(p.invitationState || "").replaceAll("_", " "))}</span>
|
|
1400
|
+
</div>`;
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
html += `
|
|
1404
|
+
<div class="td-actions" data-stop="1">
|
|
1405
|
+
<button class="act-btn accept" type="button" data-td-open="${esc(t.id)}">Open in Claude or Codex</button>
|
|
1406
|
+
</div>`;
|
|
1407
|
+
taskDetailViewEl.innerHTML = html;
|
|
1408
|
+
wireTaskDetail();
|
|
1409
|
+
}
|
|
1410
|
+
async function runTdMutation(btn, fn) {
|
|
1411
|
+
if (!btn || btn.disabled) return;
|
|
1412
|
+
btn.disabled = true;
|
|
1413
|
+
try {
|
|
1414
|
+
const res = await fn();
|
|
1415
|
+
if (res && res.ok === false) taskDetailError = res.error || "Could not complete that.";
|
|
1416
|
+
} catch (err) {
|
|
1417
|
+
taskDetailError = (err && err.message) || "Could not complete that.";
|
|
1418
|
+
} finally {
|
|
1419
|
+
btn.disabled = false;
|
|
1420
|
+
refreshTaskDetail();
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
function wireTaskDetail() {
|
|
1424
|
+
for (const btn of taskDetailViewEl.querySelectorAll("[data-td-back]")) {
|
|
1425
|
+
btn.addEventListener("click", closeTaskDetail);
|
|
1426
|
+
}
|
|
1427
|
+
for (const btn of taskDetailViewEl.querySelectorAll("[data-td-open]")) {
|
|
1428
|
+
btn.addEventListener("click", () => window.relay.openTask(btn.getAttribute("data-td-open")));
|
|
1429
|
+
}
|
|
1430
|
+
for (const btn of taskDetailViewEl.querySelectorAll("[data-td-approve]")) {
|
|
1431
|
+
btn.addEventListener("click", () => runTdMutation(btn, () => window.relay.approve(taskDetailId, btn.getAttribute("data-td-approve"))));
|
|
1432
|
+
}
|
|
1433
|
+
for (const btn of taskDetailViewEl.querySelectorAll("[data-td-decline]")) {
|
|
1434
|
+
btn.addEventListener("click", () => runTdMutation(btn, () => window.relay.decline(taskDetailId, btn.getAttribute("data-td-decline"))));
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1217
1438
|
function wireTaskRows() {
|
|
1218
1439
|
// action zones inside a row must not also open the task web detail
|
|
1219
1440
|
for (const z of tasksListEl.querySelectorAll('[data-stop="1"]')) {
|
|
@@ -1235,15 +1456,17 @@
|
|
|
1235
1456
|
runTaskAction(btn, () => window.relay.reject(taskId, pid));
|
|
1236
1457
|
});
|
|
1237
1458
|
}
|
|
1238
|
-
// "N waiting to approve" ->
|
|
1459
|
+
// "N waiting to approve" -> the in-pill detail, where the approval buttons live
|
|
1239
1460
|
for (const el of tasksListEl.querySelectorAll("[data-approve]")) {
|
|
1240
1461
|
el.addEventListener("click", (e) => {
|
|
1241
1462
|
e.stopPropagation();
|
|
1242
|
-
|
|
1463
|
+
showTaskDetail(el.getAttribute("data-approve"));
|
|
1243
1464
|
});
|
|
1244
1465
|
}
|
|
1466
|
+
// Row click opens the live in-pill detail; opening in Claude/Codex is an
|
|
1467
|
+
// explicit action inside it.
|
|
1245
1468
|
for (const el of tasksListEl.querySelectorAll(".task")) {
|
|
1246
|
-
el.addEventListener("click", () =>
|
|
1469
|
+
el.addEventListener("click", () => showTaskDetail(el.getAttribute("data-task")));
|
|
1247
1470
|
}
|
|
1248
1471
|
}
|
|
1249
1472
|
function taskRowHtml(t) {
|
|
@@ -1493,12 +1716,19 @@
|
|
|
1493
1716
|
|
|
1494
1717
|
// ---------- views / tabs ----------
|
|
1495
1718
|
function syncTabs() {
|
|
1496
|
-
|
|
1719
|
+
// The in-pill task detail lives under the Tasks tab.
|
|
1720
|
+
const tabView = activeView === "taskDetail" ? "tasks" : activeView;
|
|
1721
|
+
for (const tab of tabEls) tab.classList.toggle("active", tab.getAttribute("data-view") === tabView);
|
|
1497
1722
|
}
|
|
1498
1723
|
function applyView() {
|
|
1499
1724
|
relaysViewEl.classList.toggle("hidden", activeView !== "relays");
|
|
1500
1725
|
tasksViewEl.classList.toggle("hidden", activeView !== "tasks");
|
|
1726
|
+
taskDetailViewEl.classList.toggle("hidden", activeView !== "taskDetail");
|
|
1501
1727
|
contactsViewEl.classList.toggle("hidden", activeView !== "contacts");
|
|
1728
|
+
if (activeView !== "taskDetail" && taskDetailTimer) {
|
|
1729
|
+
clearInterval(taskDetailTimer);
|
|
1730
|
+
taskDetailTimer = null;
|
|
1731
|
+
}
|
|
1502
1732
|
if (activeView === "contacts") {
|
|
1503
1733
|
loadContacts();
|
|
1504
1734
|
} else {
|
|
@@ -1550,6 +1780,9 @@
|
|
|
1550
1780
|
else notifyArrival(row);
|
|
1551
1781
|
});
|
|
1552
1782
|
if (window.relay.onOpenFull) window.relay.onOpenFull(() => trayOpen());
|
|
1783
|
+
// The OS window was just re-shown and main re-asserted click-through; reset the
|
|
1784
|
+
// local flag so the next mousemove re-runs the interactivity handshake cleanly.
|
|
1785
|
+
if (window.relay.onShown) window.relay.onShown(() => { interactive = false; });
|
|
1553
1786
|
if (window.relay.onOpenDone) window.relay.onOpenDone((id) => stopOpening(id));
|
|
1554
1787
|
window.relay.refresh().then((p) => {
|
|
1555
1788
|
onPayload(p);
|
package/overlay/main.cjs
CHANGED
|
@@ -226,6 +226,8 @@ function readRelays() {
|
|
|
226
226
|
provider: p.provider || null,
|
|
227
227
|
contentPath: p.contentPath || null,
|
|
228
228
|
expectedVersion: p.expectedVersion || null,
|
|
229
|
+
humanAnswered: Boolean(p.humanAnswered),
|
|
230
|
+
humanAnswerMarkdown: p.humanAnswerMarkdown || null,
|
|
229
231
|
}))
|
|
230
232
|
.sort((a, b) => {
|
|
231
233
|
if (a.unread !== b.unread) return a.unread ? -1 : 1; // unread always on top
|
|
@@ -324,7 +326,7 @@ async function pushInbox(force) {
|
|
|
324
326
|
if (dismissed) {
|
|
325
327
|
if (hostRunning) {
|
|
326
328
|
ghostActive = true;
|
|
327
|
-
|
|
329
|
+
showOverlayWindow();
|
|
328
330
|
win.webContents.send("newRelay", fresh[0], { ghost: true });
|
|
329
331
|
}
|
|
330
332
|
} else {
|
|
@@ -454,7 +456,7 @@ async function declineShare(taskId, approvalId) {
|
|
|
454
456
|
});
|
|
455
457
|
}
|
|
456
458
|
|
|
457
|
-
async function answerQuestion(taskId, messageId, text) {
|
|
459
|
+
async function answerQuestion(taskId, messageId, text, packetId) {
|
|
458
460
|
const answerMarkdown = String(text || "").trim();
|
|
459
461
|
if (!taskId || !messageId) return { ok: false, error: "Missing task or message id." };
|
|
460
462
|
if (!answerMarkdown) return { ok: false, error: "Write an answer first." };
|
|
@@ -464,9 +466,27 @@ async function answerQuestion(taskId, messageId, text) {
|
|
|
464
466
|
answerMarkdown,
|
|
465
467
|
idempotencyKey: idempotencyKey("answer"),
|
|
466
468
|
});
|
|
469
|
+
// The reply box must not survive a successful answer: record it on the staged
|
|
470
|
+
// packet immediately (the daemon reconciles the same way on its next poll).
|
|
471
|
+
if (packetId) markQuestionAnswered(packetId, answerMarkdown);
|
|
467
472
|
});
|
|
468
473
|
}
|
|
469
474
|
|
|
475
|
+
function markQuestionAnswered(packetId, answerMarkdown) {
|
|
476
|
+
const store = readStore();
|
|
477
|
+
const row = store.packets && store.packets[packetId];
|
|
478
|
+
if (!row) return;
|
|
479
|
+
store.packets[packetId] = {
|
|
480
|
+
...row,
|
|
481
|
+
state: "read",
|
|
482
|
+
quickReply: false,
|
|
483
|
+
humanAnswered: true,
|
|
484
|
+
humanAnswerMarkdown: answerMarkdown,
|
|
485
|
+
updatedAt: new Date().toISOString(),
|
|
486
|
+
};
|
|
487
|
+
writeStateAtomic(store);
|
|
488
|
+
}
|
|
489
|
+
|
|
470
490
|
function taskVersion(taskId) {
|
|
471
491
|
const task = tasksCache.find((t) => t.id === taskId);
|
|
472
492
|
return task && task.version != null ? task.version : null;
|
|
@@ -686,13 +706,25 @@ function anchorTopRight() {
|
|
|
686
706
|
return { x: wa.x + wa.width - WIN.width - MARGIN, y: wa.y + MARGIN, width: WIN.width, height: WIN.height };
|
|
687
707
|
}
|
|
688
708
|
|
|
709
|
+
// Show the overlay window. Electron's forwarded-mousemove stream (the thing that
|
|
710
|
+
// lets the renderer detect hover on a click-through window) can stall across a
|
|
711
|
+
// hide()/show() cycle, leaving the window permanently click-through. Re-assert
|
|
712
|
+
// setIgnoreMouseEvents on every hidden->shown transition and tell the renderer to
|
|
713
|
+
// reset its interactivity handshake.
|
|
714
|
+
function showOverlayWindow() {
|
|
715
|
+
if (!win || win.isDestroyed() || win.isVisible()) return;
|
|
716
|
+
win.showInactive();
|
|
717
|
+
win.setIgnoreMouseEvents(true, { forward: true });
|
|
718
|
+
win.webContents.send("shown");
|
|
719
|
+
}
|
|
720
|
+
|
|
689
721
|
function maybeShow() {
|
|
690
722
|
if (!win || win.isDestroyed()) return;
|
|
691
723
|
// Dismissed keeps the window hidden except while a ghost notification is up.
|
|
692
724
|
// trayForcedVisible honors an explicit menu-bar click even when no host runs.
|
|
693
725
|
const wanted = (hostRunning || trayForcedVisible) && (!dismissed || ghostActive);
|
|
694
726
|
if (wanted) {
|
|
695
|
-
|
|
727
|
+
showOverlayWindow();
|
|
696
728
|
} else if (win.isVisible()) {
|
|
697
729
|
win.hide();
|
|
698
730
|
}
|
|
@@ -809,11 +841,23 @@ ipcMain.handle("relay:refreshTasks", async () => {
|
|
|
809
841
|
await pushInbox(true);
|
|
810
842
|
return tasksCache;
|
|
811
843
|
});
|
|
844
|
+
// Live task detail for the in-pill task view (GET /v1/tasks/:id, same payload the
|
|
845
|
+
// web detail page renders from). The renderer polls this while the view is open.
|
|
846
|
+
ipcMain.handle("relay:taskStatus", async (_e, taskId) => {
|
|
847
|
+
if (!taskId) return { ok: false, error: "Missing task id." };
|
|
848
|
+
try {
|
|
849
|
+
const client = await relayClient();
|
|
850
|
+
const res = await client.getTask(taskId);
|
|
851
|
+
return { ok: true, task: (res && res.task) || res };
|
|
852
|
+
} catch (error) {
|
|
853
|
+
return { ok: false, error: error && error.message ? error.message : String(error) };
|
|
854
|
+
}
|
|
855
|
+
});
|
|
812
856
|
ipcMain.handle("relay:accept", (_e, taskId, participantId) => acceptTask(taskId, participantId));
|
|
813
857
|
ipcMain.handle("relay:reject", (_e, taskId, participantId) => rejectTask(taskId, participantId));
|
|
814
858
|
ipcMain.handle("relay:approve", (_e, taskId, approvalId) => approveShare(taskId, approvalId));
|
|
815
859
|
ipcMain.handle("relay:decline", (_e, taskId, approvalId) => declineShare(taskId, approvalId));
|
|
816
|
-
ipcMain.handle("relay:answer", (_e, taskId, messageId, text) => answerQuestion(taskId, messageId, text));
|
|
860
|
+
ipcMain.handle("relay:answer", (_e, taskId, messageId, text, packetId) => answerQuestion(taskId, messageId, text, packetId));
|
|
817
861
|
ipcMain.handle("relay:contacts", () => readContacts());
|
|
818
862
|
ipcMain.handle("relay:contactSave", (_e, input) => saveContact(input));
|
|
819
863
|
|
package/overlay/preload.cjs
CHANGED
|
@@ -6,6 +6,7 @@ contextBridge.exposeInMainWorld("relay", {
|
|
|
6
6
|
onNewRelay: (cb) => ipcRenderer.on("newRelay", (_e, relay, opts) => cb(relay, opts || {})),
|
|
7
7
|
onOpenDone: (cb) => ipcRenderer.on("openDone", (_e, id) => cb(id)),
|
|
8
8
|
onOpenFull: (cb) => ipcRenderer.on("openFull", () => cb()), // menu-bar icon clicked
|
|
9
|
+
onShown: (cb) => ipcRenderer.on("shown", () => cb()), // window re-shown; reset hover handshake
|
|
9
10
|
|
|
10
11
|
// pull the full payload on demand
|
|
11
12
|
refresh: () => ipcRenderer.invoke("relay:get"),
|
|
@@ -20,11 +21,12 @@ contextBridge.exposeInMainWorld("relay", {
|
|
|
20
21
|
reject: (taskId, participantId) => ipcRenderer.invoke("relay:reject", taskId, participantId),
|
|
21
22
|
approve: (taskId, approvalId) => ipcRenderer.invoke("relay:approve", taskId, approvalId),
|
|
22
23
|
decline: (taskId, approvalId) => ipcRenderer.invoke("relay:decline", taskId, approvalId),
|
|
23
|
-
answer: (taskId, messageId, text) => ipcRenderer.invoke("relay:answer", taskId, messageId, text),
|
|
24
|
+
answer: (taskId, messageId, text, packetId) => ipcRenderer.invoke("relay:answer", taskId, messageId, text, packetId),
|
|
24
25
|
|
|
25
26
|
// tasks view
|
|
26
27
|
refreshTasks: () => ipcRenderer.invoke("relay:refreshTasks"),
|
|
27
28
|
openTask: (taskId) => ipcRenderer.send("relay:openTask", taskId),
|
|
29
|
+
taskStatus: (taskId) => ipcRenderer.invoke("relay:taskStatus", taskId),
|
|
28
30
|
|
|
29
31
|
// contacts
|
|
30
32
|
contacts: () => ipcRenderer.invoke("relay:contacts"),
|
package/package.json
CHANGED
package/src/materializer.js
CHANGED
|
@@ -251,9 +251,14 @@ async function materializeRowInHost({ id, row, rowState = {}, host = "claude", l
|
|
|
251
251
|
setThreadPinned(threadId, true);
|
|
252
252
|
} catch {}
|
|
253
253
|
await waitForCodexOpenReadiness(threadId);
|
|
254
|
-
|
|
254
|
+
// Hydrate the recents rail over the bridge, but do NOT navigate through it:
|
|
255
|
+
// the bridge's hotkey-window route renders the desktop's in-memory copy of the
|
|
256
|
+
// thread, which is stale/empty for threads whose visible turn was appended to
|
|
257
|
+
// the rollout after thread/start. The codex:// deep link below goes through
|
|
258
|
+
// Codex's own protocol handler, which loads the thread fully from disk.
|
|
259
|
+
const desktopOpenResult = await refreshCodexDesktopForThreads([threadId], { force: true });
|
|
255
260
|
openedInHost = Boolean(desktopOpenResult?.ok);
|
|
256
|
-
skipExternalOpen =
|
|
261
|
+
skipExternalOpen = false;
|
|
257
262
|
url = `codex://threads/${encodeURIComponent(threadId)}`;
|
|
258
263
|
} else {
|
|
259
264
|
let claudeNativeSession = rowState.claudeNativeSession || null;
|
package/src/mcp.js
CHANGED
|
@@ -543,7 +543,21 @@ export async function runMcpServer() {
|
|
|
543
543
|
try {
|
|
544
544
|
return await handleCall(client, req.params.name, req.params.arguments || {});
|
|
545
545
|
} catch (err) {
|
|
546
|
-
|
|
546
|
+
// Surface the API's validation detail so the calling agent can self-correct
|
|
547
|
+
// instead of guessing what "invalid_request" meant.
|
|
548
|
+
let detail = "";
|
|
549
|
+
const issues = err && err.body && Array.isArray(err.body.issues) ? err.body.issues : null;
|
|
550
|
+
if (issues && issues.length) {
|
|
551
|
+
detail =
|
|
552
|
+
"\n" +
|
|
553
|
+
issues
|
|
554
|
+
.slice(0, 8)
|
|
555
|
+
.map((i) => `- ${Array.isArray(i.path) && i.path.length ? i.path.join(".") : "(request)"}: ${i.message}`)
|
|
556
|
+
.join("\n");
|
|
557
|
+
} else if (err && err.body && typeof err.body.instruction === "string") {
|
|
558
|
+
detail = `\n${err.body.instruction}`;
|
|
559
|
+
}
|
|
560
|
+
return { content: [{ type: "text", text: `Relay error: ${err.message}${detail}` }], isError: true };
|
|
547
561
|
}
|
|
548
562
|
});
|
|
549
563
|
|
package/src/notifications.js
CHANGED
|
@@ -287,6 +287,11 @@ function senderForNotification(notification) {
|
|
|
287
287
|
// anyway via RELAY_HIDDEN_KINDS, but the staged senderName must still be coherent.)
|
|
288
288
|
if (notification.kind === "task_request") return "Your agent";
|
|
289
289
|
if (notification.kind === "share_approval") return "Your agent";
|
|
290
|
+
// Task lifecycle notices (completed/cancelled/failed) are the agent run reporting
|
|
291
|
+
// back, not a person — label them as the automation, never "Someone".
|
|
292
|
+
if (notification.kind === "task_completed") return "Your agent";
|
|
293
|
+
if (notification.kind === "task_cancelled") return "Your agent";
|
|
294
|
+
if (notification.kind === "task_failed") return "Your agent";
|
|
290
295
|
if (notification.kind === "connector_reauth") return "Relay connectors";
|
|
291
296
|
return "Someone";
|
|
292
297
|
}
|
|
@@ -453,6 +458,44 @@ export function defaultStageRelayCompanionItem(notification) {
|
|
|
453
458
|
return stageRelayCompanionItem(notification);
|
|
454
459
|
}
|
|
455
460
|
|
|
461
|
+
// A staged human_question keeps quickReply:true forever once the server stops
|
|
462
|
+
// listing it (requiresAnswer flips false after the human answers anywhere — the
|
|
463
|
+
// pill, the web app, or a host session). Reconcile staged questions against the
|
|
464
|
+
// live attention feed so the pill never renders an active reply box for a
|
|
465
|
+
// question that was already answered; carry the recorded answer when available.
|
|
466
|
+
export function reconcileAnsweredQuestions(relays = [], { statePath = companionStatePath() } = {}) {
|
|
467
|
+
const awaiting = new Set();
|
|
468
|
+
const answers = new Map();
|
|
469
|
+
for (const relay of relays) {
|
|
470
|
+
const message = relay?.message;
|
|
471
|
+
if (!message?.id) continue;
|
|
472
|
+
if (relay.requiresAnswer) awaiting.add(message.id);
|
|
473
|
+
const response = message.humanResponse;
|
|
474
|
+
if (response?.status === "answered" && response.answerMarkdown) {
|
|
475
|
+
answers.set(message.id, response.answerMarkdown);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
const state = readCompanionState(statePath);
|
|
479
|
+
const packets = state.packets || {};
|
|
480
|
+
let changed = 0;
|
|
481
|
+
for (const [id, packet] of Object.entries(packets)) {
|
|
482
|
+
if (!packet || packet.relayNotificationKind !== "human_question") continue;
|
|
483
|
+
if (packet.humanAnswered) continue;
|
|
484
|
+
if (!packet.quickReply) continue;
|
|
485
|
+
if (packet.messageId && awaiting.has(packet.messageId)) continue;
|
|
486
|
+
packets[id] = {
|
|
487
|
+
...packet,
|
|
488
|
+
quickReply: false,
|
|
489
|
+
humanAnswered: true,
|
|
490
|
+
humanAnswerMarkdown: packet.humanAnswerMarkdown || answers.get(packet.messageId) || null,
|
|
491
|
+
updatedAt: new Date().toISOString(),
|
|
492
|
+
};
|
|
493
|
+
changed += 1;
|
|
494
|
+
}
|
|
495
|
+
if (changed) writeCompanionState(statePath, state);
|
|
496
|
+
return changed;
|
|
497
|
+
}
|
|
498
|
+
|
|
456
499
|
export function stagePlainRelayItem(
|
|
457
500
|
{ item, packet, attachmentUrls = {} },
|
|
458
501
|
{ statePath = companionStatePath(), forceUnread = false } = {},
|
package/src/task-daemon.js
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
defaultStageRelayCompanionItem,
|
|
14
14
|
freshNotifications,
|
|
15
15
|
markNotificationsProcessed,
|
|
16
|
+
reconcileAnsweredQuestions,
|
|
16
17
|
stagePlainRelayItem as defaultStagePlainRelayItem,
|
|
17
18
|
} from "./notifications.js";
|
|
18
19
|
|
|
@@ -149,6 +150,12 @@ async function pollHumanNotifications({ client, ledger, stageCompanionItem, stag
|
|
|
149
150
|
}
|
|
150
151
|
}
|
|
151
152
|
if (staged.length) markNotificationsProcessed(ledger, staged);
|
|
153
|
+
try {
|
|
154
|
+
const reconciled = reconcileAnsweredQuestions(relays.relays || []);
|
|
155
|
+
if (reconciled) log(`marked ${reconciled} staged question(s) as answered`);
|
|
156
|
+
} catch (err) {
|
|
157
|
+
log(`answered-question reconciliation failed: ${err.message}`);
|
|
158
|
+
}
|
|
152
159
|
return { notifications: staged, ordinaryRelays, events };
|
|
153
160
|
} catch (err) {
|
|
154
161
|
log(`Relay companion attention polling failed: ${err.message}`);
|