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:
@@ -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)])]),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user