dismiss background task chips one at a time

the tray only had a bulk clear, so getting rid of one finished chip meant
clearing all of them. each finished chip now carries its own close control.

the pill becomes a div wrapping two buttons — a button nested inside a button
is invalid and the browser eats one of the two clicks. running chips stay
undismissable: the tray is the only handle on work still going.
This commit is contained in:
2026-08-07 20:38:16 +00:00
parent a70e4e7296
commit 5624ed8e66
2 changed files with 58 additions and 23 deletions
@@ -28,7 +28,7 @@ function statusTone(status: BackgroundTask['status']): Tone {
* tail of a backgrounded shell's log, both read from the file Claude Code streams the task into.
*/
export const BackgroundTaskTray = ({ messages }: BackgroundTaskTrayProps) => {
const { tasks, running, dismissFinished } = useBackgroundTasks(messages);
const { tasks, running, dismiss, dismissFinished } = useBackgroundTasks(messages);
const [openId, setOpenId] = useState<string | null>(null);
// Derived, not stored: dismissing the chip that is open closes the panel without a second piece of state
@@ -54,6 +54,9 @@ export const BackgroundTaskTray = ({ messages }: BackgroundTaskTrayProps) => {
task={task}
active={task.taskId === openId}
onClick={() => setOpenId(task.taskId === openId ? null : task.taskId)}
// Finished only. A running chip is the only handle on work still going on, so closing one
// would hide something you cannot get back to — the bulk clear draws the same line.
onDismiss={task.status ? () => dismiss(task.taskId) : undefined}
/>
))}
</div>
@@ -61,8 +64,8 @@ export const BackgroundTaskTray = ({ messages }: BackgroundTaskTrayProps) => {
<button
type="button"
onClick={dismissFinished}
title="Clear finished"
aria-label="Clear finished tasks"
title="Clear all finished"
aria-label="Clear all finished tasks"
className="shrink-0 cursor-pointer rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<X className="h-3.5 w-3.5" />
@@ -79,36 +82,59 @@ type TaskChipProps = {
task: BackgroundTask;
active: boolean;
onClick: () => void;
/** Absent for a running task — see the call site. */
onDismiss?: () => void;
};
const TaskChip = ({ task, active, onClick }: TaskChipProps) => {
/**
* The pill is a div wrapping two buttons rather than one button with a close control inside it: a button
* nested in a button is invalid, and the browser resolves it by swallowing one of the two clicks.
*/
const TaskChip = ({ task, active, onClick, onDismiss }: TaskChipProps) => {
const { status } = task;
const tone = toneText[statusTone(status)];
return (
<button
type="button"
onClick={onClick}
aria-expanded={active}
className={`flex shrink-0 cursor-pointer items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs transition-colors ${
<div
className={`group flex shrink-0 items-center rounded-full border text-xs transition-colors ${
active
? 'border-duck-teal/40 bg-duck-teal/10 text-foreground'
: 'border-border bg-background/60 text-muted-foreground hover:border-foreground/20 hover:text-foreground'
}`}
>
{status === 'completed' ? (
<Check className={`h-3 w-3 ${tone}`} />
) : status === 'failed' ? (
// Was a CircleSlash, the same icon as "stopped" — the two outcomes you most need to tell apart
// were the one glyph.
<X className={`h-3 w-3 ${tone}`} />
) : status ? (
<CircleSlash className={`h-3 w-3 ${tone}`} />
) : (
<span className="h-1.5 w-1.5 shrink-0 animate-pulse rounded-full bg-warning" />
<button
type="button"
onClick={onClick}
aria-expanded={active}
className={`flex cursor-pointer items-center gap-1.5 py-1 pl-2.5 ${onDismiss ? 'pr-1' : 'pr-2.5'}`}
>
{status === 'completed' ? (
<Check className={`h-3 w-3 ${tone}`} />
) : status === 'failed' ? (
// Was a CircleSlash, the same icon as "stopped" — the two outcomes you most need to tell apart
// were the one glyph.
<X className={`h-3 w-3 ${tone}`} />
) : status ? (
<CircleSlash className={`h-3 w-3 ${tone}`} />
) : (
<span className="h-1.5 w-1.5 shrink-0 animate-pulse rounded-full bg-warning" />
)}
<span className="max-w-[14rem] truncate">{task.description || 'Background task'}</span>
</button>
{onDismiss && (
// Always drawn, not revealed on hover: the strip is as much a touch target as a pointer one, and
// a control you can only find by hovering is one half the devices cannot find at all.
<button
type="button"
onClick={onDismiss}
title="Dismiss"
aria-label={`Dismiss ${task.description || 'background task'}`}
className="cursor-pointer rounded-full py-1 pl-0.5 pr-2 text-muted-foreground/50 transition-colors hover:text-foreground"
>
<X className="h-3 w-3" />
</button>
)}
<span className="max-w-[14rem] truncate">{task.description || 'Background task'}</span>
</button>
</div>
);
};
@@ -56,8 +56,17 @@ export function useBackgroundTasks(messages: ChatMessage[]) {
return {
tasks: visible,
running,
/** Clear the finished chips. Running ones are never dismissable — they'd leave nothing watching them. */
dismissFinished: () => setDismissed(tasks.filter((t) => t.status).map((t) => t.taskId)),
/**
* Close one chip. Only ever called for a finished task — running ones are not dismissable, because the
* tray is the only thing watching them and a hidden running task is one you have no way back to.
*
* Dismissing the newest of a capped set promotes the next-oldest into view rather than leaving a gap:
* `FINISHED_KEPT` is how many finished chips are shown, not which ones survive.
*/
dismiss: (taskId: string) => setDismissed((prev) => (prev.includes(taskId) ? prev : [...prev, taskId])),
/** Clear every finished chip at once. Additive, so chips closed one at a time are not resurrected. */
dismissFinished: () =>
setDismissed((prev) => [...new Set([...prev, ...tasks.filter((t) => t.status).map((t) => t.taskId)])]),
};
}