feat: show a conversation its own background tasks, and give it back every outcome
Nightly Build / build (push) Successful in 7m34s

An `execute_task mode="async"` was invisible from the chat that started it.
The only trace was the receipt in the transcript and a row on the Tasks page
— which does not say *which* of those rows the assistant just spawned — so
"is it still going?" had no answer where the question is asked.

Worse, a task that did not simply succeed never came back at all. `run_job`
branched on `Ok`/`Err` first and routed by `job.kind` only inside the `Ok`
arm, so a failure or a kill left through the `Err` arm's unconditional
`hub.notify` — the home source (`/sethome`), worded "Cron job … failed" —
while the parent conversation sat waiting for a `task_completed` that would
never arrive. The wrong chat, and a wedged one.

The fix is a shape, not a branch: one `JobOutcome` classification, then one
`match job.kind` delivery site for every ending. An async task now ends in
its parent conversation whatever happened to it. The sink has a single
channel deliberately — to the model reading it, "it broke" is a result like
any other and must not be overlookable — so a failure is delivered as prose,
carrying whatever partial output the run produced, which is usually the only
clue about why. A cron job keeps the home notification: it belongs to nobody's
conversation. Cancellation becomes a third outcome rather than a flavour of
failure (`job_runs.status` has always had `'cancelled'` in its CHECK and
nothing ever wrote it), classified off the new typed `TurnCancelled` error so
nothing keys on a message string.

The strip above the composer is the visible half. `ServerEvent::TaskUpdate`
announces state to the source of the parent conversation only; the list is
`renderTaskStrip` (shared by the desktop copilot and the mobile chat), fed by
state on `ChatSession`. Each row links to `#session/{id}` — the page that
already shows, live, what a background agent is doing, and without which
"a task is running" is a fact you can do nothing with. Stopping is the
existing kill endpoint. A finished row clears itself after 20 s (its result
is in the conversation by then); a failed one stays until dismissed, and the
dismissal is remembered across reloads.

`GET /api/{source}/tasks` is what makes the strip survive a browser refresh:
the event is a broadcast with no replay, so without a load-time read a reload
would empty a chat that still has work running under it. It answers with the
running tasks plus failures from the last 30 minutes — the two states a person
can still act on. Successes are absent on purpose. Its window compares through
`datetime()` on both sides: `completed_at` is RFC 3339 and the cutoff is
SQLite-shaped, and `'T' > ' '` would let every same-day row through a window
meant to exclude it.

