feat: show a conversation its own background tasks, and give it back every outcome
Nightly Build / build (push) Successful in 7m34s
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:
@@ -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; }
|
||||
}
|
||||
Reference in New Issue
Block a user