mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 10:26:26 +01:00
Show project identity once on decision cards
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
66786fd253
commit
31e71ccd0a
8 files changed
+68
-57
No files matched your search
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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 +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>
|
||||
|
||||
Reference in new issue
Block a user