mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 02:00:52 +01:00
Align attention card actions
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
77d758747a
commit
d86096a720
3 files changed
+94
-71
No files matched your search
@@ -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", () => {
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Reference in new issue
Block a user