Not addressed, and worth doing next: a cron job's result should go where its
creator says, not always to the home chat.
This commit is contained in:
2026-08-04 19:13:30 +01:00
parent e356741435
commit daaceff6ba
21 changed files with 949 additions and 76 deletions
+6
View File
@@ -2,6 +2,7 @@ import { html, nothing } from 'lit';
import { ChatSession } from '../lib/chat-session.js';
import { t, I18nMixin } from '../lib/i18n.js';
import { renderMsg, renderAttachmentChips } from './copilot-render.js';
import { renderTaskStrip } from './shared/agent-tasks.js';
// Built-in (server-handled) slash commands shown at the top of the composer
// autocomplete. Custom commands (from `commands/<name>/`) are fetched from
@@ -59,6 +60,10 @@ export class AppCopilot extends I18nMixin(ChatSession) {
this._onPageChange = this._onPageChange.bind(this);
}
// The desktop shell routes `#session/{id}`, so a background task's row links
// through to what it is doing.
get _canOpenTaskSession() { return true; }
connectedCallback() {
super.connectedCallback?.();
this._restoreState();
@@ -398,6 +403,7 @@ export class AppCopilot extends I18nMixin(ChatSession) {
</div>
<div class="copilot-input-area">
${renderTaskStrip(this)}
${this._renderNoModelsBanner()}
<div class="copilot-composer"
@dragover=${(e) => e.preventDefault()}
+98
View File
@@ -0,0 +1,98 @@
import { html, nothing } from 'lit';
import { t } from '../../lib/i18n.js';
/**
* The background-task strip: what the agent handed off to run on its own.
*
* Rendered just above the composer, and only when there is something to say —
* an empty strip would be a permanent slice of chrome for an occasional event.
* It sits outside the message flow on purpose: a task started twenty messages
* ago scrolls away exactly when it matters most, and the transcript is a record
* of what was said, not a dashboard.
*
* State lives on the chat component (`ChatSession._tasks`), which owns the
* WebSocket the updates arrive on; this file only knows how it looks. Shared by
* the desktop copilot and the mobile chat page.
*/
const STATE_ICON = {
running: 'bi-arrow-repeat agent-task-spin',
completed: 'bi-check-circle-fill',
failed: 'bi-exclamation-triangle-fill',
cancelled: 'bi-slash-circle',
};
/** Elapsed time since an ISO timestamp, coarse on purpose (`12s`, `4m 03s`). */
function elapsed(startedAt) {
if (!startedAt) return '';
const ms = Date.now() - new Date(startedAt).getTime();
if (!Number.isFinite(ms) || ms < 0) return '';
const s = Math.floor(ms / 1000);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ${String(s % 60).padStart(2, '0')}s`;
return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m`;
}
function renderTask(host, task) {
const running = task.state === 'running';
// The task's own session page is the drill-in: it already shows, live, what a
// background agent is doing. Without it "a task is running" is a fact you can
// do nothing with.
const openable = task.session_id != null && host._canOpenTaskSession;
return html`
<div class="agent-task agent-task--${task.state}">
<i class="bi ${STATE_ICON[task.state] ?? 'bi-hourglass'} agent-task-icon"></i>
<button
class="agent-task-body"
?disabled=${!openable}
title=${openable ? t('chat.tasks.open') : ''}
@click=${() => { if (openable) window.location.hash = `session/${task.session_id}`; }}
>
<span class="agent-task-title">${task.title}</span>
<span class="agent-task-meta">
${task.agent_id}
${running ? html`· ${elapsed(task.started_at)}` : nothing}
${task.state === 'failed' ? html`· ${t('chat.tasks.failed')}` : nothing}
${task.state === 'completed' ? html`· ${t('chat.tasks.completed')}` : nothing}
${task.state === 'cancelled' ? html`· ${t('chat.tasks.cancelled')}` : nothing}
</span>
${task.error && task.state === 'failed'
? html`<span class="agent-task-error" title=${task.error}>${task.error}</span>`
: nothing}
</button>
${running
? html`<button class="agent-task-action" title=${t('chat.tasks.stop')}
@click=${() => host._stopTask(task.job_id)}>
<i class="bi bi-stop-fill"></i>
</button>`
: html`<button class="agent-task-action" title=${t('chat.tasks.dismiss')}
@click=${() => host._dismissTask(task.job_id)}>
<i class="bi bi-x"></i>
</button>`}
</div>
`;
}
export function renderTaskStrip(host) {
const tasks = host._tasks ?? [];
if (tasks.length === 0) return nothing;
const running = tasks.filter(x => x.state === 'running').length;
return html`
<div class="agent-tasks">
<div class="agent-tasks-head">
<i class="bi bi-cpu"></i>
<span>${running > 0
? t('chat.tasks.running_n', { n: running })
: t('chat.tasks.title')}</span>
<a class="agent-tasks-all" href="#tasks">${t('chat.tasks.see_all')}</a>
</div>
${tasks.map(task => renderTask(host, task))}
</div>
`;
}
+2
View File
@@ -2,6 +2,7 @@ import { html, nothing } from 'lit';
import { ChatSession } from '../../lib/chat-session.js';
import { t } from '../../lib/i18n.js';
import { renderMsg, renderAttachmentChips } from '../copilot-render.js';
import { renderTaskStrip } from './agent-tasks.js';
export class ChatPage extends ChatSession {
static properties = {
@@ -193,6 +194,7 @@ export class ChatPage extends ChatSession {
</div>
<div class="chat-page-input-area">
${renderTaskStrip(this)}
${this._renderNoModelsBanner()}
<div class="chat-page-composer"
@dragover=${(e) => e.preventDefault()}
+151
View File
@@ -0,0 +1,151 @@
/* ── Background-task strip ──────────────────────────────────────────────────
The list of `execute_task mode=async` tasks the current conversation started,
rendered above the composer by `renderTaskStrip` (shared by the desktop
copilot and the mobile chat page). Quiet by default: it sits next to the
composer, so it must read as a status line, never as a second UI. */
.agent-tasks {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-bottom: 0.5rem;
padding: 0.4rem 0.45rem 0.45rem;
border: 1px solid var(--toolbar-border);
border-radius: var(--radius-sm);
background: var(--msg-assistant-bg);
}
.agent-tasks-head {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0 0.25rem 0.1rem;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
color: var(--placeholder-color);
}
.agent-tasks-all {
margin-left: auto;
font-size: 0.7rem;
font-weight: 500;
text-transform: none;
letter-spacing: 0;
color: var(--placeholder-color);
text-decoration: none;
}
.agent-tasks-all:hover { color: var(--accent); text-decoration: underline; }
/* ── One task ── */
.agent-task {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.3rem 0.4rem;
border-radius: var(--radius-sm);
background: var(--card-bg);
border: 1px solid transparent;
}
.agent-task--failed {
border-color: rgba(var(--bs-danger-rgb, 220, 53, 69), 0.35);
}
.agent-task--completed,
.agent-task--cancelled { opacity: 0.7; }
.agent-task-icon {
flex-shrink: 0;
font-size: 0.85rem;
color: var(--accent);
}
.agent-task--completed .agent-task-icon { color: var(--tool-subagent); }
.agent-task--failed .agent-task-icon { color: var(--bs-danger, #dc3545); }
.agent-task--cancelled .agent-task-icon { color: var(--placeholder-color); }
/* The body is a button: clicking opens the task's own session page. */
.agent-task-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.05rem;
padding: 0;
border: none;
background: none;
text-align: left;
color: inherit;
cursor: pointer;
}
.agent-task-body:disabled { cursor: default; }
.agent-task-title {
max-width: 100%;
font-size: 0.8rem;
font-weight: 500;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.agent-task-body:not(:disabled):hover .agent-task-title {
color: var(--accent);
text-decoration: underline;
}
.agent-task-meta,
.agent-task-error {
max-width: 100%;
font-size: 0.7rem;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--placeholder-color);
}
.agent-task-error { color: var(--bs-danger, #dc3545); }
.agent-task-action {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
padding: 0;
border: none;
border-radius: var(--radius-sm);
background: none;
color: var(--placeholder-color);
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.agent-task-action:hover {
background: var(--accent-soft);
color: var(--accent);
}
/* ── The running spinner ── */
.agent-task-spin {
display: inline-block;
animation: agent-task-spin 1.4s linear infinite;
}
@keyframes agent-task-spin {
to { transform: rotate(360deg); }
}
@media (prefers-reduced-motion: reduce) {
.agent-task-spin { animation: none; }
}
+9
View File
@@ -84,6 +84,15 @@ export default {
'chat.mic.unsupported': 'This browser does not support voice recording.',
'chat.mic.denied': 'Microphone access was denied. Allow it for this site in your browser settings, then try again.',
'chat.mic.failed': 'Could not start recording: {error}',
'chat.tasks.title': 'Background tasks',
'chat.tasks.running_n': 'Background tasks · {n} running',
'chat.tasks.open': 'See what this task is doing',
'chat.tasks.stop': 'Stop this task',
'chat.tasks.dismiss': 'Remove from the list',
'chat.tasks.completed': 'done',
'chat.tasks.failed': 'failed',
'chat.tasks.cancelled': 'stopped',
'chat.tasks.see_all': 'All tasks',
// ── Copilot render ─────────────────────────────────────────────────────────
'copilot.open_in_viewer': 'Open in viewer',
+9
View File
@@ -84,6 +84,15 @@ export default {
'chat.mic.unsupported': 'Ce navigateur ne prend pas en charge l\'enregistrement vocal.',
'chat.mic.denied': 'Accès au microphone refusé. Autorisez-le pour ce site dans les réglages du navigateur, puis réessayez.',
'chat.mic.failed': 'Impossible de démarrer l\'enregistrement : {error}',
'chat.tasks.title': 'Tâches en arrière-plan',
'chat.tasks.running_n': 'Tâches en arrière-plan · {n} en cours',
'chat.tasks.open': 'Voir ce que fait cette tâche',
'chat.tasks.stop': 'Arrêter cette tâche',
'chat.tasks.dismiss': 'Retirer de la liste',
'chat.tasks.completed': 'terminée',
'chat.tasks.failed': 'échouée',
'chat.tasks.cancelled': 'arrêtée',
'chat.tasks.see_all': 'Toutes les tâches',
// ── Copilot render ─────────────────────────────────────────────────────────
'copilot.open_in_viewer': 'Ouvrir dans le visualiseur',
+9
View File
@@ -84,6 +84,15 @@ export default {
'chat.mic.unsupported': 'Questo browser non supporta la registrazione vocale.',
'chat.mic.denied': 'Accesso al microfono negato. Consentilo per questo sito nelle impostazioni del browser e riprova.',
'chat.mic.failed': 'Impossibile avviare la registrazione: {error}',
'chat.tasks.title': 'Attività in background',
'chat.tasks.running_n': 'Attività in background · {n} in corso',
'chat.tasks.open': 'Guarda cosa sta facendo questa attività',
'chat.tasks.stop': 'Ferma questa attività',
'chat.tasks.dismiss': 'Togli dalla lista',
'chat.tasks.completed': 'completata',
'chat.tasks.failed': 'fallita',
'chat.tasks.cancelled': 'fermata',
'chat.tasks.see_all': 'Tutte le attività',
// ── Copilot render ─────────────────────────────────────────────────────────
'copilot.open_in_viewer': 'Apri nel visualizzatore',
+1
View File
@@ -45,6 +45,7 @@
<link rel="stylesheet" href="css/copilot.css" />
<link rel="stylesheet" href="css/copilot-messages.css" />
<link rel="stylesheet" href="css/copilot-input.css" />
<link rel="stylesheet" href="css/agent-tasks.css" />
<link rel="stylesheet" href="css/dialogs.css" />
<link rel="stylesheet" href="css/page-shell.css" />
<link rel="stylesheet" href="css/page-header.css" />
+148 -2
View File
@@ -59,6 +59,11 @@ export class ChatSession extends LightElement {
// Pending attachments for the message being composed (shown as chips above
// the textarea; uploaded to disk on selection, sent with the next message).
_attachments: { state: true },
// Background tasks (`execute_task mode=async`) this conversation started.
// Each entry: { job_id, title, agent_id, session_id, state, error, started_at }.
_tasks: { state: true },
// Bumped once a second while a task is running, so the elapsed counters move.
_taskTick: { state: true },
};
// Live events whose arrival implies a turn is in flight (used to restore the
@@ -110,6 +115,11 @@ export class ChatSession extends LightElement {
// Each entry: { name, path, mimetype, filesize, uploading? }. While an upload
// is in flight the entry has `uploading: true` and no `path` yet.
this._attachments = [];
this._tasks = [];
this._taskTick = 0;
this._taskTimer = null;
// Timers that drop a finished task from the strip after a grace period.
this._taskDropTimers = new Map();
this._onAuthRestored = this._onAuthRestored.bind(this);
}
@@ -119,13 +129,16 @@ 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()]);
await Promise.all([this._loadProviders(), this._loadHistory(), this._loadTasks()]);
this._connectWS();
}
disconnectedCallback() {
super.disconnectedCallback?.();
window.removeEventListener('auth-restored', this._onAuthRestored);
this._stopTaskClock();
for (const timer of this._taskDropTimers.values()) clearTimeout(timer);
this._taskDropTimers.clear();
}
// ── Source identity — override in subclass ────────────────────────────────────
@@ -146,7 +159,8 @@ export class ChatSession extends LightElement {
this._activeSource = source;
this._messages = [];
this._waiting = false;
await this._loadHistory();
this._tasks = [];
await Promise.all([this._loadHistory(), this._loadTasks()]);
this._connectWS();
}
@@ -209,6 +223,119 @@ export class ChatSession extends LightElement {
}
}
// ── Background tasks ──────────────────────────────────────────────────────────
//
// The agent can hand work to a background task (`execute_task mode="async"`)
// and keep talking. Until now the only trace of one was the receipt in the
// transcript and a row on the Tasks page, so "is it still going?" had no
// answer in the chat itself. `_tasks` is that answer: a small live list of
// the tasks *this* conversation started.
//
// Live updates arrive as `task_update` over the WebSocket — a broadcast with
// no replay, which is why `_loadTasks` runs on every load and reconnect: it
// is what makes the strip survive a browser refresh.
// A finished task lingers this long before disappearing on its own. Its result
// is already in the conversation by then; the chip is just the hand-off.
static TASK_DROP_MS = 20000;
/**
* Whether this surface can open a task's own session page (`#session/{id}`).
* The mobile shell routes a fixed set of sections and silently falls back to
* the chat for anything else, so there the row is shown without a link rather
* than with one that quietly navigates somewhere wrong.
*/
get _canOpenTaskSession() { return false; }
// Failures the user has dismissed with the ✕. Kept across reloads (the server
// keeps reporting a recent failure, and dismissing it should mean dismissed).
static _DISMISSED_KEY = 'skald.dismissedTasks';
_dismissedTasks() {
try {
return new Set(JSON.parse(localStorage.getItem(ChatSession._DISMISSED_KEY) ?? '[]'));
} catch { return new Set(); }
}
_rememberDismissed(jobId) {
const ids = [...this._dismissedTasks(), jobId].slice(-50);
try { localStorage.setItem(ChatSession._DISMISSED_KEY, JSON.stringify(ids)); } catch { /* private mode */ }
}
async _loadTasks() {
try {
const res = await fetch(`/api/${this._source}/tasks`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const dismissed = this._dismissedTasks();
this._tasks = (await res.json()).filter(t => !dismissed.has(t.job_id));
this._syncTaskClock();
} catch (e) {
console.warn('Could not load background tasks:', e.message);
}
}
/** Insert or advance one task from a `task_update` event. */
_upsertTask(task) {
if (this._dismissedTasks().has(task.job_id)) return;
const idx = this._tasks.findIndex(t => t.job_id === task.job_id);
const prev = idx >= 0 ? this._tasks[idx] : null;
// Keep the fields the event does not carry (a terminal update has no
// `started_at`, and the elapsed counter should not reset at the finish line).
const next = { ...prev, ...task, started_at: task.started_at ?? prev?.started_at ?? null };
this._tasks = idx >= 0
? this._tasks.map((t, i) => (i === idx ? next : t))
: [...this._tasks, next];
// A failure stays until dismissed — it is the only place the reason is
// readable at a glance. Everything else clears itself.
if (next.state === 'completed' || next.state === 'cancelled') {
this._scheduleTaskDrop(next.job_id);
}
this._syncTaskClock();
}
_scheduleTaskDrop(jobId) {
clearTimeout(this._taskDropTimers.get(jobId));
this._taskDropTimers.set(jobId, setTimeout(() => {
this._taskDropTimers.delete(jobId);
this._tasks = this._tasks.filter(t => t.job_id !== jobId);
this._syncTaskClock();
}, ChatSession.TASK_DROP_MS));
}
_dismissTask(jobId) {
this._rememberDismissed(jobId);
clearTimeout(this._taskDropTimers.get(jobId));
this._taskDropTimers.delete(jobId);
this._tasks = this._tasks.filter(t => t.job_id !== jobId);
this._syncTaskClock();
}
/** Stop a running task. The kill lands as a `task_update` like any other end. */
async _stopTask(jobId) {
try {
const res = await fetch(`/api/cron/jobs/${jobId}/kill`, { method: 'POST' });
if (!res.ok) throw new Error(await res.text() || `HTTP ${res.status}`);
} catch (e) {
console.warn('Could not stop task:', e.message);
}
}
/** The 1 s clock runs only while something is actually running. */
_syncTaskClock() {
const running = this._tasks.some(t => t.state === 'running');
if (running && !this._taskTimer) {
this._taskTimer = setInterval(() => { this._taskTick++; }, 1000);
} else if (!running) {
this._stopTaskClock();
}
}
_stopTaskClock() {
clearInterval(this._taskTimer);
this._taskTimer = null;
}
// ── WebSocket ─────────────────────────────────────────────────────────────────
_connectWS() {
@@ -224,6 +351,9 @@ export class ChatSession extends LightElement {
if (this._reconnecting) {
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.
this._loadTasks();
}
if (this._hasPendingTools) {
ws.send(JSON.stringify({ type: 'resume' }));
@@ -315,6 +445,9 @@ export class ChatSession extends LightElement {
this._cancelStreamFlush();
this._messages = [];
this._waiting = false;
// Tasks belong to the conversation that started them; this is a new one.
this._tasks = [];
this._stopTaskClock();
try {
const res = await fetch(`/api/sessions?source=${this._source}`, { method: 'POST' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
@@ -605,6 +738,19 @@ export class ChatSession extends LightElement {
this._selectedGroup = msg.group;
break;
case 'task_update':
// A background task this conversation started changed state.
this._upsertTask({
job_id: msg.job_id,
title: msg.title,
agent_id: msg.agent_id,
session_id: msg.session_id ?? null,
state: msg.state,
error: msg.error ?? null,
started_at: msg.state === 'running' ? new Date().toISOString() : null,
});
break;
case 'llm_failed':
this._waiting = false;
this._dropStreaming();
+1
View File
@@ -43,6 +43,7 @@
<link rel="stylesheet" href="css/variables.css" />
<link rel="stylesheet" href="css/setup-page.css" />
<link rel="stylesheet" href="css/copilot-messages.css" />
<link rel="stylesheet" href="css/agent-tasks.css" />
<link rel="stylesheet" href="css/inbox-cards.css" />
<link rel="stylesheet" href="css/file-viewer.css" />
<link rel="stylesheet" href="css/mobile.css" />