the task tray shows the newest first, and forgets the oldest

Three things about the background-task strip, all the same complaint: the chip you
want is the one you cannot see.

Newest first, because the strip scrolls horizontally and the far end is off-screen —
chronological order put every task you just started exactly where you had to scroll to
reach it. Finished chips are capped at five, so a long session stops turning the tray
into a history; running ones are never counted or dropped, since watching them is the
whole point. And the default horizontal scrollbar is ~15px tall and laid out inside the
row, which is what made the chips read as squeezed — it is thin now, and the row and
chips have the padding back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 22:18:37 +00:00
co-authored by Claude Opus 5
parent ddc982ce5b
commit e1cc3a2cd5
3 changed files with 67 additions and 15 deletions
@@ -30,11 +30,13 @@ export const BackgroundTaskTray = ({ messages }: BackgroundTaskTrayProps) => {
<div className="mb-2 rounded-lg border border-duck-dark/10 bg-duck-dark/[0.03]">
{open && <TaskPanel task={open} onClose={() => setOpenId(null)} />}
<div className="flex items-center gap-2 px-2 py-1.5">
<div className="flex items-center gap-2 px-2 py-2">
<span className="shrink-0 text-[10px] uppercase tracking-wider text-duck-dark/40">
{running.length > 0 ? `${running.length} running` : 'Background'}
</span>
<div className="flex min-w-0 flex-1 items-center gap-1.5 overflow-x-auto">
{/* 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}
@@ -82,7 +84,7 @@ const TaskChip = ({ task, active, onClick }: TaskChipProps) => {
<button
type="button"
onClick={onClick}
className={`flex shrink-0 items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs transition-colors cursor-pointer ${
className={`flex shrink-0 items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs transition-colors cursor-pointer ${
active
? 'border-duck-teal/40 bg-duck-teal/10 text-duck-dark/80'
: 'border-duck-dark/10 bg-background/60 text-duck-dark/60 hover:border-duck-dark/20 hover:text-duck-dark/80'
@@ -14,18 +14,43 @@ export type BackgroundTask = Extract<ChatMessage, { role: 'task' }>;
*
* Finished tasks stay in the tray until dismissed rather than vanishing on completion: the notification
* lands while you are reading something else, and a chip that removes itself the instant it succeeds is a
* chip you never saw.
* chip you never saw. They do not stay forever, though — see FINISHED_KEPT.
*
* NEWEST FIRST. The tray is a horizontal strip, so the far end of it is off-screen, and the task you want
* is almost always the one that just started. Chronological order put it exactly where you could not see
* it without scrolling.
*/
/**
* How many finished tasks the tray keeps behind the running ones.
*
* Enough that a notification you missed is still there when you look up, few enough that the strip does
* not become a scrolling history of the whole conversation. Running tasks are never counted or dropped:
* the tray's job is watching them, and a busy session could legitimately have more than this many.
*/
const FINISHED_KEPT = 5;
export function useBackgroundTasks(messages: ChatMessage[]) {
const [dismissed, setDismissed] = useState<string[]>([]);
const tasks = useMemo(() => {
const byId = new Map<string, BackgroundTask>();
for (const m of messages) if (m.role === 'task') byId.set(m.taskId, m);
return [...byId.values()];
return [...byId.values()].reverse();
}, [messages]);
const visible = tasks.filter((t) => !dismissed.includes(t.taskId));
const visible = useMemo(() => {
let finished = 0;
// `tasks` is already newest-first, so counting forwards drops the oldest finished chips and keeps the
// 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;
finished += 1;
return finished <= FINISHED_KEPT;
});
}, [tasks, dismissed]);
const running = visible.filter((t) => !t.status);
return {