feat: let a background task ask the chat that started it, not just the Inbox
Nightly Build / build (push) Successful in 7m42s
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.
This commit is contained in:
@@ -384,7 +384,14 @@ async fn run_job(
|
||||
}
|
||||
|
||||
let handler = session.get_or_create_handler(session_id).await?;
|
||||
handler.set_context_label(format!("CronJob: {}", job.title));
|
||||
// The label rides every pending item this run raises, so it is what a human
|
||||
// reads when asked to approve something. An async task is not on a schedule
|
||||
// and calling it a cron job sends them looking on the wrong page — which
|
||||
// now shows next to the task's real name in the chat's own card.
|
||||
handler.set_context_label(match job.kind.as_str() {
|
||||
"async" => format!("Task: {}", job.title),
|
||||
_ => format!("CronJob: {}", job.title),
|
||||
});
|
||||
if job.kind == "async" {
|
||||
if let Some(parent_id) = job.parent_session_id {
|
||||
handler.set_scratchpad_session_id(parent_id);
|
||||
|
||||
@@ -148,6 +148,48 @@ pub async fn list_for_parent_session(
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// One live background task, reduced to what identifies its session.
|
||||
///
|
||||
/// The pairing an approval needs: a pending item names the session it was
|
||||
/// raised in, and this is what turns that id back into "the task «X» your
|
||||
/// conversation started".
|
||||
#[derive(Debug, Clone, sqlx::FromRow)]
|
||||
pub struct RunningChildSession {
|
||||
pub job_id: i64,
|
||||
pub title: String,
|
||||
pub session_id: i64,
|
||||
}
|
||||
|
||||
/// The sessions of the async tasks this conversation has running *right now*.
|
||||
///
|
||||
/// Deliberately narrower than [`list_for_parent_session`]: that one also
|
||||
/// reports recent failures, because a failure is still worth showing. A task
|
||||
/// that is no longer running cannot be waiting on a human, so including one
|
||||
/// here could only match a stale pending item against the wrong job.
|
||||
///
|
||||
/// `running_session_id` is written before the task's handler is built (see
|
||||
/// `cron::run_job`), so a task can never raise an approval before this query
|
||||
/// can attribute it.
|
||||
pub async fn running_child_sessions(
|
||||
pool: &SqlitePool,
|
||||
parent_session_id: i64,
|
||||
) -> Result<Vec<RunningChildSession>> {
|
||||
let rows = sqlx::query_as::<_, RunningChildSession>(
|
||||
"SELECT id AS job_id,
|
||||
title AS title,
|
||||
running_session_id AS session_id
|
||||
FROM scheduled_jobs
|
||||
WHERE kind = 'async'
|
||||
AND parent_session_id = ?
|
||||
AND running_session_id IS NOT NULL
|
||||
ORDER BY id",
|
||||
)
|
||||
.bind(parent_session_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// The two timestamp shapes this table mixes, as one RFC 3339 string:
|
||||
/// `running_since` is written by SQLite's `datetime('now')` (`Y-m-d H:M:S`,
|
||||
/// UTC, no offset) while `job_runs.started_at` is already RFC 3339. A client
|
||||
@@ -357,6 +399,27 @@ mod tests {
|
||||
assert_eq!(tasks[1].error.as_deref(), Some("boom"));
|
||||
}
|
||||
|
||||
/// Attributing a pending approval to a task means matching its session, so
|
||||
/// this query has to be narrower than the strip's: only what is running, and
|
||||
/// only for this conversation. A finished task cannot be waiting on a human,
|
||||
/// so including one could only pair a stale item with the wrong job.
|
||||
#[tokio::test]
|
||||
async fn only_this_conversations_live_task_sessions_are_attributable() {
|
||||
let pool = seeded().await;
|
||||
let children = running_child_sessions(&pool, 1).await.unwrap();
|
||||
|
||||
let seen: Vec<_> = children.iter().map(|c| (c.job_id, c.session_id)).collect();
|
||||
// Job 1 only: 2/3/4 have ended (no `running_session_id`), 5 belongs to
|
||||
// the other conversation, and 6 is a cron job — nobody's chat.
|
||||
assert_eq!(seen, vec![(1, 11)]);
|
||||
assert_eq!(children[0].title, "still going");
|
||||
|
||||
// A conversation whose tasks are all someone else's gets nothing, and a
|
||||
// conversation that never started one gets nothing — not an error.
|
||||
assert_eq!(running_child_sessions(&pool, 2).await.unwrap().len(), 1);
|
||||
assert!(running_child_sessions(&pool, 999).await.unwrap().is_empty());
|
||||
}
|
||||
|
||||
/// `running_since` is SQLite-shaped and `job_runs.started_at` is RFC 3339;
|
||||
/// both leave here as RFC 3339, or a browser reads one of them in the wrong
|
||||
/// timezone and shows an elapsed counter hours off.
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ This index will grow over time. Right now it covers memory, projects, background
|
||||
| [memory.md](memory.md) | Private and shared memory: what goes where, the indexes and history log, why some shared facts can't be changed on request |
|
||||
| [projects.md](projects.md) | Projects: shared folders with their own assistant chat, a live file explorer, and member sharing |
|
||||
| [system-agents.md](system-agents.md) | Background agents that run on a schedule (event triage, the two memory lints, the nightly conversation review of a supervised account): what they watch, why they only ever report, why a run can be skipped, and their settings |
|
||||
| [tasks.md](tasks.md) | Background tasks: the strip above the message box, following one live, stopping one, and how every outcome comes back to the conversation |
|
||||
| [tasks.md](tasks.md) | Background tasks: the strip above the message box, following one live, stopping one, answering the approvals and questions they raise, and how every outcome comes back to the conversation |
|
||||
| [settings.md](settings.md) | The admin's Config page: interface language, the compaction model picker, debug mode |
|
||||
| [access.md](access.md) | Who can use which plugin or connector: the open default, removing access per person, and the role switch that keeps children out of it |
|
||||
| [connectors.md](connectors.md) | Connectors (MCP servers): shared vs per-user, setting one up in the UI, the sign-in and QR-pairing flows, and what to do when one is not working |
|
||||
|
||||
@@ -26,6 +26,17 @@ While a background task is running, a small strip appears **just above the compo
|
||||
|
||||
A finished task's line disappears from the strip after a few seconds. A **failed** one stays, so the reason can be read, until it is dismissed with the ✕.
|
||||
|
||||
## When a task needs the user
|
||||
|
||||
A background task can hit something it is not allowed to do on its own — running a command, writing outside its own area — or it can simply need to ask a question. Because the task is a separate conversation, it cannot interrupt the chat the way the assistant does mid-answer. Instead, **a card appears at the top of the task strip**, above the message box: the approval to grant, or the question to answer, labelled with the task that is asking.
|
||||
|
||||
- **It waits.** A task that has asked for something is stopped until it gets an answer. Nothing else of it moves in the meantime.
|
||||
- **One at a time.** If several tasks are asking, the card shows the first and says how many are behind it (*1 of 3*); answering one brings up the next.
|
||||
- **It can be closed.** The **✕** in the card's top-left corner puts it away — it does *not* approve or reject anything. The request is still pending, the task is still waiting, and it can be dealt with from the **Inbox** (sidebar → Inbox) whenever the user is ready. The strip keeps a small "waiting in the Inbox" pointer while any closed request is still outstanding.
|
||||
- **Anywhere works.** The same request appears in the Inbox and, if the mobile app is paired, on the phone. Answering it in any one of those places settles it everywhere; the card disappears on its own.
|
||||
|
||||
The chat's *own* approvals are unaffected by all this — when the assistant itself needs permission mid-answer, the card still appears inline, in the transcript, where the work is happening.
|
||||
|
||||
## When a user asks about them
|
||||
|
||||
Common questions and the honest answers:
|
||||
@@ -34,4 +45,5 @@ Common questions and the honest answers:
|
||||
- *"What is it doing?"* — click the task's line.
|
||||
- *"It has been going for ages."* — a task has no time limit; stopping it with ■ is always available, and stopping is not the same as failing.
|
||||
- *"Where did the result go?"* — into this conversation, always. If it is not there yet, the task has not finished.
|
||||
- *"It's stuck."* — check whether it is asking for something: an approval or a question waiting at the top of the strip, or in the Inbox if the card was closed earlier.
|
||||
- *"Show me everything that ever ran."* — the **Tasks** page (sidebar → Tasks) has the full history, including scheduled jobs; the strip only covers the current conversation.
|
||||
|
||||
@@ -29,6 +29,92 @@ pub async fn list(
|
||||
})))
|
||||
}
|
||||
|
||||
// ── GET /api/{source}/inbox ───────────────────────────────────────────────────
|
||||
//
|
||||
// The pending items raised by the background tasks *this* conversation started.
|
||||
//
|
||||
// An async sub-agent runs in a session of its own (`source = "cron"`), so its
|
||||
// `ApprovalRequired` / `AgentQuestion` events — the rich, per-session ones that
|
||||
// draw the inline card — never reach the chat's WebSocket, and until now the
|
||||
// only place they surfaced was the Inbox. The chat already shows what it handed
|
||||
// off (the background-task strip); this is the same question asked of the
|
||||
// pending items, so the strip can carry the card too.
|
||||
//
|
||||
// The join is server-side on purpose: "whose is this pending item" is the same
|
||||
// question `/{source}/tasks` answers for a task, and it should have one answer.
|
||||
// The client is left with a list to render, not a correlation to guess.
|
||||
//
|
||||
// Live updates ride the `approval_requested` / `approval_resolved` /
|
||||
// `clarification_*` events, which are already forwarded to every one of this
|
||||
// user's sockets regardless of source — they carry ids only, so this endpoint
|
||||
// is what turns a nudge into something renderable, and what a page reload reads.
|
||||
//
|
||||
// Elicitations are absent: `PendingElicitationInfo` carries no `session_id`, so
|
||||
// there is nothing to attribute one to a task with.
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct TaskInboxApproval {
|
||||
/// The task that is asking — the strip labels the card with it.
|
||||
pub job_id: i64,
|
||||
pub job_title: String,
|
||||
#[serde(flatten)]
|
||||
pub item: skald_core::approval::PendingApprovalInfo,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct TaskInboxClarification {
|
||||
pub job_id: i64,
|
||||
pub job_title: String,
|
||||
#[serde(flatten)]
|
||||
pub item: skald_core::clarification::PendingClarificationInfo,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct TaskInbox {
|
||||
pub approvals: Vec<TaskInboxApproval>,
|
||||
pub clarifications: Vec<TaskInboxClarification>,
|
||||
}
|
||||
|
||||
pub async fn session_task_inbox(
|
||||
State(skald): State<Arc<Skald>>,
|
||||
Extension(auth): Extension<AuthUser>,
|
||||
Path(p): Path<super::cron::SourcePath>,
|
||||
) -> Result<Json<TaskInbox>, ApiError> {
|
||||
let ctx = require_context(&skald, &auth.user_id).await?;
|
||||
let empty = TaskInbox { approvals: vec![], clarifications: vec![] };
|
||||
|
||||
// A chat that has never run has no session, and therefore no tasks. Not an
|
||||
// error: the strip asks on every load, including the first one.
|
||||
let Some(session_id) = skald_core::db::sources::active_session_id(&ctx.pool, &p.source).await? else {
|
||||
return Ok(Json(empty));
|
||||
};
|
||||
let children =
|
||||
skald_core::db::scheduled_jobs::running_child_sessions(&ctx.pool, session_id).await?;
|
||||
if children.is_empty() {
|
||||
return Ok(Json(empty));
|
||||
}
|
||||
let by_session: std::collections::HashMap<i64, &skald_core::db::scheduled_jobs::RunningChildSession> =
|
||||
children.iter().map(|c| (c.session_id, c)).collect();
|
||||
|
||||
let items = ctx.inbox.list_pending().await;
|
||||
let approvals = items.approvals.into_iter()
|
||||
.filter_map(|item| by_session.get(&item.session_id).map(|job| TaskInboxApproval {
|
||||
job_id: job.job_id,
|
||||
job_title: job.title.clone(),
|
||||
item,
|
||||
}))
|
||||
.collect();
|
||||
let clarifications = items.clarifications.into_iter()
|
||||
.filter_map(|item| by_session.get(&item.session_id).map(|job| TaskInboxClarification {
|
||||
job_id: job.job_id,
|
||||
job_title: job.title.clone(),
|
||||
item,
|
||||
}))
|
||||
.collect();
|
||||
|
||||
Ok(Json(TaskInbox { approvals, clarifications }))
|
||||
}
|
||||
|
||||
// ── POST /api/inbox/approvals/:request_id/resolve ─────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
||||
@@ -74,6 +74,9 @@ pub fn router() -> Router<Arc<Skald>> {
|
||||
.route("/{source}/messages", get(sessions::source_messages))
|
||||
// Background (`execute_task mode=async`) tasks of this source's session.
|
||||
.route("/{source}/tasks", get(cron::session_tasks))
|
||||
// Pending approvals/questions raised by those tasks — the chat renders
|
||||
// them itself instead of leaving them to the Inbox alone.
|
||||
.route("/{source}/inbox", get(inbox::session_task_inbox))
|
||||
// File attachments: streamed to disk, so the default body-size limit is
|
||||
// disabled on this route only.
|
||||
.route("/{source}/uploads", post(uploads::upload).layer(DefaultBodyLimit::disable()))
|
||||
|
||||
@@ -77,22 +77,89 @@ function renderTask(host, task) {
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a background task is waiting on: the approval or question it raised.
|
||||
*
|
||||
* One at a time, on purpose. A task that is blocked stays blocked whether or not
|
||||
* its card is on screen, so stacking every pending item here would trade a
|
||||
* readable chat for a queue nobody asked to see all of — the count says how many
|
||||
* are behind it, and resolving this one reveals the next.
|
||||
*
|
||||
* It sits above the strip rather than in the transcript because the transcript
|
||||
* is a record of what was said: the task that is asking may have been started
|
||||
* twenty messages ago, and a card that scrolls away is a card that gets missed.
|
||||
*
|
||||
* Which is also why it can be closed: a card that cannot be moved out of the way
|
||||
* is a card that takes the chat hostage. The ✕ hides it and nothing more — the
|
||||
* item stays pending and the Inbox stays the place to answer it.
|
||||
*/
|
||||
function renderPending(host) {
|
||||
const pending = host._taskPending ?? [];
|
||||
if (pending.length === 0) return nothing;
|
||||
|
||||
const [item] = pending;
|
||||
|
||||
return html`
|
||||
<div class="agent-task-ask">
|
||||
<div class="agent-task-ask-head">
|
||||
<button class="agent-task-ask-close"
|
||||
title=${t('chat.tasks.ask_hide')}
|
||||
@click=${() => host._dismissAsk(item)}>
|
||||
<i class="bi bi-x"></i>
|
||||
</button>
|
||||
<i class="bi bi-hand-index-thumb-fill"></i>
|
||||
<span>${t('chat.tasks.needs_you')}</span>
|
||||
<span class="agent-task-ask-job" title=${item.job_title}>${item.job_title}</span>
|
||||
${pending.length > 1
|
||||
? html`<span class="agent-task-ask-count">
|
||||
${t('chat.tasks.pending_n', { n: pending.length })}
|
||||
</span>`
|
||||
: nothing}
|
||||
</div>
|
||||
${host._inboxError
|
||||
? html`<div class="agent-task-ask-error">${host._inboxError}</div>`
|
||||
: nothing}
|
||||
${item.kind === 'approval'
|
||||
? host._renderApprovalCard(item)
|
||||
: host._renderClarificationCard(item)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderTaskStrip(host) {
|
||||
const tasks = host._tasks ?? [];
|
||||
if (tasks.length === 0) return nothing;
|
||||
const tasks = host._tasks ?? [];
|
||||
const pending = host._taskPending ?? [];
|
||||
const hidden = host._taskPendingHidden ?? 0;
|
||||
// A pending item outlives the strip's own drop timers, so it keeps the
|
||||
// container alive on its own: a task can still be waiting on a human after
|
||||
// its row has been dismissed.
|
||||
//
|
||||
// Dismissed items deliberately do *not* keep it alive. Closing the last card
|
||||
// with nothing else running has to leave a clean chat, or the ✕ would not be
|
||||
// the promise it looks like — the sidebar badge and the Inbox are where those
|
||||
// items live now.
|
||||
if (tasks.length === 0 && pending.length === 0) return nothing;
|
||||
|
||||
const running = tasks.filter(x => x.state === 'running').length;
|
||||
|
||||
return html`
|
||||
<div class="agent-tasks">
|
||||
<div class="agent-tasks-head">
|
||||
<i class="bi bi-cpu"></i>
|
||||
<span>${running > 0
|
||||
? t('chat.tasks.running_n', { n: running })
|
||||
: t('chat.tasks.title')}</span>
|
||||
<a class="agent-tasks-all" href="#tasks">${t('chat.tasks.see_all')}</a>
|
||||
</div>
|
||||
${tasks.map(task => renderTask(host, task))}
|
||||
${renderPending(host)}
|
||||
${tasks.length > 0 ? html`
|
||||
<div class="agent-tasks-head">
|
||||
<i class="bi bi-cpu"></i>
|
||||
<span>${running > 0
|
||||
? t('chat.tasks.running_n', { n: running })
|
||||
: t('chat.tasks.title')}</span>
|
||||
${hidden > 0
|
||||
? html`<a class="agent-tasks-hidden" href="#inbox">
|
||||
<i class="bi bi-inbox"></i> ${t('chat.tasks.hidden_n', { n: hidden })}
|
||||
</a>`
|
||||
: nothing}
|
||||
<a class="agent-tasks-all" href="#tasks">${t('chat.tasks.see_all')}</a>
|
||||
</div>
|
||||
${tasks.map(task => renderTask(host, task))}
|
||||
` : nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -39,6 +39,112 @@
|
||||
|
||||
.agent-tasks-all:hover { color: var(--accent); text-decoration: underline; }
|
||||
|
||||
/* Pointer to the pending items the user closed with the ✕: still waiting,
|
||||
just not here. */
|
||||
.agent-tasks-hidden {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.agent-tasks-hidden:hover { text-decoration: underline; }
|
||||
|
||||
/* When both links are present the "all tasks" one gives up its auto margin. */
|
||||
.agent-tasks-hidden ~ .agent-tasks-all { margin-left: 0.6rem; }
|
||||
|
||||
/* ── What a task is waiting on ──────────────────────────────────────────────
|
||||
An approval or a question raised by a background task, shown above the strip
|
||||
and outside the transcript. It carries the full Inbox card, so it must read
|
||||
as an interruption worth answering — accent border, not the quiet chrome the
|
||||
task rows use — while still capping its own height: an `execute_cmd` card can
|
||||
be tall, and the conversation underneath has to stay reachable. */
|
||||
|
||||
.agent-task-ask {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
max-height: min(22rem, 45vh);
|
||||
overflow-y: auto;
|
||||
margin-bottom: 0.35rem;
|
||||
padding: 0.35rem;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.agent-task-ask-head {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.1rem 0.15rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
/* Leading, so closing the panel is the first thing the corner offers. */
|
||||
.agent-task-ask-close {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.35rem;
|
||||
height: 1.35rem;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--accent);
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.agent-task-ask-close:hover { background: var(--card-bg); }
|
||||
|
||||
.agent-task-ask-job {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
color: var(--placeholder-color);
|
||||
}
|
||||
|
||||
.agent-task-ask-count {
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 500;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
color: var(--placeholder-color);
|
||||
}
|
||||
|
||||
.agent-task-ask-error {
|
||||
padding: 0.2rem 0.35rem;
|
||||
font-size: 0.72rem;
|
||||
color: var(--bs-danger, #dc3545);
|
||||
}
|
||||
|
||||
/* The Inbox card sizes itself for a full-width page; here it is a panel. */
|
||||
.agent-task-ask .inbox-card { margin: 0; }
|
||||
|
||||
/* ── One task ── */
|
||||
|
||||
.agent-task {
|
||||
|
||||
@@ -93,6 +93,10 @@ export default {
|
||||
'chat.tasks.failed': 'failed',
|
||||
'chat.tasks.cancelled': 'stopped',
|
||||
'chat.tasks.see_all': 'All tasks',
|
||||
'chat.tasks.needs_you': 'Waiting for you',
|
||||
'chat.tasks.pending_n': '1 of {n}',
|
||||
'chat.tasks.ask_hide': 'Hide — it stays in the Inbox',
|
||||
'chat.tasks.hidden_n': '{n} waiting in the Inbox',
|
||||
|
||||
// ── Copilot render ─────────────────────────────────────────────────────────
|
||||
'copilot.open_in_viewer': 'Open in viewer',
|
||||
|
||||
@@ -93,6 +93,10 @@ export default {
|
||||
'chat.tasks.failed': 'échouée',
|
||||
'chat.tasks.cancelled': 'arrêtée',
|
||||
'chat.tasks.see_all': 'Toutes les tâches',
|
||||
'chat.tasks.needs_you': 'En attente de vous',
|
||||
'chat.tasks.pending_n': '1 sur {n}',
|
||||
'chat.tasks.ask_hide': 'Masquer — reste dans la boîte de réception',
|
||||
'chat.tasks.hidden_n': '{n} en attente dans la boîte de réception',
|
||||
|
||||
// ── Copilot render ─────────────────────────────────────────────────────────
|
||||
'copilot.open_in_viewer': 'Ouvrir dans le visualiseur',
|
||||
|
||||
@@ -93,6 +93,10 @@ export default {
|
||||
'chat.tasks.failed': 'fallita',
|
||||
'chat.tasks.cancelled': 'fermata',
|
||||
'chat.tasks.see_all': 'Tutte le attività',
|
||||
'chat.tasks.needs_you': 'Aspetta te',
|
||||
'chat.tasks.pending_n': '1 di {n}',
|
||||
'chat.tasks.ask_hide': 'Nascondi — resta nella Inbox',
|
||||
'chat.tasks.hidden_n': '{n} in attesa nella Inbox',
|
||||
|
||||
// ── Copilot render ─────────────────────────────────────────────────────────
|
||||
'copilot.open_in_viewer': 'Apri nel visualizzatore',
|
||||
|
||||
+109
-6
@@ -1,5 +1,6 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { LightElement } from './base.js';
|
||||
import { InboxCardsMixin } from './inbox-cards.js';
|
||||
import { t } from './i18n.js';
|
||||
import { isSessionExpired, notifySessionExpired, probeSession } from './session-expiry.js';
|
||||
|
||||
@@ -33,7 +34,7 @@ const SCROLL_STICKY_PX = 80;
|
||||
* within SCROLL_STICKY_PX of the bottom. Scrolling up to read pauses it, and a
|
||||
* "jump to latest" affordance (driven by the `_showJump` state) is shown then.
|
||||
*/
|
||||
export class ChatSession extends LightElement {
|
||||
export class ChatSession extends InboxCardsMixin(LightElement) {
|
||||
static properties = {
|
||||
_messages: { state: true },
|
||||
_waiting: { state: true },
|
||||
@@ -64,6 +65,9 @@ export class ChatSession extends LightElement {
|
||||
_tasks: { state: true },
|
||||
// Bumped once a second while a task is running, so the elapsed counters move.
|
||||
_taskTick: { state: true },
|
||||
// Approvals and questions raised by those tasks: `{ approvals, clarifications }`,
|
||||
// each item carrying the `job_id` / `job_title` that asked.
|
||||
_taskInbox: { state: true },
|
||||
};
|
||||
|
||||
// Live events whose arrival implies a turn is in flight (used to restore the
|
||||
@@ -116,6 +120,7 @@ export class ChatSession extends LightElement {
|
||||
// is in flight the entry has `uploading: true` and no `path` yet.
|
||||
this._attachments = [];
|
||||
this._tasks = [];
|
||||
this._taskInbox = { approvals: [], clarifications: [] };
|
||||
this._taskTick = 0;
|
||||
this._taskTimer = null;
|
||||
// Timers that drop a finished task from the strip after a grace period.
|
||||
@@ -129,7 +134,9 @@ export class ChatSession extends LightElement {
|
||||
// Fire-and-forget: availability of a transcription provider determines
|
||||
// whether the mic button is rendered at all.
|
||||
this._checkTranscribe();
|
||||
await Promise.all([this._loadProviders(), this._loadHistory(), this._loadTasks()]);
|
||||
await Promise.all([
|
||||
this._loadProviders(), this._loadHistory(), this._loadTasks(), this._loadTaskInbox(),
|
||||
]);
|
||||
this._connectWS();
|
||||
}
|
||||
|
||||
@@ -160,7 +167,8 @@ export class ChatSession extends LightElement {
|
||||
this._messages = [];
|
||||
this._waiting = false;
|
||||
this._tasks = [];
|
||||
await Promise.all([this._loadHistory(), this._loadTasks()]);
|
||||
this._taskInbox = { approvals: [], clarifications: [] };
|
||||
await Promise.all([this._loadHistory(), this._loadTasks(), this._loadTaskInbox()]);
|
||||
this._connectWS();
|
||||
}
|
||||
|
||||
@@ -336,6 +344,86 @@ export class ChatSession extends LightElement {
|
||||
this._taskTimer = null;
|
||||
}
|
||||
|
||||
// ── What those tasks are waiting on ───────────────────────────────────────────
|
||||
//
|
||||
// An async sub-agent runs in a session of its own, so the rich per-session
|
||||
// events that draw the inline approval card (`approval_required`,
|
||||
// `agent_question`) never reach this socket — only the id-only inbox lifecycle
|
||||
// events do, which is why this is a fetch and not an event payload. The chat
|
||||
// renders the first pending item above the task strip: outside the transcript,
|
||||
// in a fixed position, so the conversation stays readable underneath it.
|
||||
//
|
||||
// The queue needs no state of its own. Resolving an item broadcasts
|
||||
// `approval_resolved` / `clarification_resolved`, this list is re-read, and the
|
||||
// next item becomes the first.
|
||||
|
||||
async _loadTaskInbox() {
|
||||
try {
|
||||
const res = await fetch(`/api/${this._source}/inbox`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
this._taskInbox = {
|
||||
approvals: data.approvals ?? [],
|
||||
clarifications: data.clarifications ?? [],
|
||||
};
|
||||
} catch (e) {
|
||||
console.warn('Could not load background-task inbox:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Cards the user has waved away with the ✕. Dismissing is *not* answering:
|
||||
// the item stays pending, the task stays blocked, and the Inbox stays the
|
||||
// place to deal with it — this only says "not in front of my chat". Persisted
|
||||
// for the same reason the strip persists dismissed tasks: the endpoint keeps
|
||||
// reporting the item until it is resolved, so an in-memory set would put the
|
||||
// card back on the next reload.
|
||||
static _DISMISSED_ASKS_KEY = 'skald.dismissedAsks';
|
||||
|
||||
/** Identity for dismissal. Post-restart items carry `request_id: 0`, so they
|
||||
* key on the durable `tool_call_id` instead — otherwise every one of them
|
||||
* would share a single key and dismissing one would hide them all. */
|
||||
static askKey(item) {
|
||||
return item.request_id
|
||||
? `${item.kind}-${item.request_id}`
|
||||
: `${item.kind}-tc${item.tool_call_id}`;
|
||||
}
|
||||
|
||||
_dismissedAsks() {
|
||||
try {
|
||||
return new Set(JSON.parse(localStorage.getItem(ChatSession._DISMISSED_ASKS_KEY) ?? '[]'));
|
||||
} catch { return new Set(); }
|
||||
}
|
||||
|
||||
_dismissAsk(item) {
|
||||
const keys = [...this._dismissedAsks(), ChatSession.askKey(item)].slice(-50);
|
||||
try { localStorage.setItem(ChatSession._DISMISSED_ASKS_KEY, JSON.stringify(keys)); } catch { /* private mode */ }
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
/** Approvals first: a blocked tool call is more urgent than an open question. */
|
||||
get _taskPendingAll() {
|
||||
return [
|
||||
...this._taskInbox.approvals.map(x => ({ ...x, kind: 'approval' })),
|
||||
...this._taskInbox.clarifications.map(x => ({ ...x, kind: 'clarification' })),
|
||||
];
|
||||
}
|
||||
|
||||
/** What the chat still offers to show. */
|
||||
get _taskPending() {
|
||||
const dismissed = this._dismissedAsks();
|
||||
return this._taskPendingAll.filter(x => !dismissed.has(ChatSession.askKey(x)));
|
||||
}
|
||||
|
||||
/** Waved away but still waiting — the strip keeps a pointer to the Inbox for these. */
|
||||
get _taskPendingHidden() {
|
||||
return this._taskPendingAll.length - this._taskPending.length;
|
||||
}
|
||||
|
||||
/** `InboxCardsMixin` hook: a resolved item leaves this list, and may reveal the next. */
|
||||
async _afterInboxResolve() {
|
||||
await this._loadTaskInbox();
|
||||
}
|
||||
|
||||
// ── WebSocket ─────────────────────────────────────────────────────────────────
|
||||
|
||||
_connectWS() {
|
||||
@@ -352,8 +440,10 @@ export class ChatSession extends LightElement {
|
||||
this._reconnecting = false;
|
||||
this._resyncOnReconnect();
|
||||
// A task that ended while the socket was down emitted its `task_update`
|
||||
// into the void: re-read the authoritative list.
|
||||
// into the void: re-read the authoritative list — and with it whatever
|
||||
// those tasks started asking for in the meantime.
|
||||
this._loadTasks();
|
||||
this._loadTaskInbox();
|
||||
}
|
||||
if (this._hasPendingTools) {
|
||||
ws.send(JSON.stringify({ type: 'resume' }));
|
||||
@@ -599,6 +689,9 @@ export class ChatSession extends LightElement {
|
||||
case 'approval_resolved': {
|
||||
const { request_id, tool_call_id, approved } = msg;
|
||||
window.dispatchEvent(new CustomEvent('inbox-changed'));
|
||||
// Resolved elsewhere (the Inbox page, the phone) or by us — either way
|
||||
// the card above the strip has to go, and the next one to appear.
|
||||
this._loadTaskInbox();
|
||||
this._updatePendingWrite(request_id, { status: approved ? 'approved' : 'rejected' });
|
||||
if (tool_call_id != null) {
|
||||
if (approved) {
|
||||
@@ -616,12 +709,22 @@ export class ChatSession extends LightElement {
|
||||
case 'approval_requested':
|
||||
case 'clarification_requested':
|
||||
case 'clarification_resolved':
|
||||
case 'elicitation_requested':
|
||||
case 'elicitation_resolved':
|
||||
// Inbox lifecycle from any of this user's sessions (chat, cron,
|
||||
// background): nudge listeners (sidebar badge, inbox page) to refresh
|
||||
// immediately instead of waiting for the next poll.
|
||||
window.dispatchEvent(new CustomEvent('inbox-changed'));
|
||||
// These carry ids only, so they cannot say whether the item belongs to
|
||||
// one of *our* background tasks — the endpoint answers that. An item
|
||||
// raised by this chat's own turn is filtered out server-side and stays
|
||||
// where it belongs, inline in the transcript.
|
||||
this._loadTaskInbox();
|
||||
break;
|
||||
|
||||
case 'elicitation_requested':
|
||||
case 'elicitation_resolved':
|
||||
// Not attributable to a task: `PendingElicitationInfo` carries no
|
||||
// session_id, so the strip cannot claim one. Inbox only, for now.
|
||||
window.dispatchEvent(new CustomEvent('inbox-changed'));
|
||||
break;
|
||||
|
||||
case 'agent_question':
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
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>
|
||||
`;
|
||||
}
|
||||
};
|
||||
+12
-310
@@ -1,19 +1,23 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
|
||||
import { renderMarkdown } from './base.js';
|
||||
import { InboxCardsMixin } from './inbox-cards.js';
|
||||
import { t } from './i18n.js';
|
||||
|
||||
/**
|
||||
* InboxMixin — shared fetch, action, and render logic for the agent inbox.
|
||||
* InboxMixin — the Inbox *page*: fetching every pending item of this user and
|
||||
* laying them out in sections.
|
||||
*
|
||||
* The cards themselves, and the calls that resolve them, live in
|
||||
* [`InboxCardsMixin`] — the chat renders the same ones for the pending items of
|
||||
* the background tasks it started.
|
||||
*
|
||||
* Used by AgentInboxPage (full page) and DashboardPage (embedded section).
|
||||
*/
|
||||
export const InboxMixin = (Base) => class extends Base {
|
||||
export const InboxMixin = (Base) => class extends InboxCardsMixin(Base) {
|
||||
|
||||
static get properties() {
|
||||
return {
|
||||
...super.properties,
|
||||
_inboxData: { state: true },
|
||||
_inboxError: { state: true },
|
||||
_inboxLoading: { state: true },
|
||||
};
|
||||
}
|
||||
@@ -21,10 +25,7 @@ export const InboxMixin = (Base) => class extends Base {
|
||||
constructor() {
|
||||
super();
|
||||
this._inboxData = null;
|
||||
this._inboxError = null;
|
||||
this._inboxLoading = false;
|
||||
this._expanded = new Set();
|
||||
this._bypassOpen = new Set();
|
||||
}
|
||||
|
||||
// ── Data ──────────────────────────────────────────────────────────────────
|
||||
@@ -41,308 +42,9 @@ export const InboxMixin = (Base) => class extends Base {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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());
|
||||
await this._loadInbox();
|
||||
} 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());
|
||||
await this._loadInbox();
|
||||
} 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());
|
||||
await this._loadInbox();
|
||||
} catch (e) {
|
||||
this._inboxError = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
_toggleRaw(id) {
|
||||
if (this._expanded.has(id)) this._expanded.delete(id);
|
||||
else this._expanded.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._expanded.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>
|
||||
`;
|
||||
/** Every resolved item changes this page's own list. */
|
||||
async _afterInboxResolve() {
|
||||
await this._loadInbox();
|
||||
}
|
||||
|
||||
// ── Section renderer (used by both full page and home embed) ─────────────
|
||||
|
||||
Reference in New Issue
Block a user