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:
@@ -1,4 +1,4 @@
|
|||||||
@import "tailwindcss";
|
@import 'tailwindcss';
|
||||||
@plugin 'tailwindcss-animate';
|
@plugin 'tailwindcss-animate';
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
@@ -123,12 +123,12 @@
|
|||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
/* Duck brand hex colors */
|
/* Duck brand hex colors */
|
||||||
--duck-yellow: #F4C430;
|
--duck-yellow: #f4c430;
|
||||||
--duck-orange: #EA580C;
|
--duck-orange: #ea580c;
|
||||||
--duck-teal: #0891B2;
|
--duck-teal: #0891b2;
|
||||||
--duck-forest: #166534;
|
--duck-forest: #166534;
|
||||||
--duck-dark: #14532D;
|
--duck-dark: #14532d;
|
||||||
--duck-beige: #E7D4B5;
|
--duck-beige: #e7d4b5;
|
||||||
|
|
||||||
/* Page background */
|
/* Page background */
|
||||||
--pixel-grid-color: rgba(20, 83, 45, 0.1);
|
--pixel-grid-color: rgba(20, 83, 45, 0.1);
|
||||||
@@ -328,14 +328,39 @@
|
|||||||
background: #555;
|
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;
|
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;
|
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;
|
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]">
|
<div className="mb-2 rounded-lg border border-duck-dark/10 bg-duck-dark/[0.03]">
|
||||||
{open && <TaskPanel task={open} onClose={() => setOpenId(null)} />}
|
{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">
|
<span className="shrink-0 text-[10px] uppercase tracking-wider text-duck-dark/40">
|
||||||
{running.length > 0 ? `${running.length} running` : 'Background'}
|
{running.length > 0 ? `${running.length} running` : 'Background'}
|
||||||
</span>
|
</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) => (
|
{tasks.map((task) => (
|
||||||
<TaskChip
|
<TaskChip
|
||||||
key={task.taskId}
|
key={task.taskId}
|
||||||
@@ -82,7 +84,7 @@ const TaskChip = ({ task, active, onClick }: TaskChipProps) => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
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
|
active
|
||||||
? 'border-duck-teal/40 bg-duck-teal/10 text-duck-dark/80'
|
? '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'
|
: '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
|
* 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
|
* 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[]) {
|
export function useBackgroundTasks(messages: ChatMessage[]) {
|
||||||
const [dismissed, setDismissed] = useState<string[]>([]);
|
const [dismissed, setDismissed] = useState<string[]>([]);
|
||||||
|
|
||||||
const tasks = useMemo(() => {
|
const tasks = useMemo(() => {
|
||||||
const byId = new Map<string, BackgroundTask>();
|
const byId = new Map<string, BackgroundTask>();
|
||||||
for (const m of messages) if (m.role === 'task') byId.set(m.taskId, m);
|
for (const m of messages) if (m.role === 'task') byId.set(m.taskId, m);
|
||||||
return [...byId.values()];
|
return [...byId.values()].reverse();
|
||||||
}, [messages]);
|
}, [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);
|
const running = visible.filter((t) => !t.status);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user