feat(desktop): collapse work-item detail into a single column and list projects in the sidebar

Replace the body-plus-rail layout on tasks, reviews, and commits with a shared header, labeled meta rows, and collapsible sections, and add a sidebar Projects list with create, delete, and filter controls.

Signed-off-by: Thomas Petersen <thomasp@squareup.com>
This commit is contained in:
Thomas Petersen
2026-08-14 16:53:27 -04:00
parent ce1ebfe6c8
commit c22cbb4004
21 changed files with 2140 additions and 1127 deletions

View File

@@ -4,11 +4,7 @@ import * as React from "react";
import { useCommunities } from "@/features/communities/useCommunities";
import type { ProjectInboxWorkItem } from "@/features/home/lib/projectInbox";
import { ProjectIssueDetail } from "@/features/projects/ui/ProjectIssuesPanel";
import {
ProjectPullRequestDetail,
PullRequestDetailHeader,
PullRequestMetaRail,
} from "@/features/projects/ui/ProjectPullRequestsPanel";
import { ProjectPullRequestDetail } from "@/features/projects/ui/ProjectPullRequestsPanel";
import {
resolveUserLabel,
type UserProfileLookup,
@@ -16,7 +12,6 @@ import {
import { openProjectMergeRecoveryTerminal } from "@/shared/api/projectGit";
import { useElementWidth } from "@/shared/hooks/use-mobile";
import { TopChromeInsetHeader } from "@/shared/layout/TopChromeInsetHeader";
import { cn } from "@/shared/lib/cn";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { Button } from "@/shared/ui/button";
import { UserAvatar } from "@/shared/ui/UserAvatar";
@@ -40,7 +35,7 @@ export function ProjectInboxDetailPane({
const { activeCommunity } = useCommunities();
const [detailContentRef, detailContentWidth] =
useElementWidth<HTMLDivElement>();
const showSideRail = detailContentWidth >= 760;
const showWideActions = detailContentWidth >= 760;
const authorPubkey =
workItem.type === "pull-request"
? workItem.pullRequest.author
@@ -117,13 +112,13 @@ export function ProjectInboxDetailPane({
aria-label={openLabel}
className="shrink-0"
onClick={onOpenProject}
size={showSideRail ? "sm" : "icon"}
size={showWideActions ? "sm" : "icon"}
title={openLabel}
type="button"
variant="ghost"
>
<ExternalLink className="h-4 w-4" />
{showSideRail ? openLabel : null}
{showWideActions ? openLabel : null}
</Button>
</div>
</div>
@@ -135,45 +130,25 @@ export function ProjectInboxDetailPane({
>
<div className="p-3">
<div
className="overflow-hidden rounded-xl border border-border/60 bg-card [container-type:inline-size]"
className="overflow-hidden rounded-xl border border-border/60 bg-card"
data-testid="project-inbox-work-item-card"
>
{workItem.type === "pull-request" ? (
<div
className={cn(
"grid",
showSideRail && "grid-cols-[minmax(0,1fr)_18rem]",
)}
data-testid="project-inbox-work-item-layout"
>
<div className="min-w-0">
<PullRequestDetailHeader
profiles={profiles}
pullRequest={workItem.pullRequest}
/>
<ProjectPullRequestDetail
mode="conversation"
onOpenTerminal={handleOpenMergeRecoveryTerminal}
profiles={profiles}
project={workItem.repository}
pullRequest={workItem.pullRequest}
/>
</div>
<PullRequestMetaRail
<div data-testid="project-inbox-work-item-layout">
{workItem.type === "pull-request" ? (
<ProjectPullRequestDetail
onOpenTerminal={handleOpenMergeRecoveryTerminal}
profiles={profiles}
project={workItem.repository}
pullRequest={workItem.pullRequest}
stacked={!showSideRail}
/>
</div>
) : (
<ProjectIssueDetail
issue={workItem.issue}
profiles={profiles}
project={workItem.repository}
stackMetaRail={!showSideRail}
/>
)}
) : (
<ProjectIssueDetail
issue={workItem.issue}
profiles={profiles}
project={workItem.repository}
/>
)}
</div>
</div>
</div>
</div>

View File

@@ -7,6 +7,9 @@ import {
entityDiscussionQuery,
formatNameList,
groupDiscussionChannels,
mergeOriginDiscussionChannel,
pickOriginConversationEvent,
relayEventToSearchHit,
repositoryDiscussionQuery,
} from "./discussionChannels.ts";
@@ -23,6 +26,82 @@ test("query builders emit the tokens FTS needs, nothing else", () => {
);
});
test("mergeOriginDiscussionChannel prepends the origin when search missed it", () => {
const discussed = groupDiscussionChannels([
{ channelId: "c1", channelName: "general", createdAt: 100, pubkey: ALICE },
]);
assert.deepEqual(
mergeOriginDiscussionChannel(discussed, {
channelId: "origin",
createdAt: 50,
pubkey: BOB,
}),
[
{
id: "origin",
name: null,
messageCount: 1,
lastActivityAt: 50,
participants: [BOB],
},
discussed[0],
],
);
assert.equal(
mergeOriginDiscussionChannel(discussed, {
channelId: "c1",
createdAt: 1,
pubkey: BOB,
}),
discussed,
);
assert.equal(mergeOriginDiscussionChannel(discussed, null), discussed);
});
test("relayEventToSearchHit uses the thread root when present", () => {
const hit = relayEventToSearchHit(
{
id: EVENT_ID,
content: "filed it",
kind: 9,
pubkey: ALICE,
created_at: 50,
tags: [
["h", "origin"],
["e", "root-id", "", "root"],
["e", "parent-id", "", "reply"],
],
},
"origin",
"agents",
);
assert.equal(hit.eventId, EVENT_ID);
assert.equal(hit.channelId, "origin");
assert.equal(hit.channelName, "agents");
assert.equal(hit.threadRootId, "root-id");
});
test("pickOriginConversationEvent prefers the author's newest message", () => {
const origin = { channelId: "c1", createdAt: 100, pubkey: ALICE };
const picked = pickOriginConversationEvent(
[
{ pubkey: BOB, created_at: 90, id: "bob" },
{ pubkey: ALICE, created_at: 40, id: "old" },
{ pubkey: ALICE, created_at: 80, id: "alice" },
],
origin,
);
assert.equal(picked?.id, "alice");
assert.equal(
pickOriginConversationEvent(
[{ pubkey: BOB, created_at: 90, id: "bob" }],
origin,
)?.id,
"bob",
);
assert.equal(pickOriginConversationEvent([], origin), null);
});
test("commit queries match full or short hash citations", () => {
const hash = "0123456789abcdef0123456789abcdef01234567";
assert.equal(
@@ -122,6 +201,13 @@ test("discussionSnippet strips entity links and coordinates", () => {
);
});
test("discussionSnippet keeps markdown markers for the preview renderer", () => {
assert.equal(
discussionSnippet("**blessed** — @Tyler + [PR #5825](https://example.com)"),
"**blessed** — @Tyler + [PR #5825](https://example.com)",
);
});
test("discussionSnippet truncates long content on an ellipsis", () => {
// The cap only bounds DOM size — the row's CSS truncation does the
// visual cut — so it just needs to hold for arbitrarily long content.

View File

@@ -16,6 +16,11 @@
*/
import type { SearchHit } from "@/shared/api/searchTypes";
import type { RelayEvent } from "@/shared/api/types";
import {
getChannelIdFromTags,
getThreadReference,
} from "@/features/messages/lib/threading";
export type DiscussionChannel = {
id: string;
@@ -37,6 +42,72 @@ export function entityDiscussionQuery(eventId: string): string {
return eventId;
}
/** Channel the entity was created from (`h` tag). Search will not find that
* thread: those messages predate the share link. */
export type DiscussionOrigin = {
channelId: string;
createdAt: number;
pubkey: string;
};
/** Prepend the origin channel when search did not already return it. */
export function mergeOriginDiscussionChannel(
channels: DiscussionChannel[],
origin: DiscussionOrigin | null | undefined,
): DiscussionChannel[] {
const channelId = origin?.channelId?.trim();
if (!channelId || !origin) return channels;
if (channels.some((channel) => channel.id === channelId)) {
return channels;
}
return [
{
id: channelId,
name: null,
messageCount: 1,
lastActivityAt: origin.createdAt,
participants: [origin.pubkey.toLowerCase()],
},
...channels,
];
}
/** Newest origin-author message, else the newest message in the channel. */
export function pickOriginConversationEvent<
T extends { pubkey: string; created_at: number },
>(events: readonly T[], origin: DiscussionOrigin): T | null {
if (events.length === 0) return null;
const author = origin.pubkey.toLowerCase();
const byAuthor = events.filter(
(event) => event.pubkey.toLowerCase() === author,
);
const pool = byAuthor.length > 0 ? byAuthor : events;
return [...pool].sort((left, right) => right.created_at - left.created_at)[0];
}
/** Shape the origin channel event into the hit the conversation panel expects. */
export function relayEventToSearchHit(
event: Pick<
RelayEvent,
"id" | "content" | "kind" | "pubkey" | "created_at" | "tags"
>,
channelId: string,
channelName?: string | null,
): SearchHit {
const thread = getThreadReference(event.tags);
return {
eventId: event.id,
content: event.content,
kind: event.kind,
pubkey: event.pubkey,
channelId: getChannelIdFromTags(event.tags) ?? channelId,
channelName: channelName ?? null,
createdAt: event.created_at,
score: 0,
threadRootId: thread.rootId ?? thread.parentId ?? event.id,
};
}
/**
* Search text matching messages that link a repository or any of its PRs
* and issues: all those links carry `owner=<pubkey>&d=<dtag>`, so the owner

View File

@@ -3,6 +3,7 @@ import { test } from "node:test";
import {
isProjectAccessibleToViewer,
isProjectMine,
isRepositoryAccessibleToViewer,
relativeTime,
} from "./projectsViewHelpers.ts";
@@ -44,6 +45,50 @@ function makeAccessInput(overrides = {}) {
};
}
function makeProject(overrides = {}) {
return {
createdAt: 0,
description: "",
dtag: "sprout",
id: "30621:owner:sprout",
name: "sprout",
owner: REPO_OWNER,
primaryRepositoryAddress: null,
projectAddress: "30621:owner:sprout",
projectChannelId: null,
repositories: [],
repositoryAddresses: [],
status: "open",
...overrides,
};
}
test("isProjectMine is true for projects the viewer owns or contributes to", () => {
assert.equal(isProjectMine(makeProject(), undefined), false);
assert.equal(isProjectMine(makeProject(), VIEWER), false);
assert.equal(isProjectMine(makeProject(), REPO_OWNER), true);
assert.equal(
isProjectMine(
makeProject({
owner: "c".repeat(64),
repositories: [makeRepository({ owner: VIEWER })],
}),
VIEWER,
),
true,
);
assert.equal(
isProjectMine(
makeProject({
owner: "c".repeat(64),
repositories: [makeRepository({ contributors: [VIEWER] })],
}),
VIEWER,
),
true,
);
});
test("a channel-bound repository is accessible only to channel members", () => {
const repository = makeRepository();

View File

@@ -1,5 +1,6 @@
import { Hash } from "lucide-react";
import * as React from "react";
import { useQuery } from "@tanstack/react-query";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { useChannelsQuery } from "@/features/channels/hooks";
@@ -11,14 +12,29 @@ import {
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
import {
type DiscussionChannel,
type DiscussionOrigin,
discussionSnippet,
groupDiscussionChannels,
mergeOriginDiscussionChannel,
pickOriginConversationEvent,
relayEventToSearchHit,
} from "@/features/projects/lib/discussionChannels";
import { relativeTime } from "@/features/projects/lib/projectsViewHelpers";
import { useSearchMessagesQuery } from "@/features/search/hooks";
import { relayClient } from "@/shared/api/relayClient";
import type { SearchHit } from "@/shared/api/searchTypes";
import { KIND_FORUM_COMMENT, KIND_FORUM_POST } from "@/shared/constants/kinds";
import {
KIND_FORUM_COMMENT,
KIND_FORUM_POST,
KIND_STREAM_MESSAGE,
KIND_STREAM_MESSAGE_V2,
} from "@/shared/constants/kinds";
import { cn } from "@/shared/lib/cn";
import {
getMentionTagPubkey,
resolveMentionProps,
} from "@/shared/lib/resolveMentionNames";
import { Markdown } from "@/shared/ui/markdown";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import { useProjectConversationPanel } from "./ProjectConversationPanelContext";
@@ -26,6 +42,45 @@ import { useProjectConversationPanel } from "./ProjectConversationPanelContext";
// marker when it fills rather than silently presenting partial totals as exact.
const DISCUSSION_SEARCH_LIMIT = 500;
const COLLAPSED_MENTION_ROWS = 3;
const ORIGIN_CONVERSATION_KINDS = [
KIND_STREAM_MESSAGE,
KIND_STREAM_MESSAGE_V2,
KIND_FORUM_POST,
KIND_FORUM_COMMENT,
];
function useOriginConversationHit(
origin: DiscussionOrigin | null,
hasSearchHit: boolean,
): { hit: SearchHit | null; tags: string[][] | null } {
const query = useQuery({
queryKey: [
"project-origin-conversation",
origin?.channelId ?? null,
origin?.pubkey ?? null,
origin?.createdAt ?? null,
],
enabled: Boolean(origin) && !hasSearchHit,
queryFn: async () => {
if (!origin) return { hit: null, tags: null };
const events = await relayClient.fetchEvents({
kinds: ORIGIN_CONVERSATION_KINDS,
"#h": [origin.channelId],
until: origin.createdAt,
limit: 20,
});
const event = pickOriginConversationEvent(events, origin);
return event
? {
hit: relayEventToSearchHit(event, origin.channelId),
tags: event.tags,
}
: { hit: null, tags: null };
},
staleTime: 30_000,
});
return query.data ?? { hit: null, tags: null };
}
/**
* Messages (and the channels containing them) that link the entity matched
@@ -72,17 +127,19 @@ function useChannelNameLookup(enabled: boolean) {
/**
* "Related Conversations" card for PR, issue, and commit detail views: a
* bordered, softly tinted block with a small header that separates it from
* the surrounding text. Each channel gets a single truncating line —
* "Alice, Bob and Carol discussed this in #channel · 2h ago — snippet…" —
* cut at the card edge regardless of screen width. Clicking the snippet
* opens the conversation panel (forum hits still jump in-place). Renders
* nothing until at least one channel references the entity, so the detail
* layout stays unchanged for undiscussed items.
* bordered block under the body. Each channel gets a name line plus a
* compact markdown preview of the latest message (same `inbox-preview-markdown`
* treatment as inbox rows). Tasks and reviews also include the origin
* channel (`h` tag), because that thread created the entity and will not
* contain a share link yet. Renders nothing when search and origin are both
* empty.
*/
export function DiscussedInChannels({
className,
entityLabel = "this",
originChannelId,
originCreatedAt,
originPubkey,
query,
testId,
}: {
@@ -90,10 +147,45 @@ export function DiscussedInChannels({
className?: string;
/** How the sentence names the entity, e.g. "this issue". */
entityLabel?: string;
originChannelId?: string | null;
originCreatedAt?: number;
originPubkey?: string;
query: string;
testId?: string;
}) {
const { channels, hits, isTruncated } = useDiscussionChannels(query);
const {
channels: discussed,
hits,
isTruncated,
} = useDiscussionChannels(query);
const origin = React.useMemo(
() =>
originChannelId && originPubkey && originCreatedAt != null
? {
channelId: originChannelId,
createdAt: originCreatedAt,
pubkey: originPubkey,
}
: null,
[originChannelId, originCreatedAt, originPubkey],
);
const channels = React.useMemo(
() => mergeOriginDiscussionChannel(discussed, origin),
[discussed, origin],
);
const originConversation = useOriginConversationHit(
origin,
hits.some((hit) => hit.channelId === origin?.channelId),
);
const originSearchHit = originConversation.hit;
const originTags = originConversation.tags;
const originMentionPubkeys = React.useMemo(() => {
if (!originTags) return [];
return originTags.flatMap((tag) => {
const pubkey = getMentionTagPubkey(tag);
return pubkey ? [pubkey] : [];
});
}, [originTags]);
const { goChannel, openSearchHit } = useAppNavigation();
const projectConversationPanel = useProjectConversationPanel();
const [expanded, setExpanded] = React.useState(false);
@@ -102,12 +194,16 @@ export function DiscussedInChannels({
? channels
: channels.slice(0, COLLAPSED_MENTION_ROWS);
const profilesQuery = useUsersBatchQuery(
visible.flatMap((channel) => channel.participants),
[
...visible.flatMap((channel) => channel.participants),
...originMentionPubkeys,
],
{ enabled: visible.length > 0 },
);
const profiles = profilesQuery.data?.profiles;
// Hits are sorted newest first, so the first hit per channel is the one a
// click should land on (and the one worth quoting).
// click should land on (and the one worth quoting). Origin-channel events
// fill in the spawning thread when nobody has pasted a share link yet.
const latestHitByChannel = React.useMemo(() => {
const byChannel = new Map<string, SearchHit>();
for (const hit of hits) {
@@ -115,8 +211,14 @@ export function DiscussedInChannels({
byChannel.set(hit.channelId, hit);
}
}
if (
originSearchHit?.channelId &&
!byChannel.has(originSearchHit.channelId)
) {
byChannel.set(originSearchHit.channelId, originSearchHit);
}
return byChannel;
}, [hits]);
}, [hits, originSearchHit]);
if (channels.length === 0) return null;
const hiddenCount = channels.length - visible.length;
@@ -135,21 +237,32 @@ export function DiscussedInChannels({
<div className="divide-y divide-border/40">
{visible.map((channel) => {
const latestHit = latestHitByChannel.get(channel.id);
if (!latestHit) return null;
const name = channelName(channel.id, channel.name);
const isOriginFallback =
Boolean(originSearchHit) &&
latestHit?.eventId === originSearchHit?.eventId;
const opensForum =
latestHit.kind === KIND_FORUM_POST ||
latestHit.kind === KIND_FORUM_COMMENT;
latestHit != null &&
(latestHit.kind === KIND_FORUM_POST ||
latestHit.kind === KIND_FORUM_COMMENT);
const openConversation = () => {
if (!latestHit) {
void goChannel(channel.id);
return;
}
if (!projectConversationPanel || opensForum) {
void openSearchHit(latestHit);
return;
}
projectConversationPanel.openConversation(latestHit);
};
const previewMentionNames =
isOriginFallback && originTags
? resolveMentionProps(originTags, profiles).mentionNames
: undefined;
return (
<div
className="group relative flex w-full min-w-0 items-center gap-2.5 px-3 py-2 transition-colors hover:bg-muted/30"
className="group relative flex w-full min-w-0 items-start gap-2.5 px-3 py-2 transition-colors hover:bg-muted/30"
data-testid="discussion-mention-row"
key={channel.id}
>
@@ -160,37 +273,45 @@ export function DiscussedInChannels({
title={`Open the latest conversation in #${name}`}
type="button"
/>
<span className="relative z-10">
<span className="relative z-10 pt-0.5">
<ParticipantFacepile
interactive
participants={channel.participants}
profiles={profiles}
/>
</span>
<span className="pointer-events-none relative z-10 min-w-0 flex-1 truncate text-sm">
<DiscussionNameList
participants={channel.participants}
profiles={profiles}
/>
<span className="text-muted-foreground">
{" "}
discussed {entityLabel} in{" "}
</span>
<button
className="pointer-events-auto font-medium text-foreground hover:underline"
onClick={() => void goChannel(channel.id)}
title={`Open #${name}`}
type="button"
>
#{name}
</button>
<span className="text-muted-foreground group-hover:underline">
<span className="text-xs">
<span className="pointer-events-none relative z-10 min-w-0 flex-1 text-sm">
<span className="block truncate">
<DiscussionNameList
participants={channel.participants}
profiles={profiles}
/>
<span className="text-muted-foreground">
{" "}
{latestHit && !isOriginFallback ? "discussed" : "started"}{" "}
{entityLabel} in{" "}
</span>
<button
className="pointer-events-auto font-medium text-foreground hover:underline"
onClick={() => void goChannel(channel.id)}
title={`Open #${name}`}
type="button"
>
#{name}
</button>
<span className="text-xs text-muted-foreground">
{" "}
· {relativeTime(channel.lastActivityAt)}
</span>
<span> — {discussionSnippet(latestHit.content)}</span>
</span>
{latestHit ? (
<span className="block text-xs text-muted-foreground">
<DiscussionMessagePreview
content={latestHit.content}
mentionNames={previewMentionNames}
/>
</span>
) : null}
</span>
</div>
);
@@ -217,6 +338,25 @@ export function DiscussedInChannels({
const NAME_LIST_MAX = 3;
/** Compact markdown preview matching inbox list rows: first block only,
* clamped, non-interactive so the overlay click still opens the thread. */
function DiscussionMessagePreview({
content,
mentionNames,
}: {
content: string;
mentionNames?: string[];
}) {
return (
<Markdown
className="inbox-preview-markdown mt-0.5 text-inherit leading-4"
content={discussionSnippet(content)}
interactive={false}
mentionNames={mentionNames}
/>
);
}
/**
* The "Alice, Bob and Carol" (or "Alice, Bob and 2 others") part of the
* sentence, with each name opening that person's profile popover. Mirrors

View File

@@ -248,7 +248,7 @@ export function IssueAssigneesRow({
})}
{canSelfAssign && viewer ? (
<Button
className="h-6 px-1 text-xs text-muted-foreground hover:text-foreground"
className="h-5 px-1 text-xs text-muted-foreground hover:text-foreground"
data-testid="project-issue-self-assign"
disabled={assignMutation.isPending || unassignMutation.isPending}
onClick={() => {
@@ -265,7 +265,7 @@ export function IssueAssigneesRow({
<Dialog onOpenChange={setPickerOpen} open={pickerOpen}>
<DialogTrigger asChild>
<Button
className="h-6 px-1 text-xs text-muted-foreground hover:text-foreground"
className="h-5 px-1 text-xs text-muted-foreground hover:text-foreground"
data-testid="project-issue-assign"
disabled={assignMutation.isPending || unassignMutation.isPending}
size="xs"

View File

@@ -1,4 +1,4 @@
import { GitCommitHorizontal } from "lucide-react";
import { Calendar, GitCommitHorizontal, Hash } from "lucide-react";
import {
profileForCommit,
@@ -12,6 +12,11 @@ import { commitDiscussionQuery } from "@/features/projects/lib/discussionChannel
import type { ProjectRepoCommit, ProjectRepoDiff } from "@/shared/api/types";
import { DiscussedInChannels } from "./DiscussionChannels";
import { CopyCommitHashButton } from "./ProjectCommitCopyButton";
import {
ProjectDetailMetaList,
ProjectDetailMetaRow,
} from "./ProjectDetailMeta";
import { ProjectDetailSection } from "./ProjectDetailSection";
import { PROJECT_DETAIL_PANEL_CLASS } from "./projectPanelStyles";
import { ProfileIdentityButton } from "./ProjectProfileIdentity";
import { ProjectDiffFilesPanel } from "./ProjectPullRequestFilesChangedPanel";
@@ -26,9 +31,8 @@ function commitDateLabel(timestamp: number) {
}
/**
* Detail view for a single commit: header with author identity and hash,
* followed by the commit-vs-parent diff rendered with the shared changed
* files panel.
* Detail view for a single commit: title and meta header, then collapsible
* description and the commit-vs-parent diff.
*/
export function ProjectCommitDetailPanel({
commit,
@@ -61,82 +65,94 @@ export function ProjectCommitDetailPanel({
? resolveUserLabel({ pubkey: matchedProfile.pubkey, profiles })
: (commit?.authorName ?? commit?.authorEmail ?? "Unknown author");
const shortHash = commit?.shortHash ?? commitHash.slice(0, 7);
const fileCount = diff?.files.length;
return (
<div className="space-y-3">
<header
className={`space-y-2 p-4 ${PROJECT_DETAIL_PANEL_CLASS}`}
data-project-detail-panel
>
<p className="flex flex-wrap items-center gap-1.5 text-xs font-medium text-muted-foreground">
<GitCommitHorizontal className="h-3.5 w-3.5" />
Commit from {authorLabel}
<ProjectOriginReference
agentName={originAgentName}
channelId={originChannelId}
/>
</p>
<div className="flex min-w-0 items-start gap-3">
<div className={PROJECT_DETAIL_PANEL_CLASS} data-project-detail-panel>
<header className="space-y-1 px-4 pb-1 pt-3">
<h3 className="line-clamp-2 text-xl font-semibold text-foreground">
{commit?.subject ?? shortHash}
</h3>
<p className="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs font-medium text-muted-foreground">
<ProfileIdentityButton
avatarClassName="mt-0.5 shrink-0"
avatarSize="md"
avatarClassName="shrink-0"
avatarSize="xs"
avatarUrl={matchedProfile?.profile.avatarUrl ?? null}
isAgent={matchedProfile?.profile.isAgent === true}
label={authorLabel}
pubkey={matchedProfile?.pubkey ?? null}
showLabel={false}
/>
<div className="min-w-0 flex-1 space-y-1">
<h3 className="line-clamp-2 text-base font-semibold text-foreground">
{commit?.subject ?? shortHash}
</h3>
<div className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs leading-4 text-muted-foreground">
<span className="flex items-center gap-0.5 font-mono">
{shortHash}
<CopyCommitHashButton
className="h-6 w-6"
hash={commit?.hash ?? commitHash}
/>
</span>
{commit ? (
<>
<span>·</span>
<span>{commitDateLabel(commit.timestamp)}</span>
</>
) : null}
{diff ? (
<>
<span>·</span>
<span className="text-green-500">+{diff.additions}</span>
<span className="text-destructive">-{diff.deletions}</span>
</>
) : null}
</div>
</div>
</div>
{diff?.commitBody ? (
<span className="font-medium text-foreground">{authorLabel}</span>
{commit ? (
<span
className="shrink-0 whitespace-nowrap"
title={commitDateLabel(commit.timestamp)}
>
{commitDateLabel(commit.timestamp)}
</span>
) : null}
<ProjectOriginReference
agentName={originAgentName}
channelId={originChannelId}
/>
</p>
</header>
<ProjectDetailMetaList>
<ProjectDetailMetaRow icon={Hash} label="Commit">
<span className="inline-flex min-w-0 items-center gap-1 font-mono text-xs">
{shortHash}
<CopyCommitHashButton
className="h-6 w-6"
hash={commit?.hash ?? commitHash}
/>
</span>
</ProjectDetailMetaRow>
{commit ? (
<ProjectDetailMetaRow icon={Calendar} label="Date">
<span className="text-muted-foreground">
{commitDateLabel(commit.timestamp)}
</span>
</ProjectDetailMetaRow>
) : null}
{diff ? (
<ProjectDetailMetaRow icon={GitCommitHorizontal} label="Changes">
<span className="flex items-center gap-1.5">
<span className="text-green-500">+{diff.additions}</span>
<span className="text-destructive">-{diff.deletions}</span>
</span>
</ProjectDetailMetaRow>
) : null}
</ProjectDetailMetaList>
{diff?.commitBody ? (
<ProjectDetailSection defaultOpen title="Description">
<ProjectRichContent
content={diff.commitBody}
hardLineBreaks={false}
/>
) : null}
<DiscussedInChannels
entityLabel="this commit"
query={commitDiscussionQuery({
hash: commit?.hash ?? commitHash,
shortHash: commit?.shortHash,
})}
testId="commit-discussed-in"
/>
</header>
<ProjectDiffFilesPanel
diff={diff}
error={diffError}
headerLabel={`${commit?.subject ?? "Commit"} · ${shortHash}`}
isLoading={diffLoading}
subjectLabel="commit"
</ProjectDetailSection>
) : null}
<DiscussedInChannels
className="mx-4 mb-4"
entityLabel="this commit"
query={commitDiscussionQuery({
hash: commit?.hash ?? commitHash,
shortHash: commit?.shortHash,
})}
testId="commit-discussed-in"
/>
<ProjectDetailSection count={fileCount} defaultOpen title="Files changed">
<div className="-mx-4">
<ProjectDiffFilesPanel
diff={diff}
embedded
error={diffError}
headerLabel={`${commit?.subject ?? "Commit"} · ${shortHash}`}
isLoading={diffLoading}
subjectLabel="commit"
/>
</div>
</ProjectDetailSection>
</div>
);
}

View File

@@ -0,0 +1,81 @@
import type { LucideIcon } from "lucide-react";
import type * as React from "react";
import { cn } from "@/shared/lib/cn";
const META_ROW_CLASS =
"flex min-h-8 min-w-0 items-center gap-2 py-1.5 text-sm leading-5";
/** Chip height matches the meta row's text/avatar line so Labels isn't taller. */
export const PROJECT_DETAIL_META_PILL_CLASS =
"inline-flex h-5 items-center rounded-full border border-border/60 px-1.5 text-2xs leading-none text-muted-foreground";
/** Labeled icon rows under the work-item title, in a padded muted card. */
export function ProjectDetailMetaList({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div
className={cn(
"mx-4 mb-3 mt-3 rounded-xl bg-muted/50 px-3 py-1.5",
className,
)}
data-testid="project-detail-meta"
>
{children}
</div>
);
}
export function ProjectDetailMetaRow({
children,
icon: Icon,
label,
labelClassName,
leading,
}: {
children: React.ReactNode;
icon?: LucideIcon;
label: string;
labelClassName?: string;
leading?: React.ReactNode;
}) {
return (
<div className={META_ROW_CLASS}>
{leading ??
(Icon ? (
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
) : (
<span className="h-4 w-4 shrink-0" />
))}
<span
className={cn(
"w-20 shrink-0 truncate whitespace-nowrap text-muted-foreground",
labelClassName,
)}
title={label}
>
{label}
</span>
<div className="flex min-h-5 min-w-0 flex-1 items-center text-foreground">
{children}
</div>
</div>
);
}
export function ProjectDetailMetaPills({ labels }: { labels: string[] }) {
return (
<div className="flex flex-wrap items-center gap-1">
{labels.map((label) => (
<span className={PROJECT_DETAIL_META_PILL_CLASS} key={label}>
{label}
</span>
))}
</div>
);
}

View File

@@ -0,0 +1,65 @@
import { ChevronDown } from "lucide-react";
import * as React from "react";
import { cn } from "@/shared/lib/cn";
/**
* Expand/collapse block for work-item detail (description, files, activity).
* The chevron sits immediately after the title. It points down when open
* and right when collapsed.
*/
export function ProjectDetailSection({
children,
count,
defaultOpen = true,
onOpenChange,
open: openProp,
testId,
title,
}: {
children: React.ReactNode;
count?: number;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
open?: boolean;
testId?: string;
title: string;
}) {
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen);
const isControlled = openProp !== undefined;
const open = isControlled ? openProp : uncontrolledOpen;
const setOpen = (next: boolean) => {
if (!isControlled) setUncontrolledOpen(next);
onOpenChange?.(next);
};
return (
<section
data-open={open ? "true" : "false"}
data-testid={testId ?? "project-detail-section"}
>
<button
aria-expanded={open}
className="flex min-h-8 w-full min-w-0 items-center gap-2 px-4 py-1.5 text-left text-sm font-semibold leading-5 text-foreground transition-colors hover:bg-muted/20"
onClick={() => setOpen(!open)}
type="button"
>
<span className="flex min-w-0 items-center gap-1.5">
<span className="truncate">{title}</span>
<ChevronDown
className={cn(
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
!open && "-rotate-90",
)}
/>
{count != null ? (
<span className="shrink-0 font-medium text-muted-foreground">
{count}
</span>
) : null}
</span>
</button>
{open ? <div className="px-4 pb-4">{children}</div> : null}
</section>
);
}

View File

@@ -1,4 +1,11 @@
import { CircleCheck, CircleDot, CircleX, MessageSquare } from "lucide-react";
import {
CircleCheck,
CircleDot,
CircleX,
MessageSquare,
Tag,
User,
} from "lucide-react";
import * as React from "react";
import { toast } from "sonner";
@@ -23,7 +30,6 @@ import {
} from "@/features/projects/projectTaskCategories";
import { useIdentityQuery } from "@/shared/api/hooks";
import type { ChannelMember } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { IssueAssigneeFacepile, IssueAssigneesRow } from "./IssueAssigneesRow";
import {
@@ -34,13 +40,14 @@ import {
import { DiscussedInChannels } from "./DiscussionChannels";
import { ProjectIssueCommentTimeline } from "./ProjectIssueCommentTimeline";
import { ProjectOriginReference } from "./ProjectOriginReference";
import { OverviewRailSection } from "./ProjectOverviewPanel";
import {
ProjectDetailMetaList,
ProjectDetailMetaPills,
ProjectDetailMetaRow,
} from "./ProjectDetailMeta";
import { ProjectDetailSection } from "./ProjectDetailSection";
import { ProfileIdentityButton } from "./ProjectProfileIdentity";
import { ProjectRichContent } from "./ProjectRichContent";
import {
PROJECT_DETAIL_META_GRID_WIDE_CLASS,
PROJECT_DETAIL_META_RAIL_WIDE_BORDER_CLASS,
} from "./projectPanelStyles";
import { ShareLinkButton } from "./ShareLinkButton";
export function issueStatusClassName(status: ProjectIssue["status"]) {
@@ -177,12 +184,10 @@ export function ProjectIssueDetail({
issue,
profiles,
project,
stackMetaRail = false,
}: {
issue: ProjectIssue;
profiles?: UserProfileLookup;
project: Project;
stackMetaRail?: boolean;
}) {
const commentMutation = useCreateProjectIssueCommentMutation(project);
const authorLabel = resolveUserLabel({ profiles, pubkey: issue.author });
@@ -213,46 +218,95 @@ export function ProjectIssueDetail({
},
[commentMutation, issue],
);
const identityQuery = useIdentityQuery();
const authorProfile = profiles?.[normalizePubkey(issue.author)];
const status = issueStatusVisual(issue.status);
const labels = projectTaskUserLabels(issue.labels);
const viewerPubkey = identityQuery.data?.pubkey;
const viewer = viewerPubkey ? normalizePubkey(viewerPubkey) : null;
const isAuthor = viewer === normalizePubkey(issue.author);
const isOwner = viewer === normalizePubkey(project.owner);
const isManagedAgentOwner = useIsManagedAgent(project.owner) === true;
const canAssignOthers =
Boolean(viewer) && (isAuthor || isOwner || isManagedAgentOwner);
return (
<div
className={cn(
"grid",
!stackMetaRail && PROJECT_DETAIL_META_GRID_WIDE_CLASS,
)}
>
<div className="min-w-0">
<header className="space-y-3 p-4">
<div className="min-w-0">
<p className="flex flex-wrap items-center gap-1.5 text-xs font-medium text-muted-foreground">
<CircleDot className="h-3.5 w-3.5" />
{projectTaskCategoryLabel(issue.category)} from {authorLabel}
<ProjectOriginReference
agentName={issue.originAgentName}
channelId={issue.channelId}
/>
</p>
<h3 className="mt-1 line-clamp-2 text-base font-semibold text-foreground">
{issue.title}{" "}
<span className="font-normal text-muted-foreground">
#{issue.id.slice(0, 8)}
</span>
<ShareLinkButton
className="ml-1 inline-flex h-6 w-6 align-text-bottom"
label="Copy task link"
link={issueShareLink(issue)}
testId="project-issue-copy-link"
/>
</h3>
</div>
{issue.content ? (
<ProjectRichContent content={issue.content} tags={issue.tags} />
) : null}
</header>
<section className="space-y-3 p-4">
<div>
<header className="space-y-1 px-4 pb-1 pt-3">
<h3 className="line-clamp-2 text-xl font-semibold text-foreground">
{issue.title}{" "}
<span className="font-normal text-muted-foreground">
#{issue.id.slice(0, 8)}
</span>
<ShareLinkButton
className="ml-1 inline-flex h-7 w-7 align-text-bottom"
label="Copy task link"
link={issueShareLink(issue)}
testId="project-issue-copy-link"
/>
</h3>
<p className="flex flex-wrap items-center gap-x-1 gap-y-1 text-xs font-medium text-muted-foreground">
<ProfileIdentityButton
avatarClassName="shrink-0"
avatarSize="xs"
avatarUrl={authorProfile?.avatarUrl ?? null}
isAgent={authorProfile?.isAgent === true}
label={authorLabel}
pubkey={issue.author}
showLabel={false}
/>
<span className="font-medium text-foreground">{authorLabel}</span>
<span
className="shrink-0 whitespace-nowrap"
title={new Date(issue.createdAt * 1_000).toLocaleString()}
>
{relativeTime(issue.createdAt)}
</span>
<ProjectOriginReference
agentName={issue.originAgentName}
channelId={issue.channelId}
/>
</p>
</header>
<ProjectDetailMetaList>
<ProjectDetailMetaRow icon={status.icon} label="Status">
<span className={`font-medium ${status.className}`}>
{issue.status}
</span>
</ProjectDetailMetaRow>
<ProjectDetailMetaRow icon={CircleDot} label="Category">
{projectTaskCategoryLabel(issue.category)}
</ProjectDetailMetaRow>
{issue.assignees.length > 0 || viewer ? (
<ProjectDetailMetaRow icon={User} label="Assignees">
<IssueAssigneesRow
canAssignOthers={canAssignOthers}
issue={issue}
profiles={profiles}
project={project}
signAsManagedOwner={isManagedAgentOwner && !isOwner}
viewerPubkey={viewer}
/>
</ProjectDetailMetaRow>
) : null}
{labels.length > 0 ? (
<ProjectDetailMetaRow icon={Tag} label="Labels">
<ProjectDetailMetaPills labels={labels} />
</ProjectDetailMetaRow>
) : null}
</ProjectDetailMetaList>
{issue.content ? (
<ProjectDetailSection defaultOpen title="Description">
<ProjectRichContent content={issue.content} tags={issue.tags} />
</ProjectDetailSection>
) : null}
<ProjectDetailSection defaultOpen title="Activity">
<div className="space-y-3">
<DiscussedInChannels
entityLabel="this task"
originChannelId={issue.channelId}
originCreatedAt={issue.createdAt}
originPubkey={issue.author}
query={entityDiscussionQuery(issue.id)}
testId="issue-discussed-in"
/>
@@ -272,126 +326,12 @@ export function ProjectIssueDetail({
profiles={profiles}
/>
</div>
</section>
</div>
<IssueMetaRail
issue={issue}
profiles={profiles}
project={project}
stacked={stackMetaRail}
/>
</div>
</ProjectDetailSection>
</div>
);
}
/** Right-hand meta column for the issue detail view: status, assignees,
* author, labels, and dates — keeps the conversation column focused. */
function IssueMetaRail({
issue,
profiles,
project,
stacked = false,
}: {
issue: ProjectIssue;
profiles?: UserProfileLookup;
project: Project;
stacked?: boolean;
}) {
const identityQuery = useIdentityQuery();
const authorProfile = profiles?.[normalizePubkey(issue.author)];
const authorLabel = resolveUserLabel({ profiles, pubkey: issue.author });
const status = issueStatusVisual(issue.status);
const labels = projectTaskUserLabels(issue.labels);
const viewerPubkey = identityQuery.data?.pubkey;
const viewer = viewerPubkey ? normalizePubkey(viewerPubkey) : null;
const isAuthor = viewer === normalizePubkey(issue.author);
const isOwner = viewer === normalizePubkey(project.owner);
const isManagedAgentOwner = useIsManagedAgent(project.owner) === true;
// Same trust rule as parsing (assigneesForIssue): the issue author or
// repo owner (directly or via a managed agent) can assign anyone;
// everyone else who is signed in may still self-assign.
const canAssignOthers =
Boolean(viewer) && (isAuthor || isOwner || isManagedAgentOwner);
return (
<aside
className={cn(
"space-y-6 border-border/60 p-4",
stacked
? "border-t"
: cn("border-t", PROJECT_DETAIL_META_RAIL_WIDE_BORDER_CLASS),
)}
>
<OverviewRailSection title="Status">
<span
className={`inline-flex items-center gap-1.5 rounded-md border border-border/60 px-2.5 py-1 text-xs font-medium ${status.className}`}
>
<status.icon className="h-3.5 w-3.5" />
{issue.status}
</span>
</OverviewRailSection>
<OverviewRailSection title="Category">
<span className="inline-flex rounded-md border border-border/60 px-2.5 py-1 text-xs font-medium text-foreground">
{projectTaskCategoryLabel(issue.category)}
</span>
</OverviewRailSection>
{issue.assignees.length > 0 || viewer ? (
<OverviewRailSection title="Assignees">
<IssueAssigneesRow
canAssignOthers={canAssignOthers}
issue={issue}
profiles={profiles}
project={project}
signAsManagedOwner={isManagedAgentOwner && !isOwner}
viewerPubkey={viewer}
/>
</OverviewRailSection>
) : null}
<OverviewRailSection title="Author">
<ProfileIdentityButton
align="center"
avatarSize="xs"
avatarUrl={authorProfile?.avatarUrl ?? null}
isAgent={authorProfile?.isAgent === true}
label={authorLabel}
pubkey={issue.author}
/>
</OverviewRailSection>
{labels.length > 0 ? (
<OverviewRailSection title="Labels">
<div className="flex flex-wrap gap-1.5">
{labels.map((label) => (
<span
className="rounded-full border border-border/60 px-1.5 py-0.5 text-2xs text-muted-foreground"
key={label}
>
{label}
</span>
))}
</div>
</OverviewRailSection>
) : null}
<OverviewRailSection title="Activity">
<dl className="space-y-1.5 text-xs text-muted-foreground">
<div className="flex items-center justify-between gap-3">
<dt>Created</dt>
<dd className="font-medium text-foreground">
{relativeTime(issue.createdAt)}
</dd>
</div>
<div className="flex items-center justify-between gap-3">
<dt>Updated</dt>
<dd className="font-medium text-foreground">
{relativeTime(issue.updatedAt)}
</dd>
</div>
</dl>
</OverviewRailSection>
</aside>
);
}
export function ProjectIssuesPanel({
onSelectedIssueIdChange,
profiles,

View File

@@ -4,8 +4,6 @@ import {
ChevronUp,
FileCode2,
GitBranch,
GitCommitHorizontal,
GitPullRequest,
History,
MessageSquare,
TriangleAlert,
@@ -42,16 +40,18 @@ import {
ProjectFeedRowMonoCell,
} from "./ProjectFeedRow";
import { CopyCommitHashButton } from "./ProjectCommitCopyButton";
import { ProjectDetailSection } from "./ProjectDetailSection";
import type { OpenMergeRecoveryTerminal } from "./MergePullRequestButton";
import {
ProfileAuthorName,
ProfileIdentityButton,
} from "./ProjectProfileIdentity";
import { ProjectRichContent } from "./ProjectRichContent";
import { PullRequestMetaHeader } from "./PullRequestMetaRail";
import { PullRequestReviewCard } from "./PullRequestReviewCard";
import { ShareLinkButton } from "./ShareLinkButton";
export { PullRequestMetaRail } from "./PullRequestMetaRail";
export { PullRequestMetaHeader } from "./PullRequestMetaRail";
function profileForPubkey(pubkey: string, profiles?: UserProfileLookup) {
return profiles?.[normalizePubkey(pubkey)] ?? null;
@@ -318,10 +318,8 @@ function PullRequestRow({
);
}
export type PullRequestPanelMode = "conversation" | "commits" | "checks";
/** GitHub-style PR title line, rendered as the top section of the PR detail
* card. Status, branches, and dates live in the right-hand meta rail. */
/** GitHub-style PR title line, rendered as the top of the review detail
* card. Status, branches, and dates sit in the meta header below. */
export function PullRequestDetailHeader({
profiles,
pullRequest,
@@ -332,7 +330,7 @@ export function PullRequestDetailHeader({
const authorLabel = labelForPubkey(pullRequest.author, profiles);
return (
<header className="min-w-0 space-y-1 p-4 pb-4">
<header className="min-w-0 space-y-1 px-4 pb-1 pt-3">
<h3 className="line-clamp-2 min-w-0 text-xl font-semibold text-foreground">
{pullRequest.title}{" "}
<span className="font-normal text-muted-foreground">
@@ -349,7 +347,6 @@ export function PullRequestDetailHeader({
className="flex flex-wrap items-center gap-x-1 gap-y-1 text-xs font-medium text-muted-foreground"
data-testid="project-pull-request-detail-metadata"
>
<GitPullRequest className="h-3.5 w-3.5 shrink-0" />
<span
className="flex min-w-0 items-center gap-1"
data-project-metadata-phrase
@@ -369,7 +366,7 @@ export function PullRequestDetailHeader({
data-project-metadata-phrase
title={formatExactTimestamp(pullRequest.createdAt)}
>
created {relativeTime(pullRequest.createdAt)}
{relativeTime(pullRequest.createdAt)}
</span>
<ProjectOriginReference
agentName={pullRequest.originAgentName}
@@ -382,7 +379,10 @@ export function PullRequestDetailHeader({
/** Full pull-request conversation, review actions, and comment composer. */
export function ProjectPullRequestDetail({
mode,
diffStats,
filesChanged,
filesCount,
forceOpenFiles = false,
onOpenInlineComment,
onOpenCommit,
onOpenTerminal,
@@ -390,7 +390,10 @@ export function ProjectPullRequestDetail({
project,
pullRequest,
}: {
mode: PullRequestPanelMode;
diffStats?: { additions: number; deletions: number } | null;
filesChanged?: React.ReactNode;
filesCount?: number;
forceOpenFiles?: boolean;
onOpenInlineComment?: (anchor: ProjectPullRequestCommentAnchor) => void;
onOpenCommit?: (commitHash: string) => void;
onOpenTerminal?: OpenMergeRecoveryTerminal;
@@ -408,10 +411,19 @@ export function ProjectPullRequestDetail({
collapsedReviewHistoryPullRequestIds,
setCollapsedReviewHistoryPullRequestIds,
] = React.useState<Set<string>>(() => new Set());
const [filesOpen, setFilesOpen] = React.useState(forceOpenFiles);
const [filesOpenForId, setFilesOpenForId] = React.useState(pullRequest.id);
if (filesOpenForId !== pullRequest.id) {
setFilesOpenForId(pullRequest.id);
setFilesOpen(forceOpenFiles);
}
const members = React.useMemo(
() => pullRequestMembers(project, pullRequest, profiles),
[profiles, project, pullRequest],
);
React.useEffect(() => {
if (forceOpenFiles) setFilesOpen(true);
}, [forceOpenFiles]);
const submitComment = React.useCallback(
async (
content: string,
@@ -452,52 +464,7 @@ export function ProjectPullRequestDetail({
[submitComment],
);
if (mode === "commits") {
const commitCount = Math.max(1, pullRequest.updates.length + 1);
return (
<section>
<header className="flex min-h-10 items-center gap-2 border-b border-border/50 bg-muted/20 px-4">
<GitCommitHorizontal className="h-4 w-4 text-muted-foreground" />
<h4 className="text-sm font-medium text-foreground">Commits</h4>
<span className="rounded-full bg-muted px-1.5 py-0.5 text-2xs text-muted-foreground">
{commitCount}
</span>
</header>
<div className="divide-y divide-border/50">
<PullRequestCommitRow
author={pullRequest.author}
branch={pullRequest.branchName}
createdAt={pullRequest.createdAt}
hash={pullRequest.commit}
message={pullRequest.title}
onOpenCommit={onOpenCommit}
profiles={profiles}
/>
{pullRequest.updates.map((update) => (
<PullRequestCommitRow
author={update.author}
branch={pullRequest.branchName}
createdAt={update.createdAt}
hash={update.commit}
key={update.id}
message={update.content.trim() || "Updated review branch"}
onOpenCommit={onOpenCommit}
profiles={profiles}
/>
))}
</div>
</section>
);
}
if (mode === "checks") {
return (
<p className="p-4 text-sm text-muted-foreground">
No checks have been reported for this review yet.
</p>
);
}
const commitCount = Math.max(1, pullRequest.updates.length + 1);
const reviewHistory = pullRequest.comments
.map((item) => ({
item,
@@ -530,268 +497,331 @@ export function ProjectPullRequestDetail({
return (
<div>
{pullRequest.content ? (
<header className="p-4">
<ProjectRichContent
content={pullRequest.content}
tags={pullRequest.tags}
/>
</header>
) : null}
{pullRequest.updates.length > 0 ? (
<section className="space-y-3 border-border/50 border-t p-4">
<h4 className="text-sm font-semibold text-foreground">Updates</h4>
{pullRequest.updates.map((update) => (
<article className="space-y-1" key={update.id}>
<div className="flex min-w-0 items-center justify-between gap-3">
<AuthorIdentity
profiles={profiles}
pubkey={update.author}
role={
<span title={formatExactTimestamp(update.createdAt)}>
{relativeTime(update.createdAt)}
</span>
}
/>
{update.commit ? (
<CommitHashChip
hash={update.commit}
onOpenCommit={onOpenCommit}
/>
) : null}
</div>
{update.content ? (
<ProjectRichContent
className="text-sm text-muted-foreground"
content={update.content}
tags={update.tags}
/>
) : null}
</article>
))}
</section>
) : null}
<section className="space-y-3 p-4">
<DiscussedInChannels
entityLabel="this review"
query={entityDiscussionQuery(pullRequest.id)}
testId="pull-request-discussed-in"
/>
<div className="group/timeline -mx-4 overflow-hidden">
{reviewHistory.length > 0 ? (
<button
aria-expanded={!reviewHistoryCollapsed}
className="flex min-h-10 w-full items-center gap-2 px-3 py-2.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground"
data-testid="project-pull-request-review-history-toggle"
onClick={() => {
setCollapsedReviewHistoryPullRequestIds((current) => {
const next = new Set(current);
if (reviewHistoryCollapsed) {
next.delete(pullRequest.id);
} else {
next.add(pullRequest.id);
}
return next;
});
}}
type="button"
>
<span className="relative flex w-5 shrink-0 justify-center self-stretch">
{reviewHistoryCollapsed ? (
<span className="absolute top-2.5 -bottom-11 hidden w-px bg-border/80 group-has-[.pull-request-action-timeline]/timeline:block" />
) : (
<span className="absolute top-2.5 -bottom-[1.875rem] w-px bg-border/80" />
)}
<span className="relative z-10 flex h-5 w-5 items-center justify-center rounded-full bg-primary/10 text-primary ring-1 ring-primary/35">
<History className="h-3 w-3" />
</span>
</span>
<span className="flex min-h-5 min-w-0 flex-1 items-center text-left">
{reviewHistoryCollapsed
? `Show ${reviewHistory.length} earlier ${
reviewHistory.length === 1 ? "activity" : "activities"
}`
: "Collapse review history"}
</span>
{reviewHistoryCollapsed ? (
<ChevronDown className="mt-0.5 h-3.5 w-3.5" />
) : (
<ChevronUp className="mt-0.5 h-3.5 w-3.5" />
)}
</button>
<PullRequestDetailHeader profiles={profiles} pullRequest={pullRequest} />
<PullRequestMetaHeader
diffStats={diffStats}
profiles={profiles}
project={project}
pullRequest={pullRequest}
/>
{pullRequest.content || pullRequest.updates.length > 0 ? (
<ProjectDetailSection defaultOpen title="Description">
{pullRequest.content ? (
<ProjectRichContent
content={pullRequest.content}
tags={pullRequest.tags}
/>
) : null}
{!reviewHistoryCollapsed &&
earlierReviewHistoryCount > 0 &&
!reviewHistoryExpanded ? (
<button
className="flex min-h-10 w-full items-center gap-2 px-3 py-2.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground"
data-testid="project-pull-request-earlier-activities"
onClick={() => {
setExpandedReviewHistoryPullRequestIds((current) => {
const next = new Set(current);
next.add(pullRequest.id);
return next;
});
}}
type="button"
{pullRequest.updates.length > 0 ? (
<div
className={pullRequest.content ? "mt-4 space-y-3" : "space-y-3"}
>
<span className="relative flex w-5 shrink-0 justify-center self-stretch">
<span className="absolute top-2.5 -bottom-[1.875rem] w-px bg-border/80" />
<span className="relative z-10 flex h-5 w-5 items-center justify-center rounded-full bg-background ring-1 ring-border/70">
<ChevronDown className="h-3 w-3" />
</span>
</span>
<span className="min-w-0 flex-1 text-left">
Show {earlierReviewHistoryCount} earlier{" "}
{earlierReviewHistoryCount === 1 ? "activity" : "activities"}
</span>
</button>
) : null}
{displayedReviewHistory.map(({ item, timelineKind }, index) => {
const isHistoricalDecision =
item.reviewDecisionStatus === "historical";
const trimmedContent = item.content.trim();
const activityContent =
timelineKind === null
? trimmedContent
: timelineKind === "changes-requested" &&
!/^requested changes\.?$/i.test(trimmedContent)
? trimmedContent
: timelineKind === "approved" &&
!/^approved (these )?changes\.?$/i.test(trimmedContent)
? trimmedContent
: null;
return (
<div
className="flex min-h-10 min-w-0 items-start gap-2 px-3 py-2.5 text-sm text-muted-foreground"
data-testid="project-pull-request-timeline-row"
key={item.id}
>
<div className="relative flex w-5 shrink-0 justify-center self-stretch">
{index < displayedReviewHistory.length - 1 ? (
<span className="absolute top-2.5 -bottom-[1.875rem] w-px bg-border/80" />
) : (
<span className="absolute top-2.5 -bottom-11 hidden w-px bg-border/80 group-has-[.pull-request-action-timeline]/timeline:block" />
)}
<span className="relative z-10 flex h-5 w-5 items-center justify-center rounded-full bg-background ring-1 ring-border/70">
{timelineKind === "approved" ? (
<Check
className={`h-3 w-3 ${
isHistoricalDecision
? "text-muted-foreground"
: "text-green-600 dark:text-green-500"
}`}
<h4 className="text-sm font-semibold text-foreground">Updates</h4>
{pullRequest.updates.map((update) => (
<article className="space-y-1" key={update.id}>
<div className="flex min-w-0 items-center justify-between gap-3">
<AuthorIdentity
profiles={profiles}
pubkey={update.author}
role={
<span title={formatExactTimestamp(update.createdAt)}>
{relativeTime(update.createdAt)}
</span>
}
/>
{update.commit ? (
<CommitHashChip
hash={update.commit}
onOpenCommit={onOpenCommit}
/>
) : timelineKind === "changes-requested" ? (
<TriangleAlert
className={`h-3 w-3 ${
isHistoricalDecision
? "text-muted-foreground"
: "text-amber-600 dark:text-amber-400"
}`}
/>
) : timelineKind === "review-request" ? (
<UserPlus className="h-3 w-3" />
) : (
<MessageSquare className="h-3 w-3" />
)}
</span>
</div>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center">
<span className="min-w-0 truncate">
<ProfileAuthorName pubkey={item.author}>
{labelForPubkey(item.author, profiles)}
</ProfileAuthorName>
{timelineKind ? (
<>
{" "}
{timelineKind === "approved"
? isHistoricalDecision
? "approved an earlier commit"
: "approved these changes"
: timelineKind === "changes-requested"
? isHistoricalDecision
? "requested changes on an earlier commit"
: "requested changes"
: trimmedContent || "requested a review"}
</>
) : null}
</span>
<span
className="ml-auto w-20 shrink-0 text-right text-xs text-muted-foreground/70"
title={formatExactTimestamp(item.createdAt)}
>
{relativeTime(item.createdAt)}
</span>
) : null}
</div>
{activityContent ? (
{update.content ? (
<ProjectRichContent
className="mt-1 text-sm text-foreground/90"
content={activityContent}
tags={item.tags}
className="text-sm text-muted-foreground"
content={update.content}
tags={update.tags}
/>
) : null}
{item.anchor ? (
<button
aria-label={`Open ${item.anchor.path} ${item.anchor.side} line ${item.anchor.line} in Files changed`}
className="mt-1 inline-flex min-w-0 items-center gap-1 rounded-md bg-muted/65 px-1.5 py-0.5 font-mono text-2xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onClick={() => {
if (item.anchor) onOpenInlineComment?.(item.anchor);
}}
type="button"
>
<FileCode2 className="h-3 w-3 shrink-0" />
<span className="truncate">{item.anchor.path}</span>
<span className="shrink-0">
{item.anchor.side === "new" ? "+" : "-"}
{item.anchor.line}
</article>
))}
</div>
) : null}
</ProjectDetailSection>
) : null}
{filesChanged ? (
<ProjectDetailSection
count={filesCount}
defaultOpen={false}
onOpenChange={setFilesOpen}
open={filesOpen}
testId="project-detail-section-files"
title="Files changed"
>
<div className="-mx-4">{filesChanged}</div>
</ProjectDetailSection>
) : null}
<ProjectDetailSection
count={commitCount}
defaultOpen={false}
title="Commits"
>
<div className="-mx-4 divide-y divide-border/50">
<PullRequestCommitRow
author={pullRequest.author}
branch={pullRequest.branchName}
createdAt={pullRequest.createdAt}
hash={pullRequest.commit}
message={pullRequest.title}
onOpenCommit={onOpenCommit}
profiles={profiles}
/>
{pullRequest.updates.map((update) => (
<PullRequestCommitRow
author={update.author}
branch={pullRequest.branchName}
createdAt={update.createdAt}
hash={update.commit}
key={update.id}
message={update.content.trim() || "Updated review branch"}
onOpenCommit={onOpenCommit}
profiles={profiles}
/>
))}
</div>
</ProjectDetailSection>
<ProjectDetailSection defaultOpen={false} title="Checks">
<p className="text-sm text-muted-foreground">
No checks have been reported for this review yet.
</p>
</ProjectDetailSection>
<ProjectDetailSection defaultOpen title="Activity">
<div className="space-y-3">
<DiscussedInChannels
entityLabel="this review"
originChannelId={pullRequest.channelId}
originCreatedAt={pullRequest.createdAt}
originPubkey={pullRequest.author}
query={entityDiscussionQuery(pullRequest.id)}
testId="pull-request-discussed-in"
/>
<div className="group/timeline -mx-4 overflow-hidden">
{reviewHistory.length > 0 ? (
<button
aria-expanded={!reviewHistoryCollapsed}
className="flex min-h-10 w-full items-center gap-2 px-3 py-2.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground"
data-testid="project-pull-request-review-history-toggle"
onClick={() => {
setCollapsedReviewHistoryPullRequestIds((current) => {
const next = new Set(current);
if (reviewHistoryCollapsed) {
next.delete(pullRequest.id);
} else {
next.add(pullRequest.id);
}
return next;
});
}}
type="button"
>
<span className="relative flex w-5 shrink-0 justify-center self-stretch">
{reviewHistoryCollapsed ? (
<span className="absolute top-2.5 -bottom-11 hidden w-px bg-border/80 group-has-[.pull-request-action-timeline]/timeline:block" />
) : (
<span className="absolute top-2.5 -bottom-[1.875rem] w-px bg-border/80" />
)}
<span className="relative z-10 flex h-5 w-5 items-center justify-center rounded-full bg-primary/10 text-primary ring-1 ring-primary/35">
<History className="h-3 w-3" />
</span>
</span>
<span className="flex min-h-5 min-w-0 flex-1 items-center text-left">
{reviewHistoryCollapsed
? `Show ${reviewHistory.length} earlier ${
reviewHistory.length === 1 ? "activity" : "activities"
}`
: "Collapse review history"}
</span>
{reviewHistoryCollapsed ? (
<ChevronDown className="mt-0.5 h-3.5 w-3.5" />
) : (
<ChevronUp className="mt-0.5 h-3.5 w-3.5" />
)}
</button>
) : null}
{!reviewHistoryCollapsed &&
earlierReviewHistoryCount > 0 &&
!reviewHistoryExpanded ? (
<button
className="flex min-h-10 w-full items-center gap-2 px-3 py-2.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground"
data-testid="project-pull-request-earlier-activities"
onClick={() => {
setExpandedReviewHistoryPullRequestIds((current) => {
const next = new Set(current);
next.add(pullRequest.id);
return next;
});
}}
type="button"
>
<span className="relative flex w-5 shrink-0 justify-center self-stretch">
<span className="absolute top-2.5 -bottom-[1.875rem] w-px bg-border/80" />
<span className="relative z-10 flex h-5 w-5 items-center justify-center rounded-full bg-background ring-1 ring-border/70">
<ChevronDown className="h-3 w-3" />
</span>
</span>
<span className="min-w-0 flex-1 text-left">
Show {earlierReviewHistoryCount} earlier{" "}
{earlierReviewHistoryCount === 1 ? "activity" : "activities"}
</span>
</button>
) : null}
{displayedReviewHistory.map(({ item, timelineKind }, index) => {
const isHistoricalDecision =
item.reviewDecisionStatus === "historical";
const trimmedContent = item.content.trim();
const activityContent =
timelineKind === null
? trimmedContent
: timelineKind === "changes-requested" &&
!/^requested changes\.?$/i.test(trimmedContent)
? trimmedContent
: timelineKind === "approved" &&
!/^approved (these )?changes\.?$/i.test(trimmedContent)
? trimmedContent
: null;
return (
<div
className="flex min-h-10 min-w-0 items-start gap-2 px-3 py-2.5 text-sm text-muted-foreground"
data-testid="project-pull-request-timeline-row"
key={item.id}
>
<div className="relative flex w-5 shrink-0 justify-center self-stretch">
{index < displayedReviewHistory.length - 1 ? (
<span className="absolute top-2.5 -bottom-[1.875rem] w-px bg-border/80" />
) : (
<span className="absolute top-2.5 -bottom-11 hidden w-px bg-border/80 group-has-[.pull-request-action-timeline]/timeline:block" />
)}
<span className="relative z-10 flex h-5 w-5 items-center justify-center rounded-full bg-background ring-1 ring-border/70">
{timelineKind === "approved" ? (
<Check
className={`h-3 w-3 ${
isHistoricalDecision
? "text-muted-foreground"
: "text-green-600 dark:text-green-500"
}`}
/>
) : timelineKind === "changes-requested" ? (
<TriangleAlert
className={`h-3 w-3 ${
isHistoricalDecision
? "text-muted-foreground"
: "text-amber-600 dark:text-amber-400"
}`}
/>
) : timelineKind === "review-request" ? (
<UserPlus className="h-3 w-3" />
) : (
<MessageSquare className="h-3 w-3" />
)}
</span>
</div>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center">
<span className="min-w-0 truncate">
<ProfileAuthorName pubkey={item.author}>
{labelForPubkey(item.author, profiles)}
</ProfileAuthorName>
{timelineKind ? (
<>
{" "}
{timelineKind === "approved"
? isHistoricalDecision
? "approved an earlier commit"
: "approved these changes"
: timelineKind === "changes-requested"
? isHistoricalDecision
? "requested changes on an earlier commit"
: "requested changes"
: trimmedContent || "requested a review"}
</>
) : null}
</span>
{item.inlineCommentStatus === "outdated" ? (
<span className="shrink-0 text-destructive">
Outdated
<span
className="ml-auto w-20 shrink-0 text-right text-xs text-muted-foreground/70"
title={formatExactTimestamp(item.createdAt)}
>
{relativeTime(item.createdAt)}
</span>
</div>
{activityContent ? (
<ProjectRichContent
className="mt-1 text-sm text-foreground/90"
content={activityContent}
tags={item.tags}
/>
) : null}
{item.anchor ? (
<button
aria-label={`Open ${item.anchor.path} ${item.anchor.side} line ${item.anchor.line} in Files changed`}
className="mt-1 inline-flex min-w-0 items-center gap-1 rounded-md bg-muted/65 px-1.5 py-0.5 font-mono text-2xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onClick={() => {
if (item.anchor) onOpenInlineComment?.(item.anchor);
}}
type="button"
>
<FileCode2 className="h-3 w-3 shrink-0" />
<span className="truncate">{item.anchor.path}</span>
<span className="shrink-0">
{item.anchor.side === "new" ? "+" : "-"}
{item.anchor.line}
</span>
) : null}
</button>
) : null}
{item.inlineCommentStatus === "outdated" ? (
<span className="shrink-0 text-destructive">
Outdated
</span>
) : null}
</button>
) : null}
</div>
</div>
</div>
);
})}
<div className="flex min-h-12 items-start justify-start px-3 py-2.5">
<PullRequestReviewCard
onOpenTerminal={onOpenTerminal}
project={project}
pullRequest={pullRequest}
);
})}
<div className="flex min-h-12 items-start justify-start px-3 py-2.5">
<PullRequestReviewCard
onOpenTerminal={onOpenTerminal}
project={project}
pullRequest={pullRequest}
/>
</div>
</div>
<div data-testid="project-pull-request-comment-composer">
<ForumComposer
className="border border-border/60 bg-background/45"
disabled={commentMutation.isPending}
isSending={commentMutation.isPending}
members={members}
onSecondarySubmit={
canRequestChanges ? handleChangeRequestSubmit : undefined
}
onSubmit={handleCommentSubmit}
placeholder="Add a comment…"
profiles={profiles}
secondarySubmitLabel="Request changes"
/>
</div>
</div>
<div data-testid="project-pull-request-comment-composer">
<ForumComposer
className="border border-border/60 bg-background/45"
disabled={commentMutation.isPending}
isSending={commentMutation.isPending}
members={members}
onSecondarySubmit={
canRequestChanges ? handleChangeRequestSubmit : undefined
}
onSubmit={handleCommentSubmit}
placeholder="Add a comment…"
profiles={profiles}
secondarySubmitLabel="Request changes"
/>
</div>
</section>
</ProjectDetailSection>
</div>
);
}
export function PullRequestsPanel({
diffStats,
error,
filesChanged,
filesCount,
forceOpenFiles,
isLoading,
mode = "conversation",
onOpenInlineComment,
onOpenCommit,
onOpenTerminal,
@@ -801,9 +831,12 @@ export function PullRequestsPanel({
pullRequests,
selectedPullRequestId,
}: {
diffStats?: { additions: number; deletions: number } | null;
error: unknown;
filesChanged?: React.ReactNode;
filesCount?: number;
forceOpenFiles?: boolean;
isLoading: boolean;
mode?: PullRequestPanelMode;
onOpenInlineComment?: (anchor: ProjectPullRequestCommentAnchor) => void;
onOpenCommit?: (commitHash: string) => void;
onOpenTerminal?: OpenMergeRecoveryTerminal;
@@ -844,7 +877,10 @@ export function PullRequestsPanel({
if (selectedPullRequest) {
return (
<ProjectPullRequestDetail
mode={mode}
diffStats={diffStats}
filesChanged={filesChanged}
filesCount={filesCount}
forceOpenFiles={forceOpenFiles}
onOpenInlineComment={onOpenInlineComment}
onOpenCommit={onOpenCommit}
onOpenTerminal={onOpenTerminal}

View File

@@ -1,6 +1,5 @@
import { BookOpen } from "lucide-react";
import type { ProjectPullRequest } from "@/features/projects/hooks";
import { cn } from "@/shared/lib/cn";
import { TabsList, TabsTrigger } from "@/shared/ui/tabs";
@@ -65,42 +64,3 @@ export function ProjectTabsList({ prsActive }: { prsActive?: boolean }) {
</TabsList>
);
}
const PR_TAB_TRIGGER_CLASS =
"h-9 gap-1.5 rounded-none border-b-2 border-transparent px-0 text-muted-foreground hover:text-foreground data-[state=active]:border-foreground data-[state=active]:bg-transparent data-[state=active]:text-foreground data-[state=active]:shadow-none";
export function PullRequestTabsList({
filesCount,
pullRequest,
}: {
filesCount: number;
pullRequest: ProjectPullRequest;
}) {
const commitCount = Math.max(1, pullRequest.updateCount + 1);
return (
<TabsList className="h-9 w-fit justify-start gap-6 bg-transparent p-0">
<TabsTrigger className={PR_TAB_TRIGGER_CLASS} value="pr-conversation">
Conversation
<span className="rounded-full bg-muted px-1.5 py-0.5 text-2xs">
{pullRequest.comments.length}
</span>
</TabsTrigger>
<TabsTrigger className={PR_TAB_TRIGGER_CLASS} value="pr-commits">
Commits
<span className="rounded-full bg-muted px-1.5 py-0.5 text-2xs">
{commitCount}
</span>
</TabsTrigger>
<TabsTrigger className={PR_TAB_TRIGGER_CLASS} value="pr-checks">
Checks
<span className="rounded-full bg-muted px-1.5 py-0.5 text-2xs">0</span>
</TabsTrigger>
<TabsTrigger className={PR_TAB_TRIGGER_CLASS} value="pr-files">
Files changed
<span className="rounded-full bg-muted px-1.5 py-0.5 text-2xs">
{filesCount}
</span>
</TabsTrigger>
</TabsList>
);
}

View File

@@ -53,18 +53,10 @@ import {
type GitDataState,
ProjectOverviewPanel,
} from "./ProjectOverviewPanel";
import {
PullRequestDetailHeader,
PullRequestMetaRail,
PullRequestsPanel,
} from "./ProjectPullRequestsPanel";
import {
ProjectTabsList,
PullRequestTabsList,
} from "./ProjectWorkspaceTabList";
import { PullRequestsPanel } from "./ProjectPullRequestsPanel";
import { ProjectTabsList } from "./ProjectWorkspaceTabList";
import { ProjectPullRequestFilesChangedPanel } from "./ProjectPullRequestFilesChangedPanel";
import {
PROJECT_DETAIL_META_GRID_WIDE_CLASS,
PROJECT_DETAIL_PANEL_CLASS,
PROJECT_DETAIL_PANEL_MESSAGE_CLASS,
} from "./projectPanelStyles";
@@ -264,16 +256,10 @@ export function WorkspaceTabs({
React.useEffect(() => {
if (isPullRequestSelected) {
setSelectedTab((currentTab) =>
currentTab.startsWith("pr-") ? currentTab : "pr-conversation",
);
setSelectedTab("prs");
if (selectedPullRequest?.branchName) {
onBranchChange(selectedPullRequest.branchName);
}
} else {
setSelectedTab((currentTab) =>
currentTab.startsWith("pr-") ? "prs" : currentTab,
);
}
}, [isPullRequestSelected, onBranchChange, selectedPullRequest?.branchName]);
@@ -292,7 +278,7 @@ export function WorkspaceTabs({
const handleTabChange = React.useCallback(
(nextTab: string) => {
setSelectedTab(nextTab);
if (!nextTab.startsWith("pr-")) {
if (nextTab !== "prs") {
onSelectedPullRequestIdChange(null);
}
if (nextTab !== "issues") {
@@ -315,7 +301,6 @@ export function WorkspaceTabs({
anchor: { ...anchor },
pullRequestId: selectedPullRequestId,
});
setSelectedTab("pr-files");
},
[selectedPullRequestId],
);
@@ -338,7 +323,7 @@ export function WorkspaceTabs({
icon={CircleDot}
title="Tasks"
/>
) : selectedTab === "prs" ? (
) : selectedTab === "prs" && !selectedPullRequestId ? (
<ProjectSectionHeader
action={{
disabled:
@@ -438,72 +423,6 @@ export function WorkspaceTabs({
</div>
) : null}
{sectionHeader}
{selectedPullRequest ? (
<div className={PROJECT_DETAIL_PANEL_CLASS} data-project-detail-panel>
{/* Two full-height columns: the meta rail runs all the way to the
top of the card, alongside the header and tabs. */}
<div className={cn("grid", PROJECT_DETAIL_META_GRID_WIDE_CLASS)}>
<div className="min-w-0">
<PullRequestDetailHeader
profiles={profiles}
pullRequest={selectedPullRequest}
/>
<div className="border-b border-border/60 px-4">
<PullRequestTabsList
filesCount={repoDiff?.files.length ?? files.length}
pullRequest={selectedPullRequest}
/>
</div>
{(["conversation", "commits", "checks"] as const).map(
(mode) => (
<TabsContent
className="m-0"
key={mode}
value={`pr-${mode}`}
>
<PullRequestsPanel
error={pullRequestsError}
isLoading={pullRequestsLoading}
mode={mode}
onOpenInlineComment={handleOpenPullRequestComment}
onOpenCommit={onSelectedCommitHashChange}
onOpenTerminal={onOpenMergeRecoveryTerminal}
onSelectedPullRequestIdChange={
onSelectedPullRequestIdChange
}
profiles={profiles}
project={project}
pullRequests={pullRequests}
selectedPullRequestId={selectedPullRequestId}
/>
</TabsContent>
),
)}
<TabsContent className="m-0" value="pr-files">
<ProjectPullRequestFilesChangedPanel
diff={repoDiff}
error={repoDiffError}
focusedAnchor={
pullRequestCommentTarget?.pullRequestId ===
selectedPullRequestId
? pullRequestCommentTarget.anchor
: null
}
isLoading={repoDiffLoading}
profiles={profiles}
project={project}
pullRequest={selectedPullRequest}
/>
</TabsContent>
</div>
<PullRequestMetaRail
profiles={profiles}
project={project}
pullRequest={selectedPullRequest}
/>
</div>
</div>
) : null}
<TabsContent className="m-0" value="overview">
<ProjectOverviewPanel
@@ -566,9 +485,40 @@ export function WorkspaceTabs({
value="prs"
>
<PullRequestsPanel
diffStats={
repoDiff
? {
additions: repoDiff.additions,
deletions: repoDiff.deletions,
}
: null
}
error={pullRequestsError}
filesChanged={
selectedPullRequest ? (
<ProjectPullRequestFilesChangedPanel
diff={repoDiff}
error={repoDiffError}
focusedAnchor={
pullRequestCommentTarget?.pullRequestId ===
selectedPullRequestId
? pullRequestCommentTarget.anchor
: null
}
isLoading={repoDiffLoading}
profiles={profiles}
project={project}
pullRequest={selectedPullRequest}
/>
) : undefined
}
filesCount={repoDiff?.files.length}
forceOpenFiles={
pullRequestCommentTarget?.pullRequestId === selectedPullRequestId
}
isLoading={pullRequestsLoading}
onOpenCommit={onSelectedCommitHashChange}
onOpenInlineComment={handleOpenPullRequestComment}
onOpenTerminal={onOpenMergeRecoveryTerminal}
onSelectedPullRequestIdChange={onSelectedPullRequestIdChange}
profiles={profiles}

View File

@@ -1,66 +1,44 @@
import { GitMerge, GitPullRequest } from "lucide-react";
import { GitBranch, GitMerge, GitPullRequest, Tag } from "lucide-react";
import { useIsManagedAgent } from "@/features/agent-memory/hooks";
import type {
ProjectPullRequest,
Repository as Project,
} from "@/features/projects/hooks";
import {
formatExactTimestamp,
relativeTime,
} from "@/features/projects/lib/projectsViewHelpers";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { useIdentityQuery } from "@/shared/api/hooks";
import { cn } from "@/shared/lib/cn";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { CopyCommitHashButton } from "./ProjectCommitCopyButton";
import { OverviewRailSection } from "./ProjectOverviewPanel";
import {
ProjectDetailMetaList,
ProjectDetailMetaPills,
ProjectDetailMetaRow,
} from "./ProjectDetailMeta";
import { PullRequestReviewersRow } from "./PullRequestReviewersRow";
import { PROJECT_DETAIL_META_RAIL_WIDE_BORDER_CLASS } from "./projectPanelStyles";
const META_RAIL_HEADING_CLASS =
"text-2xs font-medium uppercase tracking-wide text-muted-foreground";
function pluralize(count: number, singular: string, plural = `${singular}s`) {
return `${count} ${count === 1 ? singular : plural}`;
function statusClassName(status: ProjectPullRequest["status"]) {
if (status === "Closed") return "text-destructive";
if (status === "Draft") return "text-muted-foreground";
if (status === "Merged") return "text-purple-400";
return "text-green-500";
}
function statusBadgeClassName(status: ProjectPullRequest["status"]) {
if (status === "Closed") return "bg-destructive";
if (status === "Draft") return "bg-muted-foreground/80";
if (status === "Merged") return "bg-purple-600";
return "bg-green-600";
}
/** Compact, actionable metadata for a selected review. */
export function PullRequestMetaRail({
/** Compact metadata under the review title: branches, reviewers, status. */
export function PullRequestMetaHeader({
diffStats,
profiles,
project,
pullRequest,
stacked = false,
}: {
diffStats?: { additions: number; deletions: number } | null;
profiles?: UserProfileLookup;
project: Project;
pullRequest: ProjectPullRequest;
stacked?: boolean;
}) {
const identityQuery = useIdentityQuery();
const targetBranch =
pullRequest.targetBranch || project.defaultBranch || "default branch";
const sourceBranch = pullRequest.branchName || "unknown branch";
const discussionComments = pullRequest.comments.filter(
(comment) =>
!(
(comment.isReviewRequest && comment.isTrustedReviewRequest) ||
comment.isTrustedReviewDecision
),
);
const inlineCommentCount = discussionComments.filter(
(comment) => comment.isInlineComment,
).length;
const outdatedCommentCount = discussionComments.filter(
(comment) => comment.inlineCommentStatus === "outdated",
).length;
const labels = pullRequest.labels.filter(
(label) => label.toLowerCase() !== "draft",
);
@@ -71,136 +49,58 @@ export function PullRequestMetaRail({
const isManagedAgentOwner = useIsManagedAgent(project.owner) === true;
const canRequestReview =
Boolean(viewer) && (isAuthor || isOwner || isManagedAgentOwner);
const StatusIcon =
pullRequest.status === "Merged" ? GitMerge : GitPullRequest;
return (
<aside
className={cn(
"min-w-0 space-y-5 border-border/60 p-4",
stacked
? "border-t"
: cn("border-t", PROJECT_DETAIL_META_RAIL_WIDE_BORDER_CLASS),
)}
>
<OverviewRailSection
title="Status"
titleClassName={META_RAIL_HEADING_CLASS}
>
<span
className={`inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium text-white ${statusBadgeClassName(pullRequest.status)}`}
>
{pullRequest.status === "Merged" ? (
<GitMerge className="h-3.5 w-3.5" />
) : (
<GitPullRequest className="h-3.5 w-3.5" />
)}
{pullRequest.status}
</span>
</OverviewRailSection>
<OverviewRailSection
title="Review"
titleClassName={META_RAIL_HEADING_CLASS}
>
<PullRequestReviewersRow
canRequest={canRequestReview}
profiles={profiles}
project={project}
pullRequest={pullRequest}
signAsManagedOwner={isManagedAgentOwner && !isOwner}
/>
</OverviewRailSection>
{discussionComments.length > 0 ? (
<OverviewRailSection
title="Discussion"
titleClassName={META_RAIL_HEADING_CLASS}
>
<p className="text-xs text-muted-foreground">
{pluralize(discussionComments.length, "comment")}
{inlineCommentCount > 0 ? ` · ${inlineCommentCount} inline` : ""}
{outdatedCommentCount > 0
? ` · ${outdatedCommentCount} outdated`
: ""}
</p>
</OverviewRailSection>
) : null}
<OverviewRailSection
title="Source"
titleClassName={META_RAIL_HEADING_CLASS}
>
<div className="space-y-1.5 text-xs text-muted-foreground">
<p className="flex min-w-0 flex-wrap items-center gap-1.5">
<code className="rounded-sm bg-muted px-1.5 py-0.5 text-2xs text-foreground">
{sourceBranch}
</code>
<span aria-hidden>→</span>
<code className="rounded-sm bg-muted px-1.5 py-0.5 text-2xs text-foreground">
{targetBranch}
</code>
</p>
<ProjectDetailMetaList>
<ProjectDetailMetaRow icon={GitBranch} label="Branch">
<p className="flex min-w-0 flex-wrap items-center gap-1.5 text-xs">
<code className="truncate font-mono text-foreground">
{sourceBranch}
</code>
<span aria-hidden className="text-muted-foreground">
→
</span>
<code className="truncate font-mono text-foreground">
{targetBranch}
</code>
{diffStats ? (
<>
<span className="text-green-500">+{diffStats.additions}</span>
<span className="text-destructive">-{diffStats.deletions}</span>
</>
) : null}
{pullRequest.commit ? (
<p className="flex min-w-0 items-center gap-1.5">
<span>Commit</span>
<code
className="truncate rounded-sm bg-muted px-1.5 py-0.5 text-2xs text-foreground"
title={pullRequest.commit}
>
<span className="inline-flex min-w-0 items-center gap-0.5 text-muted-foreground">
<code className="truncate font-mono" title={pullRequest.commit}>
{pullRequest.commit.slice(0, 7)}
</code>
<CopyCommitHashButton
className="h-6 w-6"
className="h-5 w-5 p-0"
hash={pullRequest.commit}
/>
</p>
) : (
<p>No commit reported</p>
)}
</div>
</OverviewRailSection>
{labels.length > 0 ? (
<OverviewRailSection
title="Labels"
titleClassName={META_RAIL_HEADING_CLASS}
>
<div className="flex flex-wrap gap-1.5">
{labels.map((label) => (
<span
className="rounded-full border border-border/60 px-2 py-0.5 text-2xs text-muted-foreground"
key={label}
>
{label}
</span>
))}
</div>
</OverviewRailSection>
) : null}
<OverviewRailSection
title="Activity"
titleClassName={META_RAIL_HEADING_CLASS}
>
<dl className="space-y-1.5 text-xs text-muted-foreground">
<div className="flex items-center justify-between gap-3">
<dt>Updated</dt>
<dd
className="font-medium text-foreground"
title={formatExactTimestamp(pullRequest.updatedAt)}
>
{relativeTime(pullRequest.updatedAt)}
</dd>
</div>
{(pullRequest.status === "Merged" ||
pullRequest.status === "Closed") &&
pullRequest.statusCreatedAt ? (
<div className="flex items-center justify-between gap-3">
<dt>{pullRequest.status}</dt>
<dd
className="font-medium text-foreground"
title={formatExactTimestamp(pullRequest.statusCreatedAt)}
>
{relativeTime(pullRequest.statusCreatedAt)}
</dd>
</div>
</span>
) : null}
</dl>
</OverviewRailSection>
</aside>
</p>
</ProjectDetailMetaRow>
<PullRequestReviewersRow
canRequest={canRequestReview}
profiles={profiles}
project={project}
pullRequest={pullRequest}
signAsManagedOwner={isManagedAgentOwner && !isOwner}
/>
<ProjectDetailMetaRow icon={StatusIcon} label="Status">
<span className={`font-medium ${statusClassName(pullRequest.status)}`}>
{pullRequest.status}
</span>
</ProjectDetailMetaRow>
{labels.length > 0 ? (
<ProjectDetailMetaRow icon={Tag} label="Labels">
<ProjectDetailMetaPills labels={labels} />
</ProjectDetailMetaRow>
) : null}
</ProjectDetailMetaList>
);
}

View File

@@ -1,4 +1,4 @@
import { Check, Clock3, History, Search, TriangleAlert } from "lucide-react";
import { Check, History, Search, TriangleAlert, Users } from "lucide-react";
import * as React from "react";
import { toast } from "sonner";
@@ -25,6 +25,8 @@ import {
import { Input } from "@/shared/ui/input";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import { ProjectDetailMetaRow } from "./ProjectDetailMeta";
function profileForPubkey(pubkey: string, profiles?: UserProfileLookup) {
return profiles?.[normalizePubkey(pubkey)] ?? null;
}
@@ -184,144 +186,145 @@ export function PullRequestReviewersRow({
}, [pickerOpen]);
return (
<div className="min-w-0 space-y-2.5 text-xs text-muted-foreground">
<p
className="font-medium text-foreground"
data-testid="project-review-summary"
>
{reviewSummary}
</p>
{hasHistoricalDecision ? (
<p className="flex items-center gap-1.5 text-amber-600 dark:text-amber-400">
<History className="h-3.5 w-3.5 shrink-0" />
Earlier decision applies to another commit
</p>
) : null}
{decisionActors.length > 0 ? (
<div className="space-y-1.5">
{decisionActors.map((pubkey) => {
const profile = profileForPubkey(pubkey, profiles);
const label = labelForPubkey(pubkey, profiles);
const hasApproved = approvedBy.has(pubkey);
const hasRequestedChanges = changesRequestedBy.has(pubkey);
const needsRereview = staleDecisionActors.has(pubkey);
const DecisionIcon = hasApproved
? Check
: hasRequestedChanges
? TriangleAlert
: needsRereview
? History
: Clock3;
const decisionLabel = hasApproved
? "Approved"
: hasRequestedChanges
? "Changes requested"
: needsRereview
? "Re-review needed"
: "Pending";
return (
<div className="flex min-w-0 items-center gap-2" key={pubkey}>
<UserAvatar
accent={profile?.isAgent === true}
avatarUrl={profile?.avatarUrl ?? null}
displayName={label}
<>
<ProjectDetailMetaRow icon={Users} label="Reviewers">
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
<span className="font-medium" data-testid="project-review-summary">
{reviewSummary}
</span>
{hasHistoricalDecision ? (
<span className="flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400">
<History className="h-3.5 w-3.5 shrink-0" />
Earlier decision applies to another commit
</span>
) : null}
{canRequest ? (
<Dialog onOpenChange={setPickerOpen} open={pickerOpen}>
<DialogTrigger asChild>
<Button
className="h-5 px-0 text-xs text-muted-foreground hover:bg-transparent hover:text-foreground"
disabled={requestReviewMutation.isPending}
size="xs"
/>
<span className="min-w-0 flex-1 truncate text-foreground">
{label}
</span>
<span
className={cn(
"flex shrink-0 items-center gap-1",
hasApproved && "text-green-600 dark:text-green-400",
hasRequestedChanges && "text-amber-600 dark:text-amber-400",
)}
type="button"
variant="ghost"
>
<DecisionIcon className="h-3.5 w-3.5" />
{decisionLabel}
</span>
</div>
);
})}
Add Reviewer
</Button>
</DialogTrigger>
<DialogContent className="max-w-md gap-0 overflow-hidden p-0">
<DialogHeader className="border-b border-border/60 px-6 py-5 pr-14">
<DialogTitle>Add reviewer</DialogTitle>
<DialogDescription>
Choose a person or agent to review these changes.
</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-2 border-b border-border/60 px-6 py-3">
<Search className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<Input
autoFocus
className="h-8 border-0 px-0 text-sm shadow-none focus-visible:ring-0"
data-testid="project-reviewer-search"
onChange={(event) => setReviewerQuery(event.target.value)}
placeholder="Search people and agents"
value={reviewerQuery}
/>
</div>
<div className="max-h-72 min-h-28 overflow-y-auto p-2">
{userSearchQuery.isLoading ? (
<p className="px-3 py-4 text-sm text-muted-foreground">
Searching…
</p>
) : candidates.length > 0 ? (
candidates.map((candidate) => {
const label = reviewerSearchLabel(candidate);
return (
<button
className="flex w-full min-w-0 items-center gap-3 rounded-lg px-3 py-2 text-left hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
data-testid={`project-reviewer-result-${candidate.pubkey}`}
disabled={requestReviewMutation.isPending}
key={candidate.pubkey}
onClick={() => {
void handleRequest(candidate.pubkey, label);
}}
type="button"
>
<UserAvatar
accent={candidate.isAgent}
avatarUrl={candidate.avatarUrl}
displayName={label}
size="xs"
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-foreground">
{label}
</span>
<span className="block truncate text-xs text-muted-foreground">
{candidate.isAgent ? "Agent · " : ""}
{truncatePubkey(candidate.pubkey)}
</span>
</span>
</button>
);
})
) : (
<p className="px-3 py-4 text-sm text-muted-foreground">
No matching people or agents.
</p>
)}
</div>
</DialogContent>
</Dialog>
) : null}
</div>
) : null}
{canRequest ? (
<Dialog onOpenChange={setPickerOpen} open={pickerOpen}>
<DialogTrigger asChild>
<Button
className="h-6 px-0 text-xs text-muted-foreground hover:bg-transparent hover:text-foreground"
disabled={requestReviewMutation.isPending}
size="xs"
type="button"
variant="ghost"
>
Add Reviewer
</Button>
</DialogTrigger>
<DialogContent className="max-w-md gap-0 overflow-hidden p-0">
<DialogHeader className="border-b border-border/60 px-6 py-5 pr-14">
<DialogTitle>Add reviewer</DialogTitle>
<DialogDescription>
Choose a person or agent to review these changes.
</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-2 border-b border-border/60 px-6 py-3">
<Search className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<Input
autoFocus
className="h-8 border-0 px-0 text-sm shadow-none focus-visible:ring-0"
data-testid="project-reviewer-search"
onChange={(event) => setReviewerQuery(event.target.value)}
placeholder="Search people and agents"
value={reviewerQuery}
</ProjectDetailMetaRow>
{decisionActors.map((pubkey) => {
const profile = profileForPubkey(pubkey, profiles);
const label = labelForPubkey(pubkey, profiles);
const hasApproved = approvedBy.has(pubkey);
const hasRequestedChanges = changesRequestedBy.has(pubkey);
const needsRereview = staleDecisionActors.has(pubkey);
const DecisionIcon = hasApproved
? Check
: hasRequestedChanges
? TriangleAlert
: needsRereview
? History
: null;
const decisionLabel = hasApproved
? "Approved"
: hasRequestedChanges
? "Changes requested"
: needsRereview
? "Re-review needed"
: "Pending";
return (
<ProjectDetailMetaRow
key={pubkey}
label={label}
labelClassName="text-foreground"
leading={
<UserAvatar
accent={profile?.isAgent === true}
avatarUrl={profile?.avatarUrl ?? null}
displayName={label}
size="xs"
/>
</div>
<div className="max-h-72 min-h-28 overflow-y-auto p-2">
{userSearchQuery.isLoading ? (
<p className="px-3 py-4 text-sm text-muted-foreground">
Searching…
</p>
) : candidates.length > 0 ? (
candidates.map((candidate) => {
const label = reviewerSearchLabel(candidate);
return (
<button
className="flex w-full min-w-0 items-center gap-3 rounded-lg px-3 py-2 text-left hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
data-testid={`project-reviewer-result-${candidate.pubkey}`}
disabled={requestReviewMutation.isPending}
key={candidate.pubkey}
onClick={() => {
void handleRequest(candidate.pubkey, label);
}}
type="button"
>
<UserAvatar
accent={candidate.isAgent}
avatarUrl={candidate.avatarUrl}
displayName={label}
size="xs"
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-foreground">
{label}
</span>
<span className="block truncate text-xs text-muted-foreground">
{candidate.isAgent ? "Agent · " : ""}
{truncatePubkey(candidate.pubkey)}
</span>
</span>
</button>
);
})
) : (
<p className="px-3 py-4 text-sm text-muted-foreground">
No matching people or agents.
</p>
}
>
<span
className={cn(
"inline-flex items-center gap-0.5 text-xs",
hasApproved && "text-green-600 dark:text-green-400",
hasRequestedChanges && "text-amber-600 dark:text-amber-400",
!hasApproved && !hasRequestedChanges && "text-muted-foreground",
)}
</div>
</DialogContent>
</Dialog>
) : null}
</div>
>
{DecisionIcon ? <DecisionIcon className="h-3.5 w-3.5" /> : null}
{decisionLabel}
</span>
</ProjectDetailMetaRow>
);
})}
</>
);
}

View File

@@ -1,6 +1,7 @@
import { Activity, Bot, FolderGit2, Inbox, Zap } from "lucide-react";
import { TopbarSearch } from "@/features/search/ui/TopbarSearch";
import { SidebarProjectsSection } from "@/features/sidebar/ui/SidebarProjectsSection";
import { FeatureGate } from "@/shared/features";
import type { Channel, SearchHit } from "@/shared/api/types";
import {
@@ -96,104 +97,107 @@ export function AppSidebarPrimaryMenu({
selectedView,
}: AppSidebarPrimaryMenuProps) {
return (
<SidebarHeader
className="relative z-40 cursor-default select-none px-2 pb-0 pt-0"
data-tauri-drag-region
data-testid="sidebar-primary-menu"
>
<SidebarMenu className="pb-2">
<SidebarMenuItem>
<SidebarMenuButton
className="data-[active=true]:font-normal"
isActive={selectedView === "home"}
onClick={onSelectHome}
tooltip="Inbox"
type="button"
>
<Inbox
className={
selectedView !== "home" ? "h-4 w-4 opacity-80" : "h-4 w-4"
}
/>
<SidebarMenuLabel
className={selectedView !== "home" ? "opacity-80" : undefined}
>
Inbox
</SidebarMenuLabel>
</SidebarMenuButton>
{homeBadgeCount > 0 ? (
<SidebarMenuBadge
className="right-2 rounded-full bg-primary/15 px-1.5 text-2xs text-primary peer-data-[active=true]/menu-button:bg-sidebar-active-foreground/20 peer-data-[active=true]/menu-button:text-sidebar-active-foreground"
data-testid="sidebar-home-count"
>
{Math.min(homeBadgeCount, 99)}
</SidebarMenuBadge>
) : null}
</SidebarMenuItem>
<FeatureGate feature="pulse">
<>
<SidebarHeader
className="relative z-40 cursor-default select-none px-2 pb-0 pt-0"
data-tauri-drag-region
data-testid="sidebar-primary-menu"
>
<SidebarMenu className="pb-2">
<SidebarMenuItem>
<SidebarMenuButton
data-testid="open-pulse-view"
isActive={selectedView === "pulse"}
onClick={onSelectPulse}
tooltip="Pulse"
className="data-[active=true]:font-normal"
isActive={selectedView === "home"}
onClick={onSelectHome}
tooltip="Inbox"
type="button"
>
<Activity className="h-4 w-4" />
<SidebarMenuLabel>Pulse</SidebarMenuLabel>
<Inbox
className={
selectedView !== "home" ? "h-4 w-4 opacity-80" : "h-4 w-4"
}
/>
<SidebarMenuLabel
className={selectedView !== "home" ? "opacity-80" : undefined}
>
Inbox
</SidebarMenuLabel>
</SidebarMenuButton>
{homeBadgeCount > 0 ? (
<SidebarMenuBadge
className="right-2 rounded-full bg-primary/15 px-1.5 text-2xs text-primary peer-data-[active=true]/menu-button:bg-sidebar-active-foreground/20 peer-data-[active=true]/menu-button:text-sidebar-active-foreground"
data-testid="sidebar-home-count"
>
{Math.min(homeBadgeCount, 99)}
</SidebarMenuBadge>
) : null}
</SidebarMenuItem>
</FeatureGate>
<FeatureGate feature="projects">
<FeatureGate feature="pulse">
<SidebarMenuItem>
<SidebarMenuButton
data-testid="open-pulse-view"
isActive={selectedView === "pulse"}
onClick={onSelectPulse}
tooltip="Pulse"
type="button"
>
<Activity className="h-4 w-4" />
<SidebarMenuLabel>Pulse</SidebarMenuLabel>
</SidebarMenuButton>
</SidebarMenuItem>
</FeatureGate>
<FeatureGate feature="projects">
<SidebarMenuItem>
<SidebarMenuButton
data-testid="open-projects-view"
isActive={selectedView === "projects"}
onClick={onSelectProjects}
tooltip="Projects"
type="button"
>
<FolderGit2 className="h-4 w-4" />
<SidebarMenuLabel>Projects</SidebarMenuLabel>
</SidebarMenuButton>
</SidebarMenuItem>
</FeatureGate>
<SidebarMenuItem>
<SidebarMenuButton
data-testid="open-projects-view"
isActive={selectedView === "projects"}
onClick={onSelectProjects}
tooltip="Projects"
className="data-[active=true]:font-normal"
data-testid="open-agents-view"
isActive={selectedView === "agents"}
onClick={onSelectAgents}
tooltip="Agents"
type="button"
>
<FolderGit2 className="h-4 w-4" />
<SidebarMenuLabel>Projects</SidebarMenuLabel>
<Bot
className={
selectedView !== "agents" ? "h-4 w-4 opacity-80" : "h-4 w-4"
}
/>
<SidebarMenuLabel
className={selectedView !== "agents" ? "opacity-80" : undefined}
>
Agents
</SidebarMenuLabel>
</SidebarMenuButton>
</SidebarMenuItem>
</FeatureGate>
<SidebarMenuItem>
<SidebarMenuButton
className="data-[active=true]:font-normal"
data-testid="open-agents-view"
isActive={selectedView === "agents"}
onClick={onSelectAgents}
tooltip="Agents"
type="button"
>
<Bot
className={
selectedView !== "agents" ? "h-4 w-4 opacity-80" : "h-4 w-4"
}
/>
<SidebarMenuLabel
className={selectedView !== "agents" ? "opacity-80" : undefined}
>
Agents
</SidebarMenuLabel>
</SidebarMenuButton>
</SidebarMenuItem>
<FeatureGate feature="workflows">
<SidebarMenuItem>
<SidebarMenuButton
data-testid="open-workflows-view"
isActive={selectedView === "workflows"}
onClick={onSelectWorkflows}
tooltip="Workflows"
type="button"
>
<Zap className="h-4 w-4" />
<SidebarMenuLabel>Workflows</SidebarMenuLabel>
</SidebarMenuButton>
</SidebarMenuItem>
</FeatureGate>
</SidebarMenu>
</SidebarHeader>
<FeatureGate feature="workflows">
<SidebarMenuItem>
<SidebarMenuButton
data-testid="open-workflows-view"
isActive={selectedView === "workflows"}
onClick={onSelectWorkflows}
tooltip="Workflows"
type="button"
>
<Zap className="h-4 w-4" />
<SidebarMenuLabel>Workflows</SidebarMenuLabel>
</SidebarMenuButton>
</SidebarMenuItem>
</FeatureGate>
</SidebarMenu>
</SidebarHeader>
<SidebarProjectsSection />
</>
);
}

View File

@@ -0,0 +1,155 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
listSidebarProjects,
selectedProjectRouteId,
} from "./listSidebarProjects.ts";
const OWNER = "a".repeat(64);
const VIEWER = "b".repeat(64);
function makeProject(overrides = {}) {
return {
createdAt: 0,
description: "",
dtag: "sprout",
id: `30621:${OWNER}:sprout`,
name: "sprout",
owner: OWNER,
primaryRepositoryAddress: null,
projectAddress: `30621:${OWNER}:sprout`,
projectChannelId: null,
repositories: [],
repositoryAddresses: [],
status: "open",
...overrides,
};
}
test("selectedProjectRouteId reads the project id from the detail route", () => {
assert.equal(selectedProjectRouteId("/projects"), undefined);
assert.equal(selectedProjectRouteId("/projects/"), undefined);
assert.equal(selectedProjectRouteId("/agents"), undefined);
assert.equal(
selectedProjectRouteId("/projects/30621:abc:sprout"),
"30621:abc:sprout",
);
assert.equal(
selectedProjectRouteId("/projects/30621%3Aabc%3Asprout"),
"30621:abc:sprout",
);
});
test("listSidebarProjects keeps owned and contributed projects for Mine", () => {
const owned = makeProject({
dtag: "owned",
id: `30621:${VIEWER}:owned`,
name: "Owned",
owner: VIEWER,
projectAddress: `30621:${VIEWER}:owned`,
});
const contributed = makeProject({
dtag: "contrib",
id: `30621:${OWNER}:contrib`,
name: "Contrib",
projectAddress: `30621:${OWNER}:contrib`,
repositories: [
{
channelId: null,
cloneUrls: [],
contributors: [VIEWER],
createdAt: 0,
defaultBranch: "main",
dtag: "repo",
id: `${OWNER}:repo`,
name: "repo",
owner: OWNER,
repoAddress: `30617:${OWNER}:repo`,
},
],
});
const other = makeProject({ name: "Other" });
const listed = listSidebarProjects({
currentPubkey: VIEWER,
filter: "mine",
projects: [other, contributed, owned],
sort: "name",
});
assert.deepEqual(
listed.map((project) => project.name),
["Contrib", "Owned"],
);
});
test("listSidebarProjects owned filter hides contributed projects", () => {
const owned = makeProject({
dtag: "owned",
id: `30621:${VIEWER}:owned`,
name: "Owned",
owner: VIEWER,
projectAddress: `30621:${VIEWER}:owned`,
});
const contributed = makeProject({
dtag: "contrib",
id: `30621:${OWNER}:contrib`,
name: "Contrib",
projectAddress: `30621:${OWNER}:contrib`,
repositories: [
{
channelId: null,
cloneUrls: [],
contributors: [VIEWER],
createdAt: 0,
defaultBranch: "main",
dtag: "repo",
id: `${OWNER}:repo`,
name: "repo",
owner: OWNER,
repoAddress: `30617:${OWNER}:repo`,
},
],
});
const listed = listSidebarProjects({
currentPubkey: VIEWER,
filter: "owned",
projects: [contributed, owned],
sort: "name",
});
assert.deepEqual(
listed.map((project) => project.name),
["Owned"],
);
});
test("listSidebarProjects newest sort orders by createdAt then name", () => {
const older = makeProject({
createdAt: 10,
dtag: "older",
id: `30621:${VIEWER}:older`,
name: "Alpha",
owner: VIEWER,
projectAddress: `30621:${VIEWER}:older`,
});
const newer = makeProject({
createdAt: 20,
dtag: "newer",
id: `30621:${VIEWER}:newer`,
name: "Zebra",
owner: VIEWER,
projectAddress: `30621:${VIEWER}:newer`,
});
const listed = listSidebarProjects({
currentPubkey: VIEWER,
filter: "mine",
projects: [older, newer],
sort: "created",
});
assert.deepEqual(
listed.map((project) => project.name),
["Zebra", "Alpha"],
);
});

View File

@@ -0,0 +1,506 @@
import { useLocation } from "@tanstack/react-router";
import {
ArrowUpDown,
ChevronDown,
FolderGit2,
Folders,
Link2,
Plus,
Settings2,
Trash2,
} from "lucide-react";
import * as React from "react";
import { toast } from "sonner";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import {
type Project,
useDeleteProjectMutation,
useProjectsQuery,
} from "@/features/projects/hooks";
import { isProjectOwnedByCurrentUser } from "@/features/projects/lib/projectsViewHelpers";
import { projectShareLink } from "@/features/projects/lib/projectShareLinks";
import { projectMatchesRouteId } from "@/features/projects/projectRoutes";
import { CreateProjectDialog } from "@/features/projects/ui/CreateProjectDialog";
import { useCreateProjectMutation } from "@/features/projects/useCreateProject";
import { useIdentityQuery } from "@/shared/api/hooks";
import { FeatureGate } from "@/shared/features";
import { copyTextToClipboard } from "@/shared/lib/clipboard";
import { cn } from "@/shared/lib/cn";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/ui/alert-dialog";
import { Button } from "@/shared/ui/button";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/shared/ui/context-menu";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import {
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
} from "@/shared/ui/sidebar";
import { SidebarMenuLabel } from "@/shared/ui/sidebar-menu-label";
import {
ContextMenuIconSlot,
deferMenuAction,
} from "@/features/sidebar/ui/sidebarMenuHelpers";
import {
SECTION_ACTION_VISIBILITY_CLASS,
SECTION_ICON_BUTTON_CLASS,
} from "@/features/sidebar/ui/sidebarSectionStyles";
import {
listSidebarProjects,
readSidebarProjectsFilter,
readSidebarProjectsSort,
selectedProjectRouteId,
type SidebarProjectsFilter,
type SidebarProjectsSort,
writeSidebarProjectsFilter,
writeSidebarProjectsSort,
} from "@/features/sidebar/ui/listSidebarProjects";
const SECTION_LABEL_BUTTON_CLASS =
"group/section-label flex w-fit max-w-[calc(100%-3rem)] cursor-pointer appearance-none items-center gap-1 text-left transition-colors hover:text-sidebar-foreground focus-visible:text-sidebar-foreground";
const SECTION_LABEL_CHEVRON_CLASS =
"relative size-2.5 shrink-0 text-current opacity-0 transition-[color,opacity] group-hover/sidebar-section:opacity-100 group-hover/section-label:opacity-100 group-focus-within/sidebar-section:opacity-100 group-focus-visible/section-label:opacity-100 group-data-[section-actions-open=true]/sidebar-section:opacity-100";
const SECTION_LABEL_CHEVRON_ICON_CLASS =
"absolute left-1/2 top-1/2 size-2.5 -translate-x-1/2 -translate-y-1/2";
/**
* Collapsible list of the viewer's projects in the left sidebar. Rendered
* only when the Projects experiment is enabled, and only includes projects
* the viewer owns or contributes to (optionally owned-only).
*/
export function SidebarProjectsSection() {
return (
<FeatureGate feature="projects">
<SidebarProjectsSectionContent />
</FeatureGate>
);
}
function SidebarProjectsSectionContent() {
const projectsQuery = useProjectsQuery();
const identityQuery = useIdentityQuery();
const currentPubkey = identityQuery.data?.pubkey;
const { goProject, goProjects } = useAppNavigation();
const pathname = useLocation({ select: (location) => location.pathname });
const routeProjectId = selectedProjectRouteId(pathname);
const [collapsed, setCollapsed] = React.useState(false);
const [actionsOpen, setActionsOpen] = React.useState(false);
const [createOpen, setCreateOpen] = React.useState(false);
const [projectToDelete, setProjectToDelete] = React.useState<Project | null>(
null,
);
const [filter, setFilter] = React.useState<SidebarProjectsFilter>(
readSidebarProjectsFilter,
);
const [sort, setSort] = React.useState<SidebarProjectsSort>(
readSidebarProjectsSort,
);
const createProjectMutation = useCreateProjectMutation();
const deleteProjectMutation = useDeleteProjectMutation();
const isPending = projectsQuery.isPending || identityQuery.isPending;
const projects = React.useMemo(
() =>
listSidebarProjects({
currentPubkey,
filter,
projects: projectsQuery.data ?? [],
sort,
}),
[currentPubkey, filter, projectsQuery.data, sort],
);
const handleFilterChange = (next: SidebarProjectsFilter) => {
setFilter(next);
writeSidebarProjectsFilter(next);
};
const handleSortChange = (next: SidebarProjectsSort) => {
setSort(next);
writeSidebarProjectsSort(next);
};
const handleDelete = React.useCallback(
async (project: Project) => {
try {
await deleteProjectMutation.mutateAsync(project);
toast.success("Project deleted");
if (
routeProjectId != null &&
projectMatchesRouteId(project, routeProjectId)
) {
await goProjects();
}
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Failed to delete project",
);
} finally {
setProjectToDelete(null);
}
},
[deleteProjectMutation, goProjects, routeProjectId],
);
return (
<SidebarGroup
className="group/sidebar-section select-none"
data-section-actions-open={actionsOpen || undefined}
data-testid="sidebar-projects-section"
>
<div className="relative">
<SidebarGroupLabel asChild>
<button
aria-controls="sidebar-projects"
aria-expanded={!collapsed}
className={SECTION_LABEL_BUTTON_CLASS}
data-testid="sidebar-projects-section-label"
onClick={() => setCollapsed((current) => !current)}
type="button"
>
<span data-sidebar-section-title>Projects</span>
<span aria-hidden="true" className={SECTION_LABEL_CHEVRON_CLASS}>
<ChevronDown
className={cn(
SECTION_LABEL_CHEVRON_ICON_CLASS,
collapsed ? "-rotate-90" : "rotate-0",
)}
/>
</span>
</button>
</SidebarGroupLabel>
<SidebarProjectsHeaderActions
filter={filter}
onBrowseAll={() => void goProjects()}
onCreate={() => setCreateOpen(true)}
onFilterChange={handleFilterChange}
onOpenChange={setActionsOpen}
onSortChange={handleSortChange}
sort={sort}
/>
</div>
{!collapsed ? (
<SidebarGroupContent id="sidebar-projects">
{projects.length > 0 ? (
<SidebarMenu data-testid="sidebar-projects">
{projects.map((project) => (
<SidebarProjectRow
canDelete={isProjectOwnedByCurrentUser(
project,
currentPubkey,
)}
deleteDisabled={deleteProjectMutation.isPending}
isActive={
routeProjectId != null &&
projectMatchesRouteId(project, routeProjectId)
}
key={project.id}
onDelete={() => setProjectToDelete(project)}
onOpen={() => goProject(project.id)}
project={project}
/>
))}
</SidebarMenu>
) : isPending ? null : (
<p className="px-2 py-1 text-xs text-sidebar-foreground/50">
No projects yet
</p>
)}
</SidebarGroupContent>
) : null}
<CreateProjectDialog
isCreating={createProjectMutation.isPending}
onCreate={async (input) => {
const result = await createProjectMutation.mutateAsync(input);
if (result.compatibilityWarning) {
toast.warning("Created as a standalone project", {
description: result.compatibilityWarning,
});
} else {
toast.success(`Project "${result.project.name}" created.`);
}
await goProject(result.project.id);
}}
onOpenChange={setCreateOpen}
open={createOpen}
/>
<AlertDialog
onOpenChange={(open) => {
if (!open) setProjectToDelete(null);
}}
open={projectToDelete != null}
>
<AlertDialogContent
data-testid={
projectToDelete
? `sidebar-project-delete-confirm-${projectToDelete.dtag}`
: undefined
}
>
<AlertDialogHeader>
<AlertDialogTitle>Delete project?</AlertDialogTitle>
<AlertDialogDescription>
Delete {projectToDelete?.name} from Projects for everyone. This
can only be done for projects you own and cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel asChild>
<Button
disabled={deleteProjectMutation.isPending}
type="button"
variant="outline"
>
Cancel
</Button>
</AlertDialogCancel>
<AlertDialogAction asChild>
<Button
data-testid={
projectToDelete
? `sidebar-project-delete-confirm-button-${projectToDelete.dtag}`
: undefined
}
disabled={deleteProjectMutation.isPending || !projectToDelete}
onClick={(event) => {
event.preventDefault();
if (projectToDelete) void handleDelete(projectToDelete);
}}
type="button"
variant="destructive"
>
{deleteProjectMutation.isPending
? "Deleting..."
: "Delete project"}
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</SidebarGroup>
);
}
function SidebarProjectsHeaderActions({
filter,
onBrowseAll,
onCreate,
onFilterChange,
onOpenChange,
onSortChange,
sort,
}: {
filter: SidebarProjectsFilter;
onBrowseAll: () => void;
onCreate: () => void;
onFilterChange: (filter: SidebarProjectsFilter) => void;
onOpenChange: (open: boolean) => void;
onSortChange: (sort: SidebarProjectsSort) => void;
sort: SidebarProjectsSort;
}) {
const settingsTriggerRef = React.useRef<HTMLButtonElement>(null);
return (
<div className="absolute right-1 top-1/2 z-10 flex -translate-y-1/2 items-center gap-0.5">
<button
aria-label="Create project"
className={cn(
SECTION_ICON_BUTTON_CLASS,
SECTION_ACTION_VISIBILITY_CLASS,
)}
data-testid="sidebar-projects-create"
onClick={(event) => {
event.stopPropagation();
onCreate();
}}
onPointerDown={(event) => event.stopPropagation()}
title="Create project"
type="button"
>
<Plus className="h-4 w-4" />
</button>
<DropdownMenu onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild>
<button
aria-label="Project section settings"
className={cn(
SECTION_ICON_BUTTON_CLASS,
SECTION_ACTION_VISIBILITY_CLASS,
)}
data-testid="sidebar-projects-settings"
onClick={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
ref={settingsTriggerRef}
type="button"
>
<Settings2 className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
onCloseAutoFocus={(event) => {
event.preventDefault();
settingsTriggerRef.current?.blur();
}}
>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Folders className="h-4 w-4" />
<span>Show</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuRadioGroup
onValueChange={(value) =>
onFilterChange(value as SidebarProjectsFilter)
}
value={filter}
>
<DropdownMenuRadioItem value="mine">Mine</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="owned">
Owned by me
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<ArrowUpDown className="h-4 w-4" />
<span>Sort</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuRadioGroup
onValueChange={(value) =>
onSortChange(value as SidebarProjectsSort)
}
value={sort}
>
<DropdownMenuRadioItem value="name">A–Z</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="created">
Newest
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => deferMenuAction(onBrowseAll)}>
<FolderGit2 className="h-4 w-4" />
<span>Browse all projects</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
function SidebarProjectRow({
canDelete,
deleteDisabled,
isActive,
onDelete,
onOpen,
project,
}: {
canDelete: boolean;
deleteDisabled: boolean;
isActive: boolean;
onDelete: () => void;
onOpen: () => void;
project: Project;
}) {
const shareLink = projectShareLink(project);
return (
<ContextMenu>
<ContextMenuTrigger asChild>
<SidebarMenuItem>
<SidebarMenuButton
data-testid={`sidebar-project-${project.dtag}`}
isActive={isActive}
onClick={onOpen}
tooltip={project.name}
type="button"
>
<FolderGit2 className="h-4 w-4" />
<SidebarMenuLabel>{project.name}</SidebarMenuLabel>
</SidebarMenuButton>
{canDelete ? (
<SidebarMenuAction
aria-label={`Delete ${project.name}`}
data-testid={`sidebar-project-delete-${project.dtag}`}
disabled={deleteDisabled}
onClick={(event) => {
event.stopPropagation();
onDelete();
}}
showOnHover
type="button"
>
<Trash2 />
</SidebarMenuAction>
) : null}
</SidebarMenuItem>
</ContextMenuTrigger>
<ContextMenuContent>
{shareLink ? (
<ContextMenuItem
onSelect={() =>
deferMenuAction(() =>
copyTextToClipboard(shareLink, "Link copied to clipboard"),
)
}
>
<ContextMenuIconSlot>
<Link2 className="h-4 w-4" />
</ContextMenuIconSlot>
<span>Copy link</span>
</ContextMenuItem>
) : null}
{canDelete ? (
<>
{shareLink ? <ContextMenuSeparator /> : null}
<ContextMenuItem
className="text-destructive focus:text-destructive"
data-testid={`sidebar-project-delete-menu-${project.dtag}`}
disabled={deleteDisabled}
onSelect={() => deferMenuAction(onDelete)}
>
<ContextMenuIconSlot>
<Trash2 className="h-4 w-4" />
</ContextMenuIconSlot>
<span>Delete project</span>
</ContextMenuItem>
</>
) : null}
</ContextMenuContent>
</ContextMenu>
);
}

View File

@@ -0,0 +1,84 @@
import type { Project } from "@/features/projects/projectModels";
import {
isProjectMine,
isProjectOwnedByCurrentUser,
} from "@/features/projects/lib/projectsViewHelpers";
const SIDEBAR_PROJECTS_FILTER_KEY = "buzz.sidebar.projects.filter";
const SIDEBAR_PROJECTS_SORT_KEY = "buzz.sidebar.projects.sort";
export type SidebarProjectsFilter = "mine" | "owned";
export type SidebarProjectsSort = "name" | "created";
export function selectedProjectRouteId(pathname: string): string | undefined {
if (!pathname.startsWith("/projects/")) return undefined;
const raw = pathname.slice("/projects/".length).split("/")[0];
if (!raw) return undefined;
try {
return decodeURIComponent(raw);
} catch {
return raw;
}
}
export function readSidebarProjectsFilter(): SidebarProjectsFilter {
try {
const value = globalThis.localStorage?.getItem(SIDEBAR_PROJECTS_FILTER_KEY);
return value === "owned" ? "owned" : "mine";
} catch {
return "mine";
}
}
export function writeSidebarProjectsFilter(filter: SidebarProjectsFilter) {
try {
globalThis.localStorage?.setItem(SIDEBAR_PROJECTS_FILTER_KEY, filter);
} catch {
// Persistence is best-effort; the in-memory toggle still works.
}
}
export function readSidebarProjectsSort(): SidebarProjectsSort {
try {
const value = globalThis.localStorage?.getItem(SIDEBAR_PROJECTS_SORT_KEY);
return value === "created" ? "created" : "name";
} catch {
return "name";
}
}
export function writeSidebarProjectsSort(sort: SidebarProjectsSort) {
try {
globalThis.localStorage?.setItem(SIDEBAR_PROJECTS_SORT_KEY, sort);
} catch {
// Persistence is best-effort; the in-memory toggle still works.
}
}
export function listSidebarProjects({
currentPubkey,
filter,
projects,
sort,
}: {
currentPubkey: string | undefined;
filter: SidebarProjectsFilter;
projects: readonly Project[];
sort: SidebarProjectsSort;
}): Project[] {
return [...projects]
.filter((project) =>
filter === "owned"
? isProjectOwnedByCurrentUser(project, currentPubkey)
: isProjectMine(project, currentPubkey),
)
.sort((left, right) => {
if (sort === "created") {
return (
right.createdAt - left.createdAt ||
left.name.localeCompare(right.name)
);
}
return left.name.localeCompare(right.name);
});
}

View File

@@ -144,8 +144,8 @@ test("Buzz Git pull request renders and stays actionable in Inbox", async ({
path: "test-results/project-inbox/01-pull-request-detail.png",
});
// At the wide two-column breakpoint, metadata may wrap between phrases but
// must never compress individual phrases into word-wide columns.
// Metadata phrases may wrap between items but must never compress
// individual phrases into word-wide columns.
await page.setViewportSize({ width: 1440, height: 900 });
await expect
.poll(() =>
@@ -156,7 +156,7 @@ test("Buzz Git pull request renders and stays actionable in Inbox", async ({
.filter(Boolean).length,
),
)
.toBe(2);
.toBe(1);
await expect(
detail.getByRole("button", {
name: "Open author-claimed origin channel #general",

View File

@@ -544,7 +544,7 @@ test("reviewer can leave a commit-scoped inline diff comment", async ({
.filter({ hasText: "alice" })
.first();
await aliceRow.getByRole("button", { name: /^#/ }).click();
await page.getByRole("tab", { name: /Files changed/ }).click();
await page.getByRole("button", { name: /^Files changed/ }).click();
const diffLine = page
.getByTestId("project-diff-line")
@@ -589,10 +589,6 @@ test("reviewer can leave a commit-scoped inline diff comment", async ({
"Please add a type for this parameter.",
);
await page.getByRole("tab", { name: "Conversation" }).click();
await expect(
page.getByTestId("project-pull-request-review-history-toggle"),
).toHaveAttribute("aria-expanded", "true");
await expect(
page.getByText("Please add a type for this parameter."),
).toBeVisible();
@@ -611,8 +607,8 @@ test("reviewer can leave a commit-scoped inline diff comment", async ({
})
.click();
await expect(
page.getByRole("tab", { name: /Files changed/ }),
).toHaveAttribute("data-state", "active");
page.getByRole("button", { name: /^Files changed/ }),
).toHaveAttribute("aria-expanded", "true");
const focusedLine = page.getByTestId("project-diff-focused-line");
await expect(focusedLine).toBeVisible();
await expect(focusedLine).toHaveAttribute(