diff --git a/crates/skald-core/src/cron/mod.rs b/crates/skald-core/src/cron/mod.rs index 01ae9db..613fa70 100644 --- a/crates/skald-core/src/cron/mod.rs +++ b/crates/skald-core/src/cron/mod.rs @@ -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); diff --git a/crates/skald-core/src/db/scheduled_jobs.rs b/crates/skald-core/src/db/scheduled_jobs.rs index e2a2484..66a9514 100644 --- a/crates/skald-core/src/db/scheduled_jobs.rs +++ b/crates/skald-core/src/db/scheduled_jobs.rs @@ -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> { + 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. diff --git a/docs/index.md b/docs/index.md index 25c0b05..4128ee6 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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 | diff --git a/docs/tasks.md b/docs/tasks.md index 0a1696e..4749dd4 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -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. diff --git a/src/frontend/api/inbox.rs b/src/frontend/api/inbox.rs index 96b9d5e..0daf81a 100644 --- a/src/frontend/api/inbox.rs +++ b/src/frontend/api/inbox.rs @@ -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, + pub clarifications: Vec, +} + +pub async fn session_task_inbox( + State(skald): State>, + Extension(auth): Extension, + Path(p): Path, +) -> Result, 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 = + 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)] diff --git a/src/frontend/api/mod.rs b/src/frontend/api/mod.rs index 2bd503f..ff0824c 100644 --- a/src/frontend/api/mod.rs +++ b/src/frontend/api/mod.rs @@ -74,6 +74,9 @@ pub fn router() -> Router> { .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())) diff --git a/web/components/shared/agent-tasks.js b/web/components/shared/agent-tasks.js index 1b6d173..3456cb6 100644 --- a/web/components/shared/agent-tasks.js +++ b/web/components/shared/agent-tasks.js @@ -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` +
+
+ + + ${t('chat.tasks.needs_you')} + ${item.job_title} + ${pending.length > 1 + ? html` + ${t('chat.tasks.pending_n', { n: pending.length })} + ` + : nothing} +
+ ${host._inboxError + ? html`
${host._inboxError}
` + : nothing} + ${item.kind === 'approval' + ? host._renderApprovalCard(item) + : host._renderClarificationCard(item)} +
+ `; +} + 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`
-
- - ${running > 0 - ? t('chat.tasks.running_n', { n: running }) - : t('chat.tasks.title')} - ${t('chat.tasks.see_all')} -
- ${tasks.map(task => renderTask(host, task))} + ${renderPending(host)} + ${tasks.length > 0 ? html` +
+ + ${running > 0 + ? t('chat.tasks.running_n', { n: running }) + : t('chat.tasks.title')} + ${hidden > 0 + ? html` + ${t('chat.tasks.hidden_n', { n: hidden })} + ` + : nothing} + ${t('chat.tasks.see_all')} +
+ ${tasks.map(task => renderTask(host, task))} + ` : nothing}
`; } diff --git a/web/css/agent-tasks.css b/web/css/agent-tasks.css index e53d37a..f424d07 100644 --- a/web/css/agent-tasks.css +++ b/web/css/agent-tasks.css @@ -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 { diff --git a/web/i18n/en.js b/web/i18n/en.js index 62650a1..9822783 100644 --- a/web/i18n/en.js +++ b/web/i18n/en.js @@ -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', diff --git a/web/i18n/fr.js b/web/i18n/fr.js index b5805e0..ce39017 100644 --- a/web/i18n/fr.js +++ b/web/i18n/fr.js @@ -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', diff --git a/web/i18n/it.js b/web/i18n/it.js index 2b42794..4c8aa85 100644 --- a/web/i18n/it.js +++ b/web/i18n/it.js @@ -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', diff --git a/web/lib/chat-session.js b/web/lib/chat-session.js index c0ab34e..4e6ae1a 100644 --- a/web/lib/chat-session.js +++ b/web/lib/chat-session.js @@ -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': diff --git a/web/lib/inbox-cards.js b/web/lib/inbox-cards.js new file mode 100644 index 0000000..13e6966 --- /dev/null +++ b/web/lib/inbox-cards.js @@ -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` +
+
+ Approval + ${label} + ${this._fmt(item.created_at)} +
+ +
+
+ + ${item.tool_name} + + ${item.agent_id} + +
+ + ${keyArgs.length > 0 ? html` +
+ ${keyArgs.map(kv => html` +
+ ${kv.key} + ${kv.value} +
+ `)} +
+ ` : nothing} + + +
${rawJson}
+
+ + +
+ `; + } + + _renderClarificationCard(item) { + const label = item.context_label ?? item.source; + + return html` +
+
+ Question + ${label} + ${this._fmt(item.created_at)} +
+ +
+
${item.title}
+
${unsafeHTML(renderMarkdown(item.question))}
+ + ${item.suggested_answers?.length ? html` +
+ ${item.suggested_answers.map(a => html` + + `)} +
+ ` : nothing} + +
+ + +
+
+
+ `; + } + + _renderElicitationCard(item) { + const masked = item.sensitive; + const confirm = item.is_confirmation; + + return html` +
+
+ + + ${confirm ? 'Conferma' : 'Input'} + + ${item.server_name} + ${this._fmt(item.created_at)} +
+ +
+
${item.message}
+ + ${confirm ? nothing : html` +
+ { + if (e.key === 'Enter') { + e.preventDefault(); + this._resolveElicitation(item, 'accept', e.target); + } + }}> +
+ `} +
+ + +
+ `; + } +}; diff --git a/web/lib/inbox-mixin.js b/web/lib/inbox-mixin.js index 1cfdc01..3fe7dce 100644 --- a/web/lib/inbox-mixin.js +++ b/web/lib/inbox-mixin.js @@ -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` -
-
- Approval - ${label} - ${this._fmt(item.created_at)} -
- -
-
- - ${item.tool_name} - - ${item.agent_id} - -
- - ${keyArgs.length > 0 ? html` -
- ${keyArgs.map(kv => html` -
- ${kv.key} - ${kv.value} -
- `)} -
- ` : nothing} - - -
${rawJson}
-
- - -
- `; - } - - _renderClarificationCard(item) { - const label = item.context_label ?? item.source; - - return html` -
-
- Question - ${label} - ${this._fmt(item.created_at)} -
- -
-
${item.title}
-
${unsafeHTML(renderMarkdown(item.question))}
- - ${item.suggested_answers?.length ? html` -
- ${item.suggested_answers.map(a => html` - - `)} -
- ` : nothing} - -
- - -
-
-
- `; - } - - _renderElicitationCard(item) { - const masked = item.sensitive; - const confirm = item.is_confirmation; - - return html` -
-
- - - ${confirm ? 'Conferma' : 'Input'} - - ${item.server_name} - ${this._fmt(item.created_at)} -
- -
-
${item.message}
- - ${confirm ? nothing : html` -
- { - if (e.key === 'Enter') { - e.preventDefault(); - this._resolveElicitation(item, 'accept', e.target); - } - }}> -
- `} -
- - -
- `; + /** Every resolved item changes this page's own list. */ + async _afterInboxResolve() { + await this._loadInbox(); } // ── Section renderer (used by both full page and home embed) ─────────────