Nightly Build / build (push) Successful in 7m42s
An async sub-agent runs in a session of its own, so the rich per-session events
that draw the inline approval card never reach the chat's socket — only the
id-only inbox lifecycle ones do. A task blocked on an approval was therefore
invisible in the conversation that started it, and the only way to unblock it
was to notice the sidebar badge and go to the Inbox.
The chat already shows what it handed off. This asks the same question of the
pending items: `GET /{source}/inbox` joins them against the sessions of this
conversation's running async jobs, so "whose is this" has one answer, in the
same place `/{source}/tasks` answers it for a task. The client is left with a
list to render, not a correlation to guess. The live path adds no event — the
existing `approval_requested` / `clarification_*` broadcasts already reach every
socket of the user, and re-reading the endpoint turns a nudge into something
renderable and survives a reload for free.
The card sits above the task strip rather than in the transcript: the task that
is asking may have been started twenty messages ago, and a card that scrolls
away is a card that gets missed. One at a time, with a count of what is behind
it — a blocked task stays blocked whether or not its card is on screen, so
stacking them would trade a readable chat for a queue nobody asked to see. And
it closes: the ✕ hides the card without resolving anything, leaving the item in
the Inbox, because a panel that cannot be moved takes the chat hostage.
`InboxCardsMixin` is the cards and their resolve calls, split out of
`InboxMixin` so the chat and the Inbox render the same approval rather than two
drifting copies of it; `_afterInboxResolve` is the only thing they disagree on.
Elicitations are left out: `PendingElicitationInfo` carries no `session_id`, so
there is nothing to attribute one to a task with.
Also: an async task's context label said "CronJob:", which sends whoever reads
the approval looking on the wrong page — and now says so next to the task's
real name.
350 lines
13 KiB
JavaScript
350 lines
13 KiB
JavaScript
import { html, nothing } from 'lit';
|
||
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
|
||
import { renderMarkdown } from './base.js';
|
||
import { t } from './i18n.js';
|
||
|
||
/**
|
||
* InboxCardsMixin — the pending-item cards and the calls that resolve them.
|
||
*
|
||
* Split out of `InboxMixin` when the chat started rendering the same cards for
|
||
* the background tasks it started: an approval raised by an async sub-agent is
|
||
* the *same* approval, resolved through the same endpoint, and a second
|
||
* implementation of it would be two places to get a bypass scope wrong.
|
||
*
|
||
* The only thing the two surfaces disagree on is what to reload once an item is
|
||
* resolved — the Inbox re-reads the whole inbox, the chat re-reads only its own
|
||
* tasks' items. That is the `_afterInboxResolve` hook, and it is the whole seam.
|
||
*/
|
||
export const InboxCardsMixin = (Base) => class extends Base {
|
||
|
||
static get properties() {
|
||
return {
|
||
...super.properties,
|
||
_inboxError: { state: true },
|
||
};
|
||
}
|
||
|
||
constructor() {
|
||
super();
|
||
this._inboxError = null;
|
||
// Raw-JSON disclosure per card, keyed `raw-{request_id}`. Deliberately not
|
||
// named `_expanded`: the chat already has one of those for its tool cards.
|
||
this._rawOpen = new Set();
|
||
this._bypassOpen = new Set();
|
||
}
|
||
|
||
/**
|
||
* Called after an item is resolved, to refresh whatever list it came from.
|
||
* Overridden by every consumer; a no-op default means a surface that forgets
|
||
* shows a stale card rather than throwing.
|
||
*/
|
||
async _afterInboxResolve() {}
|
||
|
||
// ── Actions ───────────────────────────────────────────────────────────────
|
||
|
||
async _resolveApproval(requestId, action, note = '', bypassSecs = null, bypassScope = null, toolCallId = null) {
|
||
try {
|
||
const body = { action, note };
|
||
if (bypassSecs !== null) {
|
||
body.bypass_secs = bypassSecs;
|
||
body.bypass_scope = bypassScope;
|
||
}
|
||
// Live items resolve by request_id (and support bypass); DB-persisted
|
||
// (post-restart) items carry request_id 0 → resolve by the durable,
|
||
// source-agnostic tool_call_id (bypass buttons are hidden for them).
|
||
const url = requestId
|
||
? `/api/inbox/approvals/${requestId}/resolve`
|
||
: `/api/tools/${toolCallId}/resolve`;
|
||
const res = await fetch(url, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body),
|
||
});
|
||
if (!res.ok) throw new Error(await res.text());
|
||
this._inboxError = null;
|
||
await this._afterInboxResolve();
|
||
} catch (e) {
|
||
this._inboxError = e.message;
|
||
}
|
||
}
|
||
|
||
_rejectWithNote(requestId, toolCallId = null) {
|
||
const note = prompt(t('inbox.reject_prompt')) ?? '';
|
||
this._resolveApproval(requestId, 'reject', note, null, null, toolCallId);
|
||
}
|
||
|
||
/** Approve + set a timed or session bypass scoped to the tool's category or MCP server. */
|
||
_approveWithBypass(item, bypassSecs) {
|
||
const scope = item.tool_category ? 'category'
|
||
: item.mcp_server ? 'mcp_server'
|
||
: 'all';
|
||
this._resolveApproval(item.request_id, 'approve', '', bypassSecs, scope);
|
||
}
|
||
|
||
/** Human-readable bypass scope label, e.g. "filesystem" or "Gmail". */
|
||
_bypassLabel(item) {
|
||
if (item.tool_category) return item.tool_category;
|
||
if (item.mcp_server) return item.mcp_server;
|
||
return 'session';
|
||
}
|
||
|
||
async _resolveClarification(requestId, inputEl) {
|
||
const answer = inputEl.value.trim();
|
||
if (!answer) return;
|
||
try {
|
||
const res = await fetch(`/api/inbox/clarifications/${requestId}/resolve`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ answer }),
|
||
});
|
||
if (!res.ok) throw new Error(await res.text());
|
||
this._inboxError = null;
|
||
await this._afterInboxResolve();
|
||
} catch (e) {
|
||
this._inboxError = e.message;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Resolve a server-initiated MCP elicitation. On `accept` with a field, the
|
||
* input value is packed into `content` ({ [field]: value }); the secret is
|
||
* sent once and never echoed back into the UI. `decline`/`cancel` send no value.
|
||
*/
|
||
async _resolveElicitation(item, action, inputEl) {
|
||
let content = null;
|
||
if (action === 'accept' && item.field_name) {
|
||
content = { [item.field_name]: inputEl ? inputEl.value : '' };
|
||
}
|
||
try {
|
||
const res = await fetch(`/api/inbox/elicitations/${item.request_id}/resolve`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ action, content }),
|
||
});
|
||
if (!res.ok) throw new Error(await res.text());
|
||
this._inboxError = null;
|
||
await this._afterInboxResolve();
|
||
} catch (e) {
|
||
this._inboxError = e.message;
|
||
}
|
||
}
|
||
|
||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||
|
||
_toggleRaw(id) {
|
||
if (this._rawOpen.has(id)) this._rawOpen.delete(id);
|
||
else this._rawOpen.add(id);
|
||
this.requestUpdate();
|
||
}
|
||
|
||
_toggleBypassMenu(id) {
|
||
if (this._bypassOpen.has(id)) this._bypassOpen.delete(id);
|
||
else this._bypassOpen.add(id);
|
||
this.requestUpdate();
|
||
}
|
||
|
||
_fmt(iso) {
|
||
if (!iso) return '';
|
||
return new Date(iso).toLocaleString(undefined, {
|
||
day: '2-digit', month: '2-digit', year: '2-digit',
|
||
hour: '2-digit', minute: '2-digit',
|
||
});
|
||
}
|
||
|
||
_keyArgs(args) {
|
||
const entries = [];
|
||
for (const key of ['path', 'command', 'url', 'origin', 'destination', 'name', 'message', 'query']) {
|
||
if (args[key] !== undefined) {
|
||
let val = args[key];
|
||
if (typeof val === 'object') val = JSON.stringify(val);
|
||
entries.push({ key, value: String(val) });
|
||
}
|
||
}
|
||
return entries;
|
||
}
|
||
|
||
// ── Card renderers ────────────────────────────────────────────────────────
|
||
|
||
_renderApprovalCard(item) {
|
||
const id = `raw-${item.request_id}`;
|
||
const open = this._rawOpen.has(id);
|
||
const label = item.context_label ?? item.source;
|
||
const args = item.arguments ?? {};
|
||
const keyArgs = this._keyArgs(args);
|
||
const rawJson = JSON.stringify(args, null, 2);
|
||
|
||
return html`
|
||
<div class="inbox-card approval-card">
|
||
<div class="inbox-card-header">
|
||
<span class="badge bg-warning text-dark">Approval</span>
|
||
<span class="inbox-card-origin" title="${label}">${label}</span>
|
||
<span class="inbox-card-time">${this._fmt(item.created_at)}</span>
|
||
</div>
|
||
|
||
<div class="inbox-card-body">
|
||
<div class="inbox-tool-name">
|
||
<i class="bi bi-tools"></i>
|
||
<strong>${item.tool_name}</strong>
|
||
<span class="inbox-agent-tag">
|
||
<i class="bi bi-person"></i> ${item.agent_id}
|
||
</span>
|
||
</div>
|
||
|
||
${keyArgs.length > 0 ? html`
|
||
<div class="inbox-args-structured">
|
||
${keyArgs.map(kv => html`
|
||
<div class="inbox-arg-row">
|
||
<span class="inbox-arg-key">${kv.key}</span>
|
||
<span class="inbox-arg-value">${kv.value}</span>
|
||
</div>
|
||
`)}
|
||
</div>
|
||
` : nothing}
|
||
|
||
<button class="inbox-args-toggle" @click=${() => this._toggleRaw(id)}>
|
||
<i class="bi ${open ? 'bi-chevron-up' : 'bi-chevron-down'}"></i>
|
||
${open ? 'Hide raw JSON' : 'Show raw JSON'}
|
||
</button>
|
||
<pre class="inbox-args-raw ${open ? 'open' : ''}">${rawJson}</pre>
|
||
</div>
|
||
|
||
<div class="inbox-card-footer approval-footer">
|
||
<button class="btn btn-success"
|
||
@click=${() => this._resolveApproval(item.request_id, 'approve', '', null, null, item.tool_call_id)}>
|
||
<i class="bi bi-check-lg"></i> ${t('approval.approve')}
|
||
</button>
|
||
<button class="btn btn-outline-danger"
|
||
@click=${() => this._rejectWithNote(item.request_id, item.tool_call_id)}>
|
||
<i class="bi bi-x-lg"></i> ${t('approval.reject')}
|
||
</button>
|
||
|
||
${item.request_id ? html`
|
||
<div class="inbox-bypass-wrap">
|
||
<button class="btn btn-outline-secondary"
|
||
@click=${() => this._toggleBypassMenu(id)}>
|
||
<i class="bi bi-clock-history"></i> ×${this._bypassLabel(item)} ▾
|
||
</button>
|
||
<div class="inbox-bypass-menu ${this._bypassOpen.has(id) ? 'open' : ''}">
|
||
<button @click=${() => { this._bypassOpen.delete(id); this._approveWithBypass(item, 15 * 60); }}>
|
||
15 min
|
||
</button>
|
||
<button @click=${() => { this._bypassOpen.delete(id); this._approveWithBypass(item, 60 * 60); }}>
|
||
1 ora
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<button class="btn btn-outline-secondary"
|
||
@click=${() => this._approveWithBypass(item, 0)}
|
||
title=${t('approval.bypass_all')}>
|
||
<i class="bi bi-shield-check"></i> Sessione
|
||
</button>
|
||
` : nothing}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
_renderClarificationCard(item) {
|
||
const label = item.context_label ?? item.source;
|
||
|
||
return html`
|
||
<div class="inbox-card clarification-card">
|
||
<div class="inbox-card-header">
|
||
<span class="badge bg-info text-dark">Question</span>
|
||
<span class="inbox-card-origin" title="${label}">${label}</span>
|
||
<span class="inbox-card-time">${this._fmt(item.created_at)}</span>
|
||
</div>
|
||
|
||
<div class="inbox-card-body">
|
||
<div class="inbox-card-title">${item.title}</div>
|
||
<div class="inbox-question copilot-markdown">${unsafeHTML(renderMarkdown(item.question))}</div>
|
||
|
||
${item.suggested_answers?.length ? html`
|
||
<div class="inbox-chips">
|
||
${item.suggested_answers.map(a => html`
|
||
<button class="inbox-chip"
|
||
@click=${(e) => {
|
||
const inp = e.target.closest('.inbox-card')?.querySelector('.inbox-answer-input');
|
||
if (inp) { inp.value = a; inp.focus(); }
|
||
}}>
|
||
${a}
|
||
</button>
|
||
`)}
|
||
</div>
|
||
` : nothing}
|
||
|
||
<div class="inbox-answer-area">
|
||
<textarea class="inbox-answer-input" rows="2"
|
||
placeholder="Your answer…"
|
||
@keydown=${(e) => {
|
||
if (e.key === 'Enter' && !e.shiftKey) {
|
||
e.preventDefault();
|
||
this._resolveClarification(item.request_id, e.target);
|
||
}
|
||
}}></textarea>
|
||
<button class="inbox-answer-send"
|
||
@click=${(e) => {
|
||
const inp = e.target.closest('.inbox-card')?.querySelector('.inbox-answer-input');
|
||
if (inp) this._resolveClarification(item.request_id, inp);
|
||
}}>
|
||
<i class="bi bi-send"></i> Send
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
_renderElicitationCard(item) {
|
||
const masked = item.sensitive;
|
||
const confirm = item.is_confirmation;
|
||
|
||
return html`
|
||
<div class="inbox-card elicitation-card">
|
||
<div class="inbox-card-header">
|
||
<span class="badge bg-secondary">
|
||
<i class="bi ${masked ? 'bi-shield-lock' : 'bi-question-circle'}"></i>
|
||
${confirm ? 'Conferma' : 'Input'}
|
||
</span>
|
||
<span class="inbox-card-origin" title="${item.server_name}">${item.server_name}</span>
|
||
<span class="inbox-card-time">${this._fmt(item.created_at)}</span>
|
||
</div>
|
||
|
||
<div class="inbox-card-body">
|
||
<div class="inbox-question">${item.message}</div>
|
||
|
||
${confirm ? nothing : html`
|
||
<div class="inbox-answer-area">
|
||
<input class="inbox-answer-input inbox-secret-input"
|
||
type="${masked ? 'password' : 'text'}"
|
||
autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false"
|
||
placeholder="${masked ? '••••••••' : 'Value…'}"
|
||
@keydown=${(e) => {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
this._resolveElicitation(item, 'accept', e.target);
|
||
}
|
||
}}>
|
||
</div>
|
||
`}
|
||
</div>
|
||
|
||
<div class="inbox-card-footer approval-footer">
|
||
<button class="btn btn-success"
|
||
@click=${(e) => {
|
||
const inp = e.target.closest('.inbox-card')?.querySelector('.inbox-secret-input');
|
||
this._resolveElicitation(item, 'accept', inp);
|
||
}}>
|
||
<i class="bi bi-check-lg"></i> ${confirm ? 'Conferma' : 'Invia'}
|
||
</button>
|
||
<button class="btn btn-outline-danger"
|
||
@click=${() => this._resolveElicitation(item, 'decline', null)}>
|
||
<i class="bi bi-x-lg"></i> Rifiuta
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
};
|