Files
buzz/desktop/src/features/projects/ui/ProjectsActivityFeed.tsx
Thomas Petersen 8728af5040 feat(desktop): refine context-aware Projects collaboration
Give project agents the visible page context and shared thread presentation, while aligning list metadata and detail communication actions with the active workspace.

Signed-off-by: Thomas Petersen <thomasp@squareup.com>
2026-08-17 12:22:33 -04:00

683 lines
22 KiB
TypeScript

import {
resolveUserLabel,
type UserProfileLookup,
} from "@/features/profile/lib/identity";
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
import type {
Project,
ProjectIssue,
ProjectIssueListItem,
ProjectPullRequest,
ProjectPullRequestListItem,
ProjectRepoSnapshot,
Repository,
} from "@/features/projects/hooks";
import type { ProjectsOverviewAgentContextItem } from "@/features/projects/lib/projectDetailAgentContext";
import {
commitShareLink,
issueShareLink,
projectShareLink,
pullRequestShareLink,
} from "@/features/projects/lib/projectShareLinks";
import {
selectionItemFromCommit,
selectionItemFromProject,
selectionItemFromReview,
selectionItemFromTask,
type ProjectSelectionItem,
} from "@/features/projects/lib/projectSelection";
import { useProjectSelection } from "@/features/projects/lib/useProjectSelection";
import {
formatExactTimestamp,
markdownToPlainText,
relativeTime,
} from "@/features/projects/lib/projectsViewHelpers";
import {
projectPullRequestCommentTimelineKind,
projectPullRequestEffectiveReviewDecision,
} from "@/features/projects/projectPullRequests.mjs";
import { cn } from "@/shared/lib/cn";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { BuzzLoadingState } from "@/shared/ui/BuzzLoadingState";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import {
PROJECT_EVENT_VISUALS,
type ProjectEventKind,
} from "./ProjectEventTypeIcon";
import { ProjectEntitySelectControl } from "./ProjectEntityListRow";
type ActivityKind = ProjectEventKind;
type ActivityTarget =
| { type: "project"; project: Project }
| { type: "commit"; project: Project; commitHash: string }
| {
type: "pull-request";
project: Project;
repository: Repository;
pullRequest: ProjectPullRequest;
}
| {
type: "issue";
project: Project;
repository: Repository;
issue: ProjectIssue;
};
type ProjectActivityItem = {
id: string;
kind: ActivityKind;
createdAt: number;
actorPubkey: string | null;
actorName: string | null;
action: string;
title: string;
body: string;
detail: string | null;
target: ActivityTarget;
};
type ProjectActivityGroup = {
key: string;
label: string;
items: ProjectActivityItem[];
};
type ProjectsActivityFeedProps = {
compact?: boolean;
isLoading: boolean;
issues: ProjectIssueListItem[];
onOpenCommit: (project: Project, commitHash: string) => void;
onOpenIssue: (
project: Project,
repository: Repository,
issue: ProjectIssue,
) => void;
onOpenProject: (project: Project) => void;
onOpenPullRequest: (
project: Project,
repository: Repository,
pullRequest: ProjectPullRequest,
) => void;
profiles?: UserProfileLookup;
projects: Project[];
pullRequests: ProjectPullRequestListItem[];
snapshots?: Record<string, ProjectRepoSnapshot>;
};
const ACTIVITY_LIMIT = 30;
const WEEK_SECONDS = 7 * 24 * 60 * 60;
function contentPreview(content: string) {
return markdownToPlainText(content).replace(/\s+/g, " ").trim().slice(0, 280);
}
function activitySelectionItem(
item: ProjectActivityItem,
): ProjectSelectionItem | null {
const project = item.target.project;
const repository =
item.target.type === "issue" || item.target.type === "pull-request"
? item.target.repository
: project.repositories[0];
const channelId = repository?.channelId ?? project.projectChannelId;
if (item.target.type === "commit") {
return selectionItemFromCommit({
author: item.actorPubkey,
channelId,
commitHash: item.target.commitHash,
projectId: project.id,
shareLink: repository
? commitShareLink(repository, item.target.commitHash)
: null,
title: item.title,
});
}
if (item.target.type === "issue") {
return selectionItemFromTask({
author: item.target.issue.author,
channelId,
id: item.target.issue.id,
shareLink: issueShareLink(item.target.issue),
title: item.target.issue.title,
});
}
if (item.target.type === "pull-request") {
return selectionItemFromReview({
author: item.target.pullRequest.author,
channelId,
id: item.target.pullRequest.id,
shareLink: pullRequestShareLink(item.target.pullRequest),
title: item.target.pullRequest.title,
});
}
return selectionItemFromProject({
channelId: project.projectChannelId,
id: project.id,
owner: project.owner,
shareLink: projectShareLink(project),
title: project.name,
});
}
function buildActivityItems({
issues,
projects,
pullRequests,
snapshots,
}: Pick<
ProjectsActivityFeedProps,
"issues" | "projects" | "pullRequests" | "snapshots"
>) {
const items: ProjectActivityItem[] = [];
for (const project of projects) {
items.push({
id: `repository:${project.id}`,
kind: "repository",
createdAt: project.createdAt,
actorPubkey: project.owner,
actorName: null,
action: "created the repository",
title: project.name,
body: contentPreview(project.description),
detail: null,
target: { type: "project", project },
});
}
for (const project of projects) {
const snapshot = snapshots?.[project.id];
const commit = snapshot?.commits.reduce(
(latest, candidate) =>
!latest || candidate.timestamp > latest.timestamp ? candidate : latest,
snapshot.commits[0],
);
if (!commit) continue;
items.push({
id: `commit:${project.id}:${commit.hash}`,
kind: "commit",
createdAt: commit.timestamp,
actorPubkey: null,
actorName: commit.authorName,
action: "pushed a commit to",
title: commit.subject || commit.shortHash,
body: "",
detail: commit.shortHash,
target: {
type: "commit",
project,
commitHash: commit.hash,
},
});
}
for (const { project, pullRequest, repository } of pullRequests) {
const target = {
type: "pull-request",
project,
pullRequest,
repository,
} as const;
items.push({
id: `pr:${repository.id}:${pullRequest.id}`,
kind: "pull-request",
createdAt: pullRequest.createdAt,
actorPubkey: pullRequest.author,
actorName: null,
action: "opened a review in",
title: pullRequest.title,
body: contentPreview(pullRequest.content),
detail: pullRequest.status,
target,
});
for (const update of pullRequest.updates) {
items.push({
id: `pr-update:${repository.id}:${update.id}`,
kind: "commit",
createdAt: update.createdAt,
actorPubkey: update.author,
actorName: null,
action: "updated a review in",
title: pullRequest.title,
body: contentPreview(update.content),
detail: update.commit?.slice(0, 7) ?? null,
target,
});
}
for (const comment of pullRequest.comments) {
const reviewDecision = projectPullRequestEffectiveReviewDecision(
pullRequest,
comment,
);
const timelineKind = projectPullRequestCommentTimelineKind(comment);
const kind =
reviewDecision === "approved"
? "approval"
: reviewDecision === "changes-requested"
? "changes-requested"
: timelineKind === "review-request"
? "review-request"
: "comment";
items.push({
id: `pr-comment:${repository.id}:${comment.id}`,
kind,
createdAt: comment.createdAt,
actorPubkey: comment.author,
actorName: null,
action:
kind === "approval"
? "approved a review in"
: kind === "changes-requested"
? "requested changes to a review in"
: kind === "review-request"
? "requested review in"
: "commented on a review in",
title: pullRequest.title,
body: contentPreview(comment.content),
detail:
kind === "approval"
? "Approved"
: kind === "changes-requested"
? "Changes requested"
: null,
target,
});
}
}
for (const { project, issue, repository } of issues) {
const target = { type: "issue", project, issue, repository } as const;
items.push({
id: `issue:${repository.id}:${issue.id}`,
kind: "issue",
createdAt: issue.createdAt,
actorPubkey: issue.author,
actorName: null,
action: "created a task in",
title: issue.title,
body: contentPreview(issue.content),
detail: issue.status,
target,
});
for (const comment of issue.comments) {
items.push({
id: `issue-comment:${repository.id}:${comment.id}`,
kind: "comment",
createdAt: comment.createdAt,
actorPubkey: comment.author,
actorName: null,
action: "commented on a task in",
title: issue.title,
body: contentPreview(comment.content),
detail: null,
target,
});
}
}
return items
.sort((left, right) => right.createdAt - left.createdAt)
.slice(0, ACTIVITY_LIMIT);
}
export function buildProjectsActivityAgentContextItems(
input: Pick<
ProjectsActivityFeedProps,
"issues" | "projects" | "pullRequests" | "snapshots"
>,
): ProjectsOverviewAgentContextItem[] {
return buildActivityItems(input).map((item) => {
const project = item.target.project;
const repository =
item.target.type === "issue" || item.target.type === "pull-request"
? item.target.repository.name
: null;
return {
detail: [
item.action,
repository ? `${project.name} / ${repository}` : project.name,
item.detail,
item.body,
]
.filter(Boolean)
.join(" · "),
kind: item.kind,
reference: item.id,
title: item.title,
};
});
}
function startOfWeek(timestamp: number) {
const date = new Date(timestamp * 1_000);
date.setHours(0, 0, 0, 0);
const daysSinceMonday = (date.getDay() + 6) % 7;
date.setDate(date.getDate() - daysSinceMonday);
return Math.floor(date.getTime() / 1_000);
}
function groupActivityItems(items: ProjectActivityItem[]) {
const thisWeek = startOfWeek(Math.floor(Date.now() / 1_000));
const lastWeek = thisWeek - WEEK_SECONDS;
const groups: ProjectActivityGroup[] = [
{ key: "this-week", label: "This week", items: [] },
{ key: "last-week", label: "Last week", items: [] },
{ key: "earlier", label: "Earlier", items: [] },
];
for (const item of items) {
if (item.createdAt >= thisWeek) {
groups[0].items.push(item);
} else if (item.createdAt >= lastWeek) {
groups[1].items.push(item);
} else {
groups[2].items.push(item);
}
}
return groups.filter((group) => group.items.length > 0);
}
function ActivityCard({
compact,
isFirst,
isLast,
item,
onOpen,
onOpenProject,
profiles,
rangeItems,
}: {
compact: boolean;
isFirst: boolean;
isLast: boolean;
item: ProjectActivityItem;
onOpen: () => void;
onOpenProject: () => void;
profiles?: UserProfileLookup;
rangeItems: ProjectSelectionItem[];
}) {
const visual = PROJECT_EVENT_VISUALS[item.kind];
const TypeIcon = visual.icon;
const profile = item.actorPubkey
? profiles?.[normalizePubkey(item.actorPubkey)]
: undefined;
const actorLabel = item.actorPubkey
? resolveUserLabel({ profiles, pubkey: item.actorPubkey })
: item.actorName || "Someone";
const selection = useProjectSelection();
const selectionItem = activitySelectionItem(item);
const selected = Boolean(
selectionItem && selection?.isSelected(selectionItem.id),
);
const showSelectControl = Boolean(selectionItem && selection && selected);
return (
<div
className={cn(
"group relative block w-full rounded-xl bg-transparent text-left transition-colors hover:bg-muted/20",
compact ? "py-3 pr-3" : "py-4 pr-4",
selected && "bg-muted/40",
)}
data-selected={selected ? "true" : undefined}
data-testid="projects-activity-card"
>
<button
aria-label={`Open ${item.title} in ${item.target.project.name}`}
className="absolute inset-0 rounded-xl focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
onClick={onOpen}
type="button"
/>
<div className="pointer-events-none relative flex min-w-0 items-start gap-3">
{selectionItem && selection ? (
<span
className={cn(
"mt-0.5 flex w-4 shrink-0 justify-center opacity-0 group-hover:opacity-100",
showSelectControl && "opacity-100",
)}
>
<ProjectEntitySelectControl
checked={selected}
onToggle={({ shiftKey }) =>
selection.toggle(selectionItem, {
rangeItems,
shiftKey,
})
}
/>
</span>
) : (
<span className="w-4 shrink-0" />
)}
{/* Avatar gutter: a vertical spine runs through the avatar centers
to connect consecutive cards. Segments extend into the card's
vertical padding so they meet the neighbouring card's segments;
the first card has no incoming line and the last no outgoing. */}
<div
className={cn(
"relative flex shrink-0 items-start justify-center self-stretch",
compact ? "w-5" : "w-9",
)}
>
{isFirst ? null : (
<span
aria-hidden="true"
className={cn(
"absolute left-1/2 w-px -translate-x-1/2 bg-border/80",
compact ? "-top-3 h-3" : "-top-4 h-4",
)}
/>
)}
{isLast ? null : (
<span
aria-hidden="true"
className={cn(
"absolute left-1/2 w-px -translate-x-1/2 bg-border/80",
compact ? "-bottom-3 top-5" : "-bottom-4 top-9",
)}
/>
)}
{item.actorPubkey ? (
<UserProfilePopover pubkey={item.actorPubkey} triggerElement="span">
<button
aria-label={`View ${actorLabel}'s profile`}
className="pointer-events-auto relative z-10 shrink-0 rounded-full focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
type="button"
>
<UserAvatar
accent={profile?.isAgent === true}
avatarUrl={profile?.avatarUrl ?? null}
displayName={actorLabel}
size={compact ? "xs" : "md"}
/>
</button>
</UserProfilePopover>
) : (
<UserAvatar
accent={profile?.isAgent === true}
avatarUrl={profile?.avatarUrl ?? null}
className="relative z-10 shrink-0"
displayName={actorLabel}
size={compact ? "xs" : "md"}
/>
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-start gap-2">
<div
className="min-w-0 flex-1 text-xs text-muted-foreground/70"
data-projects-text-priority="secondary"
>
<span>
{item.actorPubkey ? (
<UserProfilePopover
pubkey={item.actorPubkey}
triggerElement="span"
>
<button
className="pointer-events-auto relative z-10 rounded-sm font-semibold text-muted-foreground/75 hover:underline focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"
type="button"
>
{actorLabel}
</button>
</UserProfilePopover>
) : (
actorLabel
)}{" "}
{item.action}{" "}
<button
className="pointer-events-auto relative z-10 inline-block max-w-48 truncate rounded-sm align-bottom font-semibold text-muted-foreground/75 hover:underline focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring sm:max-w-64 2xl:max-w-none"
onClick={onOpenProject}
type="button"
>
{item.target.project.name}
</button>
</span>
<Tooltip>
<TooltipTrigger asChild>
<button
className="pointer-events-auto relative z-10 mt-0.5 block w-fit rounded-sm hover:underline focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"
onClick={onOpen}
type="button"
>
{relativeTime(item.createdAt)}
</button>
</TooltipTrigger>
<TooltipContent>
{formatExactTimestamp(item.createdAt)}
</TooltipContent>
</Tooltip>
</div>
{item.detail ? (
<span
className={cn(
"shrink-0 rounded-full border px-2 py-0.5 text-2xs font-medium",
visual.detailClassName,
)}
>
{item.detail}
</span>
) : null}
</div>
<div className={compact ? "mt-2" : "mt-3"}>
{/* Bare event-type glyph beside the headline (no badge circle). */}
<div className="flex min-w-0 items-start gap-2">
<TypeIcon
aria-hidden="true"
className={cn("mt-0.5 h-4 w-4 shrink-0", visual.iconClassName)}
/>
<p
className="min-w-0 flex-1 truncate text-sm font-semibold leading-5 text-foreground"
data-projects-text-priority="primary"
>
{item.title}
</p>
</div>
{item.body ? (
<p
className={cn(
"mt-0.5 text-sm leading-6 text-muted-foreground/65",
compact ? "line-clamp-1" : "line-clamp-2",
)}
data-projects-text-priority="secondary"
>
{item.body}
</p>
) : null}
</div>
</div>
</div>
</div>
);
}
/** Mixed GitHub-style workspace activity shown beneath the overview callouts. */
export function ProjectsActivityFeed(props: ProjectsActivityFeedProps) {
const items = buildActivityItems(props);
const groups = groupActivityItems(items);
const rangeItems = groups.flatMap((group) =>
group.items.flatMap((item) => {
const selectionItem = activitySelectionItem(item);
return selectionItem ? [selectionItem] : [];
}),
);
if (props.isLoading && items.length === 0) {
return <BuzzLoadingState label="Loading project activity" />;
}
if (items.length === 0) {
return (
<div className="rounded-xl border border-dashed border-border/60 px-4 py-12 text-center">
<p className="text-sm font-medium text-foreground">
No project activity yet
</p>
<p className="mt-1 text-sm text-muted-foreground">
Commits, reviews, review decisions, and tasks will appear here.
</p>
</div>
);
}
return (
<div
className="relative space-y-7 bg-transparent"
data-testid="projects-activity-timeline"
>
{groups.map((group) => (
<section data-testid="projects-activity-group" key={group.key}>
<div className="mb-1 flex items-center gap-3">
<h3 className="shrink-0 text-xs font-medium text-muted-foreground">
{group.label}
</h3>
<span aria-hidden="true" className="h-px flex-1 bg-border/70" />
</div>
<div>
{group.items.map((item, index) => {
return (
<div className="relative" key={item.id}>
<ActivityCard
compact={props.compact === true}
isFirst={index === 0}
isLast={index === group.items.length - 1}
item={item}
onOpen={() => {
if (item.target.type === "project") {
props.onOpenProject(item.target.project);
} else if (item.target.type === "commit") {
props.onOpenCommit(
item.target.project,
item.target.commitHash,
);
} else if (item.target.type === "pull-request") {
props.onOpenPullRequest(
item.target.project,
item.target.repository,
item.target.pullRequest,
);
} else {
props.onOpenIssue(
item.target.project,
item.target.repository,
item.target.issue,
);
}
}}
onOpenProject={() =>
props.onOpenProject(item.target.project)
}
profiles={props.profiles}
rangeItems={rangeItems}
/>
</div>
);
})}
</div>
</section>
))}
</div>
);
}