Align attention card actions

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-07-10 20:08:18 +00:00
1 parent 77d758747a
commit d86096a720
3 files changed
+94 -71

No files matched your search

+18 -4
View File
@@ -184,8 +184,8 @@ describe("AttentionQueueRow", () => {
expect(links.some((a) => a.textContent?.includes("Hire agent: Research Analyst"))).toBe(false);
});
it("renders inline decision verbs on a collapsed inline row", () => {
const el = render(
it("renders collapsed inline decision verbs in the right-side action area with semantic variants", () => {
render(
<AttentionQueueRow
item={buildItem({
decisionVerbs: [
@@ -199,8 +199,22 @@ describe("AttentionQueueRow", () => {
onDismiss={noop}
/>,
);
expect(el.textContent).toContain("Approve");
expect(el.textContent).toContain("Reject");
const header = container?.querySelector('[role="button"][aria-expanded]');
expect(header?.textContent).not.toContain("Approve");
expect(header?.textContent).not.toContain("Reject");
const decisionActions = container?.querySelector('[aria-label="Decision actions"]');
expect(decisionActions?.textContent).toContain("Approve");
expect(decisionActions?.textContent).toContain("Reject");
const buttons = Array.from(decisionActions?.querySelectorAll("button") ?? []);
expect(buttons.find((button) => button.textContent === "Approve")?.getAttribute("data-variant")).toBe(
"default",
);
expect(buttons.find((button) => button.textContent === "Reject")?.getAttribute("data-variant")).toBe(
"destructive",
);
});
it("does not expose a toggle button for non-inline rows", () => {
+73 -64
View File
@@ -140,7 +140,7 @@ export function AttentionQueueRow({
<div
className={cn(
"flex min-w-0 flex-1 items-start gap-3 rounded-md",
expandable && "cursor-pointer focus-visible:ring-ring focus-visible:ring-[3px] focus-visible:outline-none",
expandable && "cursor-pointer focus-visible:ring-ring focus-visible:ring-(length:--rad-3) focus-visible:outline-none",
)}
{...(expandable
? {
@@ -236,76 +236,78 @@ export function AttentionQueueRow({
{images.length > 0 && <ThumbnailStack images={images} />}
</div>
{/* Inline decision verbs on collapsed inline rows (plan §3). */}
{expandable && !expanded && verbs.length > 0 && (
<div className="mt-2 flex flex-wrap gap-2">
{verbs.map((verb) => (
<Button
key={verb.id}
type="button"
variant="outline"
size="xs"
onClick={(e) => {
e.stopPropagation();
onToggleExpand();
}}
>
{verb.label}
</Button>
))}
</div>
)}
</div>
</div>
{/* Controls: kept as siblings (not inside the clickable header) so they
never toggle expand and stay valid interactive targets. */}
<div className="flex shrink-0 items-start gap-1">
{!inline && href && (
<Button asChild variant="outline" size="xs">
<Link to={href}>
Open
<ExternalLink className="h-3 w-3" />
</Link>
</Button>
<div className="flex shrink-0 flex-col items-end gap-1">
{expandable && !expanded && verbs.length > 0 && (
<div className="flex flex-wrap justify-end gap-1" aria-label="Decision actions">
{verbs.map((verb) => (
<Button
key={verb.id}
type="button"
variant={decisionVerbVariant(verb)}
size="xs"
onClick={(e) => {
e.stopPropagation();
onToggleExpand();
}}
>
{verb.label}
</Button>
))}
</div>
)}
{isHidden ? (
onRestore && (
<Button type="button" variant="outline" size="xs" onClick={() => onRestore(item)}>
<RotateCcw className="h-3 w-3" />
Restore
<div className="flex items-start justify-end gap-1">
{!inline && href && (
<Button asChild variant="outline" size="xs">
<Link to={href}>
Open
<ExternalLink className="h-3 w-3" />
</Link>
</Button>
)
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
className="text-muted-foreground"
aria-label="Row actions"
>
<MoreHorizontal className="h-4 w-4" />
)}
{isHidden ? (
onRestore && (
<Button type="button" variant="outline" size="xs" onClick={() => onRestore(item)}>
<RotateCcw className="h-3 w-3" />
Restore
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{onSnooze && <SnoozeSubmenu onSnooze={(iso) => onSnooze(item, iso)} />}
<DropdownMenuItem onClick={() => onDismiss(item)}>
<X className="h-4 w-4" />
Dismiss
</DropdownMenuItem>
{href && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link to={href}>Open source</Link>
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
)
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
className="text-muted-foreground"
aria-label="Row actions"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{onSnooze && <SnoozeSubmenu onSnooze={(iso) => onSnooze(item, iso)} />}
<DropdownMenuItem onClick={() => onDismiss(item)}>
<X className="h-4 w-4" />
Dismiss
</DropdownMenuItem>
{href && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link to={href}>Open source</Link>
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
</div>
@@ -324,6 +326,13 @@ export function AttentionQueueRow({
);
}
function decisionVerbVariant(verb: AttentionItem["decisionVerbs"][number]): "default" | "outline" | "destructive" {
const text = `${verb.id} ${verb.label}`.toLowerCase();
if (/\b(reject|decline|deny|delete|remove)\b/.test(text)) return "destructive";
if (/\b(accept|approve|confirm|apply)\b/.test(text)) return "default";
return "outline";
}
/** Small pill used for the project / workspace chips. Clickable when `onClick`. */
function Chip({
icon: Icon,
@@ -335,7 +344,7 @@ function Chip({
onClick?: (e: MouseEvent) => void;
}) {
const className = cn(
"inline-flex max-w-[12rem] items-center gap-1 rounded-sm border border-border/70 bg-muted/40 px-1.5 py-0.5 text-(length:--text-nano) text-muted-foreground",
"inline-flex max-w-(--sz-12rem) items-center gap-1 rounded-sm border border-border/70 bg-muted/40 px-1.5 py-0.5 text-(length:--text-nano) text-muted-foreground",
onClick && "transition-colors hover:border-border hover:bg-accent hover:text-foreground",
);
const content = (
@@ -508,7 +517,7 @@ function ApprovalResolver({ item, companyId }: { item: AttentionItem; companyId:
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="Optional decision note…"
className="min-h-[64px] text-sm"
className="min-h-16 text-sm"
/>
<div className="flex flex-wrap gap-2">
<Button size="sm" onClick={() => approve.mutate()} disabled={pending}>
+3 -3
View File
@@ -71,7 +71,7 @@ export function WhatNeedsMe() {
const { pushToast } = useToastActions();
useEffect(() => {
setBreadcrumbs([{ label: "What needs me" }]);
setBreadcrumbs([{ label: "Decisions" }]);
}, [setBreadcrumbs]);
// Re-hydrate per-company preferences when the company changes.
@@ -237,7 +237,7 @@ export function WhatNeedsMe() {
return (
<div className="max-w-3xl space-y-4">
<div className="flex items-center justify-between gap-2">
<h1 className="text-xl font-bold">What needs me</h1>
<h1 className="text-xl font-bold">Decisions</h1>
<div className="flex items-center gap-2">
{visibleCount > 0 && (
<span className="text-sm text-muted-foreground">
@@ -450,7 +450,7 @@ function FilterMenu({
const hasActive = countActiveAttentionFilters(filters) > 0;
return (
<div className="max-h-[70vh] overflow-y-auto">
<div className="max-h-(--sz-70vh) overflow-y-auto">
<div className="flex items-center justify-between px-3 py-2">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Filter</span>
{hasActive && (