mirror of
https://github.com/block/buzz.git
synced 2026-10-31 14:31:02 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
81
desktop/src/features/projects/ui/ProjectDetailMeta.tsx
Normal file
81
desktop/src/features/projects/ui/ProjectDetailMeta.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
desktop/src/features/projects/ui/ProjectDetailSection.tsx
Normal file
65
desktop/src/features/projects/ui/ProjectDetailSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
155
desktop/src/features/sidebar/ui/SidebarProjectsSection.test.mjs
Normal file
155
desktop/src/features/sidebar/ui/SidebarProjectsSection.test.mjs
Normal 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"],
|
||||
);
|
||||
});
|
||||
506
desktop/src/features/sidebar/ui/SidebarProjectsSection.tsx
Normal file
506
desktop/src/features/sidebar/ui/SidebarProjectsSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
84
desktop/src/features/sidebar/ui/listSidebarProjects.ts
Normal file
84
desktop/src/features/sidebar/ui/listSidebarProjects.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user