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
+34 -9
View File
@@ -1,4 +1,4 @@
@import "tailwindcss";
@import 'tailwindcss';
@plugin 'tailwindcss-animate';
@custom-variant dark (&:is(.dark *));
@@ -123,12 +123,12 @@
@layer base {
:root {
/* Duck brand hex colors */
--duck-yellow: #F4C430;
--duck-orange: #EA580C;
--duck-teal: #0891B2;
--duck-yellow: #f4c430;
--duck-orange: #ea580c;
--duck-teal: #0891b2;
--duck-forest: #166534;
--duck-dark: #14532D;
--duck-beige: #E7D4B5;
--duck-dark: #14532d;
--duck-beige: #e7d4b5;
/* Page background */
--pixel-grid-color: rgba(20, 83, 45, 0.1);
@@ -328,14 +328,39 @@
background: #555;
}
.file-grid:has([data-state="open"]) [data-file-item]:not([data-state="open"]) {
/* Chat — the background-task strip. A default horizontal scrollbar is ~15px tall and is laid out INSIDE
the row, so it stole most of the chips' breathing room and made the tray read as squeezed. Thin, and
neutral enough to sit on either theme. */
.task-strip {
scrollbar-width: thin;
scrollbar-color: rgb(128 128 128 / 0.3) transparent;
}
.task-strip::-webkit-scrollbar {
height: 4px;
}
.task-strip::-webkit-scrollbar-track {
background: transparent;
}
.task-strip::-webkit-scrollbar-thumb {
background: rgb(128 128 128 / 0.3);
border-radius: 2px;
}
.task-strip:hover::-webkit-scrollbar-thumb {
background: rgb(128 128 128 / 0.5);
}
.file-grid:has([data-state='open']) [data-file-item]:not([data-state='open']) {
pointer-events: none;
}
.file-grid:has([data-state="open"]) [data-file-item]:not([data-state="open"]) .file-item-ellipsis {
.file-grid:has([data-state='open']) [data-file-item]:not([data-state='open']) .file-item-ellipsis {
opacity: 0 !important;
}
.file-grid [data-file-item][data-state="open"] .file-item-ellipsis {
.file-grid [data-file-item][data-state='open'] .file-item-ellipsis {
opacity: 1 !important;
}
@@ -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 {