Files
Skald-Circle/web/css/agent-tasks.css
T
dguiducci daaceff6ba
Nightly Build / build (push) Successful in 7m34s
feat: show a conversation its own background tasks, and give it back every outcome
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.
2026-08-04 19:13:30 +01:00

152 lines
3.5 KiB
CSS

/* ── 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; }
}