Release 0.2.0 #4

Merged
dguiducci merged 96 commits from main into release 2026-08-17 18:07:20 +01:00
14 changed files with 838 additions and 328 deletions
Showing only changes of commit 01b8a187b5 - Show all commits
+8 -1
View File
@@ -384,7 +384,14 @@ async fn run_job(
} }
let handler = session.get_or_create_handler(session_id).await?; 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 job.kind == "async" {
if let Some(parent_id) = job.parent_session_id { if let Some(parent_id) = job.parent_session_id {
handler.set_scratchpad_session_id(parent_id); handler.set_scratchpad_session_id(parent_id);
@@ -148,6 +148,48 @@ pub async fn list_for_parent_session(
.collect()) .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: /// 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`, /// `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 /// 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")); 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; /// `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 /// both leave here as RFC 3339, or a browser reads one of them in the wrong
/// timezone and shows an elapsed counter hours off. /// timezone and shows an elapsed counter hours off.
+1 -1
View File
@@ -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 | | [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 | | [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 | | [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 | | [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 | | [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 | | [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 |
+12
View File
@@ -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 ✕. 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 ## When a user asks about them
Common questions and the honest answers: 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. - *"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. - *"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. - *"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. - *"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.
+86
View File
@@ -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 ───────────────────────────── // ── POST /api/inbox/approvals/:request_id/resolve ─────────────────────────────
#[derive(Deserialize)] #[derive(Deserialize)]
+3
View File
@@ -74,6 +74,9 @@ pub fn router() -> Router<Arc<Skald>> {
.route("/{source}/messages", get(sessions::source_messages)) .route("/{source}/messages", get(sessions::source_messages))
// Background (`execute_task mode=async`) tasks of this source's session. // Background (`execute_task mode=async`) tasks of this source's session.
.route("/{source}/tasks", get(cron::session_tasks)) .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 // File attachments: streamed to disk, so the default body-size limit is
// disabled on this route only. // disabled on this route only.
.route("/{source}/uploads", post(uploads::upload).layer(DefaultBodyLimit::disable())) .route("/{source}/uploads", post(uploads::upload).layer(DefaultBodyLimit::disable()))
+68 -1
View File
@@ -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) { export function renderTaskStrip(host) {
const tasks = host._tasks ?? []; 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; const running = tasks.filter(x => x.state === 'running').length;
return html` return html`
<div class="agent-tasks"> <div class="agent-tasks">
${renderPending(host)}
${tasks.length > 0 ? html`
<div class="agent-tasks-head"> <div class="agent-tasks-head">
<i class="bi bi-cpu"></i> <i class="bi bi-cpu"></i>
<span>${running > 0 <span>${running > 0
? t('chat.tasks.running_n', { n: running }) ? t('chat.tasks.running_n', { n: running })
: t('chat.tasks.title')}</span> : 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> <a class="agent-tasks-all" href="#tasks">${t('chat.tasks.see_all')}</a>
</div> </div>
${tasks.map(task => renderTask(host, task))} ${tasks.map(task => renderTask(host, task))}
` : nothing}
</div> </div>
`; `;
} }
+106
View File
@@ -39,6 +39,112 @@
.agent-tasks-all:hover { color: var(--accent); text-decoration: underline; } .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 ── */ /* ── One task ── */
.agent-task { .agent-task {
+4
View File
@@ -93,6 +93,10 @@ export default {
'chat.tasks.failed': 'failed', 'chat.tasks.failed': 'failed',
'chat.tasks.cancelled': 'stopped', 'chat.tasks.cancelled': 'stopped',
'chat.tasks.see_all': 'All tasks', '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 render ─────────────────────────────────────────────────────────
'copilot.open_in_viewer': 'Open in viewer', 'copilot.open_in_viewer': 'Open in viewer',
+4
View File
@@ -93,6 +93,10 @@ export default {
'chat.tasks.failed': 'échouée', 'chat.tasks.failed': 'échouée',
'chat.tasks.cancelled': 'arrêtée', 'chat.tasks.cancelled': 'arrêtée',
'chat.tasks.see_all': 'Toutes les tâches', '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 render ─────────────────────────────────────────────────────────
'copilot.open_in_viewer': 'Ouvrir dans le visualiseur', 'copilot.open_in_viewer': 'Ouvrir dans le visualiseur',
+4
View File
@@ -93,6 +93,10 @@ export default {
'chat.tasks.failed': 'fallita', 'chat.tasks.failed': 'fallita',
'chat.tasks.cancelled': 'fermata', 'chat.tasks.cancelled': 'fermata',
'chat.tasks.see_all': 'Tutte le attività', '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 render ─────────────────────────────────────────────────────────
'copilot.open_in_viewer': 'Apri nel visualizzatore', 'copilot.open_in_viewer': 'Apri nel visualizzatore',
+109 -6
View File
@@ -1,5 +1,6 @@
import { html, nothing } from 'lit'; import { html, nothing } from 'lit';
import { LightElement } from './base.js'; import { LightElement } from './base.js';
import { InboxCardsMixin } from './inbox-cards.js';
import { t } from './i18n.js'; import { t } from './i18n.js';
import { isSessionExpired, notifySessionExpired, probeSession } from './session-expiry.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 * 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. * "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 = { static properties = {
_messages: { state: true }, _messages: { state: true },
_waiting: { state: true }, _waiting: { state: true },
@@ -64,6 +65,9 @@ export class ChatSession extends LightElement {
_tasks: { state: true }, _tasks: { state: true },
// Bumped once a second while a task is running, so the elapsed counters move. // Bumped once a second while a task is running, so the elapsed counters move.
_taskTick: { state: true }, _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 // 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. // is in flight the entry has `uploading: true` and no `path` yet.
this._attachments = []; this._attachments = [];
this._tasks = []; this._tasks = [];
this._taskInbox = { approvals: [], clarifications: [] };
this._taskTick = 0; this._taskTick = 0;
this._taskTimer = null; this._taskTimer = null;
// Timers that drop a finished task from the strip after a grace period. // 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 // Fire-and-forget: availability of a transcription provider determines
// whether the mic button is rendered at all. // whether the mic button is rendered at all.
this._checkTranscribe(); this._checkTranscribe();
await Promise.all([this._loadProviders(), this._loadHistory(), this._loadTasks()]); await Promise.all([
this._loadProviders(), this._loadHistory(), this._loadTasks(), this._loadTaskInbox(),
]);
this._connectWS(); this._connectWS();
} }
@@ -160,7 +167,8 @@ export class ChatSession extends LightElement {
this._messages = []; this._messages = [];
this._waiting = false; this._waiting = false;
this._tasks = []; this._tasks = [];
await Promise.all([this._loadHistory(), this._loadTasks()]); this._taskInbox = { approvals: [], clarifications: [] };
await Promise.all([this._loadHistory(), this._loadTasks(), this._loadTaskInbox()]);
this._connectWS(); this._connectWS();
} }
@@ -336,6 +344,86 @@ export class ChatSession extends LightElement {
this._taskTimer = null; 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 ───────────────────────────────────────────────────────────────── // ── WebSocket ─────────────────────────────────────────────────────────────────
_connectWS() { _connectWS() {
@@ -352,8 +440,10 @@ export class ChatSession extends LightElement {
this._reconnecting = false; this._reconnecting = false;
this._resyncOnReconnect(); this._resyncOnReconnect();
// A task that ended while the socket was down emitted its `task_update` // 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._loadTasks();
this._loadTaskInbox();
} }
if (this._hasPendingTools) { if (this._hasPendingTools) {
ws.send(JSON.stringify({ type: 'resume' })); ws.send(JSON.stringify({ type: 'resume' }));
@@ -599,6 +689,9 @@ export class ChatSession extends LightElement {
case 'approval_resolved': { case 'approval_resolved': {
const { request_id, tool_call_id, approved } = msg; const { request_id, tool_call_id, approved } = msg;
window.dispatchEvent(new CustomEvent('inbox-changed')); 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' }); this._updatePendingWrite(request_id, { status: approved ? 'approved' : 'rejected' });
if (tool_call_id != null) { if (tool_call_id != null) {
if (approved) { if (approved) {
@@ -616,12 +709,22 @@ export class ChatSession extends LightElement {
case 'approval_requested': case 'approval_requested':
case 'clarification_requested': case 'clarification_requested':
case 'clarification_resolved': case 'clarification_resolved':
case 'elicitation_requested':
case 'elicitation_resolved':
// Inbox lifecycle from any of this user's sessions (chat, cron, // Inbox lifecycle from any of this user's sessions (chat, cron,
// background): nudge listeners (sidebar badge, inbox page) to refresh // background): nudge listeners (sidebar badge, inbox page) to refresh
// immediately instead of waiting for the next poll. // immediately instead of waiting for the next poll.
window.dispatchEvent(new CustomEvent('inbox-changed')); 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; break;
case 'agent_question': case 'agent_question':
+349
View File
@@ -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
View File
@@ -1,19 +1,23 @@
import { html, nothing } from 'lit'; import { html, nothing } from 'lit';
import { unsafeHTML } from 'lit/directives/unsafe-html.js'; import { InboxCardsMixin } from './inbox-cards.js';
import { renderMarkdown } from './base.js';
import { t } from './i18n.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). * 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() { static get properties() {
return { return {
...super.properties, ...super.properties,
_inboxData: { state: true }, _inboxData: { state: true },
_inboxError: { state: true },
_inboxLoading: { state: true }, _inboxLoading: { state: true },
}; };
} }
@@ -21,10 +25,7 @@ export const InboxMixin = (Base) => class extends Base {
constructor() { constructor() {
super(); super();
this._inboxData = null; this._inboxData = null;
this._inboxError = null;
this._inboxLoading = false; this._inboxLoading = false;
this._expanded = new Set();
this._bypassOpen = new Set();
} }
// ── Data ────────────────────────────────────────────────────────────────── // ── Data ──────────────────────────────────────────────────────────────────
@@ -41,308 +42,9 @@ export const InboxMixin = (Base) => class extends Base {
} }
} }
// ── Actions ─────────────────────────────────────────────────────────────── /** Every resolved item changes this page's own list. */
async _afterInboxResolve() {
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(); 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) ───────────── // ── Section renderer (used by both full page and home embed) ─────────────