desktop: make project work-item rows easier to scan

Replace opaque event hashes with author, repository, branch, and label
context so duplicate PR and issue titles remain distinguishable.

Signed-off-by: Thomas Petersen <thomasp@squareup.com>
This commit is contained in:
Thomas Petersen
2026-08-12 07:49:31 -04:00
parent 2c5d459280
commit 2576705db0
4 changed files with 126 additions and 67 deletions
@@ -59,20 +59,26 @@ function nextStepLabel(status: ProjectIssue["status"]) {
return "Open issue";
}
function issueLabelsSummary(issue: ProjectIssue) {
const visibleLabels = issue.labels.slice(0, 2);
if (visibleLabels.length === 0) return null;
const hiddenCount = issue.labels.length - visibleLabels.length;
return `${visibleLabels.join(", ")}${hiddenCount > 0 ? ` +${hiddenCount}` : ""}`;
}
function IssueHeader({
authorTestId,
includeDate = true,
issue,
profiles,
project,
repository,
}: {
authorTestId?: string;
includeDate?: boolean;
issue: ProjectIssue;
profiles?: UserProfileLookup;
project: Project;
repository: Repository;
}) {
const authorLabel = resolveUserLabel({ profiles, pubkey: issue.author });
const labelsSummary = issueLabelsSummary(issue);
return (
<div className="-mt-0.5 min-w-0 flex-1">
@@ -84,34 +90,24 @@ function IssueHeader({
<div
className={`flex min-w-0 items-center gap-x-1.5 overflow-hidden whitespace-nowrap ${PROJECT_LIST_ROW_SUBTEXT_CLASS}`}
>
<span>{project.name}</span>
{includeDate ? (
<ProjectAuthorIdentity
label={authorLabel}
profiles={profiles}
pubkey={issue.author}
testId={authorTestId}
/>
<span aria-hidden>·</span>
<span className="truncate">{repository.name}</span>
{labelsSummary ? (
<>
<span>·</span>
<span>created {relativeTime(issue.createdAt)}</span>
<span aria-hidden>·</span>
<span className="truncate">{labelsSummary}</span>
</>
) : null}
<span>·</span>
<span className="inline-flex items-center gap-1">
<span>by</span>
<ProjectAuthorIdentity
label={authorLabel}
profiles={profiles}
pubkey={issue.author}
testId={authorTestId}
/>
<span className="md:hidden" aria-hidden>
·
</span>
{includeDate ? (
<>
<span>·</span>
<span>{issue.status}</span>
</>
) : (
<>
<span className="md:hidden">·</span>
<span className="md:hidden">{issue.status}</span>
</>
)}
<span className="md:hidden">{issue.status}</span>
</div>
</div>
);
@@ -122,12 +118,16 @@ function IssueGridCard({
onOpen,
profiles,
project,
repository,
}: {
issue: ProjectIssue;
onOpen: (project: Project, issue: ProjectIssue) => void;
profiles?: UserProfileLookup;
project: Project;
repository: Repository;
}) {
const authorLabel = resolveUserLabel({ profiles, pubkey: issue.author });
return (
<Card
className="group relative flex min-h-40 flex-col overflow-hidden border-border/60 bg-transparent p-4 shadow-none transition-colors duration-150 hover:bg-muted/20"
@@ -138,12 +138,18 @@ function IssueGridCard({
onClick={() => onOpen(project, issue)}
type="button"
>
<span className="sr-only">View {issue.title}</span>
<span className="sr-only">
View issue {issue.title} by {authorLabel} in {repository.name}
</span>
</button>
<div className="flex min-h-0 flex-1 flex-col gap-3">
<div className="flex min-w-0 items-start gap-3">
<ProjectEventTypeIcon className="h-5 w-5" kind="issue" />
<IssueHeader issue={issue} profiles={profiles} project={project} />
<IssueHeader
issue={issue}
profiles={profiles}
repository={repository}
/>
<Button
className="relative z-10 h-7 shrink-0 px-2.5"
onClick={(event) => {
@@ -166,9 +172,8 @@ function IssueGridCard({
<div className="mt-auto border border-border/60 bg-muted/30 px-2.5 py-2">
<div className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs text-foreground/80">
<span className="font-mono text-foreground">
#{issue.id.slice(0, 8)}
</span>
<span className="font-medium text-foreground">{issue.status}</span>
<span>created {relativeTime(issue.createdAt)}</span>
{issue.comments.length > 0 ? (
<span className="flex items-center gap-1">
<MessageSquare className="h-3.5 w-3.5" />
@@ -187,12 +192,16 @@ function IssueListRow({
onOpen,
profiles,
project,
repository,
}: {
issue: ProjectIssue;
onOpen: (project: Project, issue: ProjectIssue) => void;
profiles?: UserProfileLookup;
project: Project;
repository: Repository;
}) {
const authorLabel = resolveUserLabel({ profiles, pubkey: issue.author });
return (
<div
className={PROJECT_LIST_ROW_CLASS}
@@ -203,16 +212,17 @@ function IssueListRow({
onClick={() => onOpen(project, issue)}
type="button"
>
<span className="sr-only">View {issue.title}</span>
<span className="sr-only">
View issue {issue.title} by {authorLabel} in {repository.name}
</span>
</button>
<div className={PROJECT_LIST_ROW_CONTENT_CLASS}>
<ProjectEventTypeIcon className="h-5 w-5" kind="issue" />
<IssueHeader
authorTestId="projects-issue-author"
includeDate={false}
issue={issue}
profiles={profiles}
project={project}
repository={repository}
/>
<div className={PROJECT_LIST_ROW_TRAILING_CLASS}>
<IssueAssigneeFacepile
@@ -320,6 +330,7 @@ export function ProjectsIssuesList({
}
profiles={profiles}
project={project}
repository={repository}
/>
))}
</div>
@@ -345,6 +356,7 @@ export function ProjectsIssuesList({
}
profiles={profiles}
project={project}
repository={repository}
/>
))}
</div>
@@ -58,21 +58,31 @@ function nextStepLabel(status: ProjectPullRequest["status"]) {
return "Review PR";
}
function pullRequestBranchLabel(pullRequest: ProjectPullRequest) {
if (pullRequest.branchName && pullRequest.targetBranch) {
return `${pullRequest.branchName}${pullRequest.targetBranch}`;
}
return pullRequest.branchName ?? pullRequest.targetBranch;
}
function PullRequestGridCard({
project,
profiles,
pullRequest,
repository,
onOpen,
}: {
project: Project;
profiles?: UserProfileLookup;
pullRequest: ProjectPullRequest;
repository: Repository;
onOpen: (project: Project, pullRequest: ProjectPullRequest) => void;
}) {
const authorLabel = resolveUserLabel({
profiles,
pubkey: pullRequest.author,
});
const branchLabel = pullRequestBranchLabel(pullRequest);
return (
<Card
@@ -84,7 +94,10 @@ function PullRequestGridCard({
onClick={() => onOpen(project, pullRequest)}
type="button"
>
<span className="sr-only">View {pullRequest.title}</span>
<span className="sr-only">
View pull request {pullRequest.title} by {authorLabel} in{" "}
{repository.name}
</span>
</button>
<div className="flex min-h-0 flex-1 flex-col gap-3">
<div className="flex min-w-0 items-start gap-3">
@@ -95,9 +108,21 @@ function PullRequestGridCard({
{pullRequest.title}
</p>
</div>
<p className="truncate text-xs text-muted-foreground">
{project.name}
</p>
<div className="flex min-w-0 items-center gap-x-1.5 overflow-hidden whitespace-nowrap text-xs leading-4 text-muted-foreground">
<ProjectAuthorIdentity
label={authorLabel}
profiles={profiles}
pubkey={pullRequest.author}
/>
<span aria-hidden>·</span>
<span className="truncate">{repository.name}</span>
{branchLabel ? (
<>
<span aria-hidden>·</span>
<span className="truncate">{branchLabel}</span>
</>
) : null}
</div>
</div>
<Button
className="relative z-10 h-7 shrink-0 px-2.5"
@@ -121,21 +146,10 @@ function PullRequestGridCard({
<div className="mt-auto border border-border/60 bg-muted/30 px-2.5 py-2">
<div className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs text-foreground/80">
<span className="font-mono text-foreground">
#{pullRequest.id.slice(0, 8)}
</span>
<span className="font-medium text-foreground">
{pullRequest.status}
</span>
<span>created {relativeTime(pullRequest.createdAt)}</span>
<span>
by{" "}
<ProjectAuthorIdentity
label={authorLabel}
profiles={profiles}
pubkey={pullRequest.author}
/>
</span>
{pullRequest.comments.length > 0 ? (
<span className="flex items-center gap-1">
<MessageSquare className="h-3.5 w-3.5" />
@@ -153,17 +167,20 @@ function PullRequestListRow({
project,
profiles,
pullRequest,
repository,
onOpen,
}: {
project: Project;
profiles?: UserProfileLookup;
pullRequest: ProjectPullRequest;
repository: Repository;
onOpen: (project: Project, pullRequest: ProjectPullRequest) => void;
}) {
const authorLabel = resolveUserLabel({
profiles,
pubkey: pullRequest.author,
});
const branchLabel = pullRequestBranchLabel(pullRequest);
return (
<div
@@ -175,7 +192,10 @@ function PullRequestListRow({
onClick={() => onOpen(project, pullRequest)}
type="button"
>
<span className="sr-only">View {pullRequest.title}</span>
<span className="sr-only">
View pull request {pullRequest.title} by {authorLabel} in{" "}
{repository.name}
</span>
</button>
<div className={PROJECT_LIST_ROW_CONTENT_CLASS}>
<ProjectEventTypeIcon className="h-5 w-5" kind="pull-request" />
@@ -186,20 +206,20 @@ function PullRequestListRow({
<div
className={`flex min-w-0 items-center gap-x-1.5 overflow-hidden whitespace-nowrap ${PROJECT_LIST_ROW_SUBTEXT_CLASS}`}
>
<span>{project.name}</span>
<span>·</span>
<span className="font-mono text-foreground">
#{pullRequest.id.slice(0, 8)}
</span>
<span className="inline-flex items-center gap-1">
<span>by</span>
<ProjectAuthorIdentity
label={authorLabel}
profiles={profiles}
pubkey={pullRequest.author}
testId="projects-pr-author"
/>
</span>
<ProjectAuthorIdentity
label={authorLabel}
profiles={profiles}
pubkey={pullRequest.author}
testId="projects-pr-author"
/>
<span aria-hidden>·</span>
<span className="truncate">{repository.name}</span>
{branchLabel ? (
<>
<span aria-hidden>·</span>
<span className="truncate">{branchLabel}</span>
</>
) : null}
<span className="md:hidden">·</span>
<span className="md:hidden">{pullRequest.status}</span>
</div>
@@ -308,6 +328,7 @@ export function ProjectsPullRequestsList({
profiles={profiles}
project={project}
pullRequest={pullRequest}
repository={repository}
/>
))}
</div>
@@ -333,6 +354,7 @@ export function ProjectsPullRequestsList({
profiles={profiles}
project={project}
pullRequest={pullRequest}
repository={repository}
/>
))}
</div>
@@ -17,7 +17,7 @@ export const PROJECT_LIST_ROW_META_TEXT_CLASS =
export const PROJECT_LIST_ROW_META_CLASS = `flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5 ${PROJECT_LIST_ROW_META_TEXT_CLASS}`;
export const PROJECT_LIST_ROW_SUBTEXT_CLASS =
"mt-0.5 text-sm leading-5 text-muted-foreground";
"mt-0.5 text-xs leading-4 text-muted-foreground";
export const PROJECT_LIST_ROW_PREVIEW_CLASS = `line-clamp-1 ${PROJECT_LIST_ROW_SUBTEXT_CLASS}`;
@@ -75,3 +75,28 @@ test("projects v3 workspace screenshot states", async ({ page }) => {
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/04-pr-detail.png` });
});
test("projects v3 work-item list metadata", async ({ page }) => {
await page.addInitScript(() => {
window.localStorage.setItem("buzz.projects.viewMode", "list");
});
await installMockBridge(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByTestId("open-projects-view").click();
await page.getByTestId("projects-section-prs").click();
const pullRequestRow = page.getByTestId(/^projects-pr-row-/).first();
await expect(pullRequestRow).toBeVisible();
await expect(pullRequestRow).toContainText("feature/mock-2-0");
await expect(pullRequestRow).not.toContainText(/#[0-9a-f]{8}/);
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/05-pr-list-metadata.png` });
await page.getByTestId("projects-section-issues").click();
const issueRow = page.getByTestId(/^projects-issue-row-/).first();
await expect(issueRow).toBeVisible();
await expect(issueRow).toContainText("relay-tools");
await expect(issueRow).not.toContainText(/#[0-9a-f]{8}/);
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/06-issue-list-metadata.png` });
});