Show project identity once on decision cards

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-07-10 20:08:18 +00:00
1 parent 66786fd253
commit 31e71ccd0a
8 files changed
+68 -57

No files matched your search

+2
View File
@@ -45,6 +45,8 @@ export interface AttentionProjectRef {
id: string;
name: string;
urlKey: string;
color: string | null;
icon: string | null;
}
export interface AttentionWorkspaceRef {
@@ -622,6 +622,8 @@ describeEmbeddedPostgres("attention service", () => {
companyId,
name: "Attention Project",
status: "in_progress",
color: "#0f766e",
icon: "rocket",
});
await db.insert(projectWorkspaces).values({
id: workspaceId,
@@ -752,7 +754,12 @@ describeEmbeddedPostgres("attention service", () => {
const planItem = detailsByKind.get("plan_approval");
expect(planItem?.subject.title).toBe("Plan approval - Approve launch plan");
expect(planItem?.subject.metadata).toMatchObject({ isPlanTarget: true, targetDocumentKey: "plan" });
expect(planItem?.project).toMatchObject({ id: projectId, name: "Attention Project" });
expect(planItem?.project).toMatchObject({
id: projectId,
name: "Attention Project",
color: "#0f766e",
icon: "rocket",
});
expect(planItem?.project?.urlKey).toEqual(expect.any(String));
expect(planItem?.workspace).toEqual({ id: workspaceId, name: "Preview workspace" });
expect(planItem?.detail).toMatchObject({
+4
View File
@@ -438,6 +438,8 @@ async function issueSummaryMap(db: Db, companyId: string, issueIds: Array<string
updatedAt: issues.updatedAt,
projectId: projects.id,
projectName: projects.name,
projectColor: projects.color,
projectIcon: projects.icon,
workspaceId: projectWorkspaces.id,
workspaceName: projectWorkspaces.name,
})
@@ -463,6 +465,8 @@ async function issueSummaryMap(db: Db, companyId: string, issueIds: Array<string
id: row.projectId,
name: row.projectName,
urlKey: deriveProjectUrlKey(row.projectName, row.projectId),
color: row.projectColor,
icon: row.projectIcon,
} : null,
workspace: row.workspaceId && row.workspaceName ? {
id: row.workspaceId,
@@ -184,6 +184,26 @@ describe("AttentionQueueRow", () => {
expect(links.some((a) => a.textContent?.includes("Hire agent: Research Analyst"))).toBe(false);
});
it("renders project identity once without a filter button", () => {
render(
<AttentionQueueRow
item={buildItem({
project: { id: "project-1", name: "Alpha", urlKey: "alpha", color: null, icon: "rocket" },
})}
companyId="c1"
expanded={false}
onToggleExpand={noop}
onDismiss={noop}
/>,
);
const projectChip = container?.querySelector('[data-testid="attention-project-chip"]');
expect(projectChip?.textContent).toBe("Alpha");
expect(projectChip?.querySelector("button")).toBeNull();
expect(container?.querySelector('button[title="Filter by Alpha"]')).toBeNull();
expect(container?.textContent?.match(/Alpha/g)).toHaveLength(1);
});
it("renders collapsed inline decision verbs in the right-side action area with semantic variants", () => {
render(
<AttentionQueueRow
+24 -39
View File
@@ -1,4 +1,4 @@
import { useState, type KeyboardEvent, type MouseEvent } from "react";
import { useState, type KeyboardEvent } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
AlarmClock,
@@ -7,13 +7,13 @@ import {
ChevronRight,
Clock,
ExternalLink,
Folder,
Loader2,
MoreHorizontal,
RotateCcw,
X,
type LucideIcon,
} from "lucide-react";
import type { Agent, AttentionDetailImage, AttentionItem, AttentionProjectRef } from "@paperclipai/shared";
import type { Agent, AttentionDetailImage, AttentionItem } from "@paperclipai/shared";
import { Link } from "@/lib/router";
import { accessApi } from "../api/access";
import { approvalsApi } from "../api/approvals";
@@ -41,6 +41,7 @@ import {
DropdownMenuTrigger,
} from "./ui/dropdown-menu";
import { AttentionInteractionResolver } from "./AttentionInteractionResolver";
import { ProjectTile } from "./ProjectTile";
const HOUR_MS = 60 * 60 * 1000;
const DAY_MS = 24 * HOUR_MS;
@@ -70,8 +71,6 @@ interface AttentionQueueRowProps {
onSnooze?: (item: AttentionItem, snoozedUntil: string) => void;
/** Restore a snoozed/dismissed row (curtain variant only). */
onRestore?: (item: AttentionItem) => void;
/** Click-to-filter on the project chip (wires into the toolbar filter state). */
onFilterProject?: (project: AttentionProjectRef) => void;
/** "active" renders the live queue row; "hidden" renders a curtain row. */
variant?: "active" | "hidden";
agentMap?: Map<string, Agent>;
@@ -87,7 +86,6 @@ export function AttentionQueueRow({
onDismiss,
onSnooze,
onRestore,
onFilterProject,
variant = "active",
agentMap,
currentUserId,
@@ -214,20 +212,7 @@ export function AttentionQueueRow({
{(item.project || workspaceLabel) && (
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
{item.project && (
<Chip
icon={Folder}
label={item.project.name}
onClick={
onFilterProject
? (e) => {
e.stopPropagation();
onFilterProject(item.project!);
}
: undefined
}
/>
)}
{item.project && <ProjectChip project={item.project} />}
{workspaceLabel && <Chip icon={Boxes} label={workspaceLabel} />}
</div>
)}
@@ -332,34 +317,34 @@ function decisionVerbVariant(verb: AttentionItem["decisionVerbs"][number]): "def
return "outline";
}
/** Small pill used for the project / workspace chips. Clickable when `onClick`. */
/** Small passive pill used for the project identity. */
function ProjectChip({ project }: { project: NonNullable<AttentionItem["project"]> }) {
return (
<span
className="inline-flex max-w-(--sz-12rem) items-center gap-1.5 rounded-sm border border-border/70 bg-muted/40 px-1.5 py-0.5 text-(length:--text-nano) text-muted-foreground"
title={project.name}
data-testid="attention-project-chip"
>
<ProjectTile color={project.color} icon={project.icon} size="xs" />
<span className="truncate">{project.name}</span>
</span>
);
}
/** Small passive pill used for workspace context. */
function Chip({
icon: Icon,
label,
onClick,
}: {
icon: typeof Folder;
icon: LucideIcon;
label: string;
onClick?: (e: MouseEvent) => void;
}) {
const className = cn(
"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 = (
<>
return (
<span className="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">
<Icon className="h-3 w-3 shrink-0" />
<span className="truncate">{label}</span>
</>
</span>
);
if (onClick) {
return (
<button type="button" className={className} onClick={onClick} title={`Filter by ${label}`}>
{content}
</button>
);
}
return <span className={className}>{content}</span>;
}
/** Square screenshot thumbnails at the right of the description (plan §10). */
+3 -3
View File
@@ -312,7 +312,7 @@ describe("groupAttentionItems", () => {
it("groups by project, keeping a 'No project' bucket for unassigned rows", () => {
const items = [
buildItem({ id: "p1", activityAt: "2026-07-10T10:00:00Z", project: { id: "proj-1", name: "Alpha", urlKey: "alpha" } }),
buildItem({ id: "p1", activityAt: "2026-07-10T10:00:00Z", project: { id: "proj-1", name: "Alpha", urlKey: "alpha", color: null, icon: null } }),
buildItem({ id: "none", activityAt: "2026-07-10T11:00:00Z", project: null }),
];
const groups = groupAttentionItems(items, "project");
@@ -350,7 +350,7 @@ describe("groupAttentionItems", () => {
});
describe("filterAttentionItems", () => {
const approval = buildItem({ id: "ap", sourceKind: "approval", severity: "high", project: { id: "p1", name: "Alpha", urlKey: "a" } });
const approval = buildItem({ id: "ap", sourceKind: "approval", severity: "high", project: { id: "p1", name: "Alpha", urlKey: "a", color: null, icon: null } });
const join = buildItem({ id: "jn", sourceKind: "join_request", severity: "low", project: null });
const items = [approval, join];
@@ -389,7 +389,7 @@ describe("filterAttentionItems", () => {
describe("buildAttentionFilterOptions", () => {
it("collects the distinct dimensions present in the feed", () => {
const items = [
buildItem({ sourceKind: "approval", severity: "high", project: { id: "p1", name: "Alpha", urlKey: "a" }, workspace: { id: "w1", name: "WS" } }),
buildItem({ sourceKind: "approval", severity: "high", project: { id: "p1", name: "Alpha", urlKey: "a", color: null, icon: null }, workspace: { id: "w1", name: "WS" } }),
buildItem({ sourceKind: "join_request", severity: "low", project: null, workspace: null }),
];
const options = buildAttentionFilterOptions(items);
+1 -7
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { ArrowUpDown, Check, CheckCircle2, Inbox, Layers, ListFilter } from "lucide-react";
import type { Agent, AttentionItem, AttentionProjectRef } from "@paperclipai/shared";
import type { Agent, AttentionItem } from "@paperclipai/shared";
import { attentionApi } from "../api/attention";
import { agentsApi } from "../api/agents";
import { authApi } from "../api/auth";
@@ -217,11 +217,6 @@ export function WhatNeedsMe() {
setPendingRestore((prev) => new Set(prev).add(item.id));
restore(item.dismissalKey);
};
const handleFilterProject = (project: AttentionProjectRef) => {
if (filters.projectIds.includes(project.id)) return;
updateFilters({ ...filters, projectIds: [...filters.projectIds, project.id] });
};
const activeFilterCount = countActiveAttentionFilters(filters);
if (!selectedCompanyId) {
@@ -371,7 +366,6 @@ export function WhatNeedsMe() {
onToggleExpand={() => setExpandedId((prev) => (prev === item.id ? null : item.id))}
onDismiss={handleDismiss}
onSnooze={handleSnooze}
onFilterProject={handleFilterProject}
agentMap={agentMap}
currentUserId={currentUserId}
/>
@@ -188,9 +188,9 @@ const ACTIVITY_OFFSETS: Record<string, number> = {
"budget-1": NOW - 40 * DAY,
};
const PROJECTS: Record<string, AttentionItem["project"]> = {
"appr-1": { id: "proj-alpha", name: "Alpha", urlKey: "alpha" },
"intx-1": { id: "proj-alpha", name: "Alpha", urlKey: "alpha" },
"review-1": { id: "proj-beta", name: "Beta", urlKey: "beta" },
"appr-1": { id: "proj-alpha", name: "Alpha", urlKey: "alpha", color: "#0f766e", icon: "rocket" },
"intx-1": { id: "proj-alpha", name: "Alpha", urlKey: "alpha", color: "#0f766e", icon: "rocket" },
"review-1": { id: "proj-beta", name: "Beta", urlKey: "beta", color: "#7c3aed", icon: "layers" },
};
const DETAILS: Record<string, AttentionItem["detail"]> = {
"recov-1": { kind: "generic", summaryExcerpt: "Agent has not produced output in 40 minutes.", images: [] },
@@ -224,7 +224,7 @@ const SHOWCASE: AttentionItem[] = [
{ id: "approve", label: "Approve plan", description: null },
{ id: "request_changes", label: "Request changes", description: null },
],
project: { id: "proj-alpha", name: "Alpha", urlKey: "alpha" },
project: { id: "proj-alpha", name: "Alpha", urlKey: "alpha", color: "#0f766e", icon: "rocket" },
}),
activityAt: new Date(NOW - 20 * 60 * 1000).toISOString(),
detail: { kind: "plan_approval", issueTitle: "Attention home", planTitle: "Row/card redesign — 8 sections", summaryExcerpt: null, images: [IMAGES[1]] },
@@ -238,7 +238,7 @@ const SHOWCASE: AttentionItem[] = [
{ id: "approve", label: "Approve", description: null },
{ id: "reject", label: "Reject", description: null },
],
project: { id: "proj-beta", name: "Beta", urlKey: "beta" },
project: { id: "proj-beta", name: "Beta", urlKey: "beta", color: "#7c3aed", icon: "layers" },
}),
activityAt: new Date(NOW - 40 * 60 * 1000).toISOString(),
detail: { kind: "confirmation", promptExcerpt: "Ship v2026.707.0 changelog to the public page?", isPlanTarget: false, images: [] },
@@ -248,7 +248,7 @@ const SHOWCASE: AttentionItem[] = [
inlineResolvable: true,
subject: { kind: "interaction", id: "intx-qs", companyId, title: "Answer 2 questions on rollout", identifier: null, status: "pending", href: "/PAP/issues/PAP-1000#qs", metadata: { kind: "ask_user_questions", issueId: "issue-1000" } },
decisionVerbs: [{ id: "respond", label: "Answer", description: null }],
project: { id: "proj-alpha", name: "Alpha", urlKey: "alpha" },
project: PROJECTS["intx-1"],
}),
activityAt: new Date(NOW - 90 * 60 * 1000).toISOString(),
detail: { kind: "questions", questionCount: 2, firstQuestionText: "Which auth provider should we standardize on?", images: [IMAGES[0], IMAGES[2]] },
@@ -383,7 +383,6 @@ function Queue({
onToggleExpand={() => setExpandedId((p) => (p === it.id ? null : it.id))}
onDismiss={(d) => setCleared((prev) => new Set(prev).add(d.id))}
onSnooze={(d) => setCleared((prev) => new Set(prev).add(d.id))}
onFilterProject={() => {}}
/>
))}
</div>