diff --git a/src/workspaces/officerdev/src/apps/Chat/components/BackgroundTaskTray.tsx b/src/workspaces/officerdev/src/apps/Chat/components/BackgroundTaskTray.tsx index 28d0b577..1aa93bf1 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/BackgroundTaskTray.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/components/BackgroundTaskTray.tsx @@ -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(null); // Derived, not stored: dismissing the chip that is open closes the panel without a second piece of state @@ -37,62 +39,93 @@ 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 (
{open && setOpenId(null)} />} -
- - {running.length > 0 ? `${running.length} running` : 'Background'} - - {/* 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. */} -
- {tasks.map((task) => ( - 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 && ( + 0}> + {pinned.map((task) => ( + ))} -
- {tasks.length > running.length && ( - - )} -
+ + )} + + {unpinned.length > 0 && ( + 0 ? `${running.length} running` : 'Background'} + trailing={ + clearable.length > 0 && ( + + ) + } + > + {unpinned.map((task) => ( + + ))} + + )}
); }; +// ── Strip ── + +type TaskStripProps = { + label: string; + children: ReactNode; + trailing?: ReactNode; + divided?: boolean; +}; + +const TaskStrip = ({ label, children, trailing, divided }: TaskStripProps) => ( +
+ {label} + {/* 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. */} +
{children}
+ {trailing} +
+); + // ── 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 (
{ 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' ? ( @@ -119,16 +152,28 @@ const TaskChip = ({ task, active, onClick, onDismiss }: TaskChipProps) => { ) : ( )} - {task.description || 'Background task'} + {label} + + {/* 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. */} + {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.