Release 0.2.0 #4
@@ -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 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">
|
||||
${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>
|
||||
`;
|
||||
}
|
||||
};
|
||||
+11
-309
@@ -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());
|
||||
/** Every resolved item changes this page's own list. */
|
||||
async _afterInboxResolve() {
|
||||
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>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Section renderer (used by both full page and home embed) ─────────────
|
||||
|
||||
Reference in New Issue
Block a user