pin background tasks to a row of their own

a pin on each chip lifts it out of the strip and into a row above it, so the
one task you are actually waiting on stops sliding off the end as newer ones
arrive. more than one can be pinned; the pinned row scrolls like the other.

a pin outranks FINISHED_KEPT and the bulk clear both — it is an explicit
"keep this", and it would be useless if five newer tasks could still evict it.
pinning survives the task finishing, because the outcome is what you pinned it
for.
This commit is contained in:
2026-08-07 20:43:03 +00:00
parent 5624ed8e66
commit 302116d624
2 changed files with 108 additions and 45 deletions
@@ -1,5 +1,6 @@
import type { ReactNode } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Check, ChevronDown, CircleSlash, Loader2, X } from 'lucide-react';
import { Check, ChevronDown, CircleSlash, Loader2, Pin, X } from 'lucide-react';
import type { Tone } from '@/components/Data';
import { toneText } from '@/components/Data';
import type { ChatMessage } from '../types';
@@ -28,7 +29,8 @@ 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, dismiss, dismissFinished } = useBackgroundTasks(messages);
const { tasks, pinned, unpinned, running, clearable, togglePin, 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
@@ -37,30 +39,33 @@ export const BackgroundTaskTray = ({ messages }: BackgroundTaskTrayProps) => {
if (tasks.length === 0) return null;
const chipProps = (task: BackgroundTask) => ({
task,
active: task.taskId === openId,
onClick: () => setOpenId(task.taskId === openId ? null : task.taskId),
onTogglePin: () => togglePin(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,
});
return (
<div className="mb-2 rounded-lg border border-border bg-muted/40">
{open && <TaskPanel task={open} onClose={() => setOpenId(null)} />}
<div className="flex items-center gap-2 px-2 py-2">
<span className="shrink-0 text-xs uppercase tracking-wider text-muted-foreground">
{running.length > 0 ? `${running.length} running` : 'Background'}
</span>
{/* pb-1.5 keeps the scrollbar clear of the chips; the matching -mb-1.5 hides that pad from layout,
so it draws into the container's own padding and the label and X still centre on the chips. */}
<div className="task-strip -mb-1.5 flex min-w-0 flex-1 items-center gap-1.5 overflow-x-auto pb-1.5">
{tasks.map((task) => (
<TaskChip
key={task.taskId}
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}
/>
{pinned.length > 0 && (
<TaskStrip label="Pinned" divided={unpinned.length > 0}>
{pinned.map((task) => (
<TaskChip key={task.taskId} pinned {...chipProps(task)} />
))}
</div>
{tasks.length > running.length && (
</TaskStrip>
)}
{unpinned.length > 0 && (
<TaskStrip
label={running.length > 0 ? `${running.length} running` : 'Background'}
trailing={
clearable.length > 0 && (
<button
type="button"
onClick={dismissFinished}
@@ -70,29 +75,57 @@ export const BackgroundTaskTray = ({ messages }: BackgroundTaskTrayProps) => {
>
<X className="h-3.5 w-3.5" />
</button>
)
}
>
{unpinned.map((task) => (
<TaskChip key={task.taskId} pinned={false} {...chipProps(task)} />
))}
</TaskStrip>
)}
</div>
</div>
);
};
// ── Strip ──
type TaskStripProps = {
label: string;
children: ReactNode;
trailing?: ReactNode;
divided?: boolean;
};
const TaskStrip = ({ label, children, trailing, divided }: TaskStripProps) => (
<div className={`flex items-center gap-2 px-2 py-2 ${divided ? 'border-b border-border' : ''}`}>
<span className="shrink-0 text-xs uppercase tracking-wider text-muted-foreground">{label}</span>
{/* pb-1.5 keeps the scrollbar clear of the chips; the matching -mb-1.5 hides that pad from layout,
so it draws into the container's own padding and the label and X still centre on the chips. */}
<div className="task-strip -mb-1.5 flex min-w-0 flex-1 items-center gap-1.5 overflow-x-auto pb-1.5">{children}</div>
{trailing}
</div>
);
// ── Chip ──
type TaskChipProps = {
task: BackgroundTask;
active: boolean;
pinned: boolean;
onClick: () => void;
onTogglePin: () => void;
/** Absent for a running task — see the call site. */
onDismiss?: () => void;
};
/**
* The pill is a div wrapping two buttons rather than one button with a close control inside it: a button
* The pill is a div wrapping several buttons rather than one button with controls 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 TaskChip = ({ task, active, pinned, onClick, onTogglePin, onDismiss }: TaskChipProps) => {
const { status } = task;
const tone = toneText[statusTone(status)];
const label = task.description || 'Background task';
return (
<div
@@ -106,7 +139,7 @@ const TaskChip = ({ task, active, onClick, onDismiss }: TaskChipProps) => {
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'}`}
className="flex cursor-pointer items-center gap-1.5 py-1 pl-2.5 pr-1"
>
{status === 'completed' ? (
<Check className={`h-3 w-3 ${tone}`} />
@@ -119,16 +152,28 @@ const TaskChip = ({ task, active, onClick, onDismiss }: TaskChipProps) => {
) : (
<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>
<span className="max-w-[14rem] truncate">{label}</span>
</button>
{/* 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={onTogglePin}
aria-pressed={pinned}
title={pinned ? 'Unpin' : 'Pin to the top row'}
aria-label={`${pinned ? 'Unpin' : 'Pin'} ${label}`}
className={`cursor-pointer py-1 pl-0.5 transition-colors ${onDismiss ? 'pr-0.5' : 'pr-2'} ${
pinned ? 'text-duck-teal' : 'text-muted-foreground/50 hover:text-foreground'
}`}
>
<Pin className={`h-3 w-3 ${pinned ? 'fill-current' : ''}`} />
</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'}`}
aria-label={`Dismiss ${label}`}
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" />
@@ -32,6 +32,7 @@ const FINISHED_KEPT = 5;
export function useBackgroundTasks(messages: ChatMessage[]) {
const [dismissed, setDismissed] = useState<string[]>([]);
const [pinnedIds, setPinnedIds] = useState<string[]>([]);
const tasks = useMemo(() => {
const byId = new Map<string, BackgroundTask>();
@@ -45,17 +46,32 @@ export function useBackgroundTasks(messages: ChatMessage[]) {
// ones whose notifications are still recent enough to be worth reading.
return tasks.filter((t) => {
if (dismissed.includes(t.taskId)) return false;
if (!t.status) return true;
// A pin is an explicit "keep this", so it outranks the cap — otherwise pinning the one task you care
// about would not stop five newer ones from pushing it out.
if (!t.status || pinnedIds.includes(t.taskId)) return true;
finished += 1;
return finished <= FINISHED_KEPT;
});
}, [tasks, dismissed]);
}, [tasks, dismissed, pinnedIds]);
const running = visible.filter((t) => !t.status);
const pinned = visible.filter((t) => pinnedIds.includes(t.taskId));
const unpinned = visible.filter((t) => !pinnedIds.includes(t.taskId));
// Uncapped on purpose: clearing the finished chips must also clear the ones the cap is hiding behind
// them, or the bulk clear just deals the next five off the same deck.
const clearable = tasks.filter((t) => t.status && !pinnedIds.includes(t.taskId));
return {
tasks: visible,
/** Raised to their own row above the rest, in the same newest-first order. */
pinned,
unpinned,
running,
clearable,
/** Pinning survives the task finishing — you pinned it to watch it, and the outcome is the point. */
togglePin: (taskId: string) =>
setPinnedIds((prev) => (prev.includes(taskId) ? prev.filter((id) => id !== taskId) : [...prev, 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.
@@ -63,10 +79,12 @@ export function useBackgroundTasks(messages: ChatMessage[]) {
* 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)])]),
dismiss: (taskId: string) => {
setPinnedIds((prev) => prev.filter((id) => id !== taskId));
setDismissed((prev) => (prev.includes(taskId) ? prev : [...prev, taskId]));
},
/** Clear every finished chip at once, pinned ones excepted. Additive, so closed chips stay closed. */
dismissFinished: () => setDismissed((prev) => [...new Set([...prev, ...clearable.map((t) => t.taskId)])]),
};
}