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.
|
* tail of a backgrounded shell's log, both read from the file Claude Code streams the task into.
|
||||||
*/
|
*/
|
||||||
export const BackgroundTaskTray = ({ messages }: BackgroundTaskTrayProps) => {
|
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);
|
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
|
// 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}
|
task={task}
|
||||||
active={task.taskId === openId}
|
active={task.taskId === openId}
|
||||||
onClick={() => setOpenId(task.taskId === openId ? null : task.taskId)}
|
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>
|
</div>
|
||||||
@@ -61,8 +64,8 @@ export const BackgroundTaskTray = ({ messages }: BackgroundTaskTrayProps) => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={dismissFinished}
|
onClick={dismissFinished}
|
||||||
title="Clear finished"
|
title="Clear all finished"
|
||||||
aria-label="Clear finished tasks"
|
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"
|
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" />
|
<X className="h-3.5 w-3.5" />
|
||||||
@@ -79,22 +82,31 @@ type TaskChipProps = {
|
|||||||
task: BackgroundTask;
|
task: BackgroundTask;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
onClick: () => void;
|
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 { status } = task;
|
||||||
const tone = toneText[statusTone(status)];
|
const tone = toneText[statusTone(status)];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<div
|
||||||
type="button"
|
className={`group flex shrink-0 items-center rounded-full border text-xs transition-colors ${
|
||||||
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 ${
|
|
||||||
active
|
active
|
||||||
? 'border-duck-teal/40 bg-duck-teal/10 text-foreground'
|
? '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'
|
: 'border-border bg-background/60 text-muted-foreground hover:border-foreground/20 hover:text-foreground'
|
||||||
}`}
|
}`}
|
||||||
|
>
|
||||||
|
<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' ? (
|
{status === 'completed' ? (
|
||||||
<Check className={`h-3 w-3 ${tone}`} />
|
<Check className={`h-3 w-3 ${tone}`} />
|
||||||
@@ -109,6 +121,20 @@ const TaskChip = ({ task, active, onClick }: TaskChipProps) => {
|
|||||||
)}
|
)}
|
||||||
<span className="max-w-[14rem] truncate">{task.description || 'Background task'}</span>
|
<span className="max-w-[14rem] truncate">{task.description || 'Background task'}</span>
|
||||||
</button>
|
</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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -56,8 +56,17 @@ export function useBackgroundTasks(messages: ChatMessage[]) {
|
|||||||
return {
|
return {
|
||||||
tasks: visible,
|
tasks: visible,
|
||||||
running,
|
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