diff --git a/desktop/src/features/home/ui/ProjectInboxDetailPane.tsx b/desktop/src/features/home/ui/ProjectInboxDetailPane.tsx index f83628b318..0f8308e9ae 100644 --- a/desktop/src/features/home/ui/ProjectInboxDetailPane.tsx +++ b/desktop/src/features/home/ui/ProjectInboxDetailPane.tsx @@ -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(); - 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" > - {showSideRail ? openLabel : null} + {showWideActions ? openLabel : null} @@ -135,45 +130,25 @@ export function ProjectInboxDetailPane({ >
- {workItem.type === "pull-request" ? ( -
-
- - -
- + {workItem.type === "pull-request" ? ( + -
- ) : ( - - )} + ) : ( + + )} +
diff --git a/desktop/src/features/projects/lib/discussionChannels.test.mjs b/desktop/src/features/projects/lib/discussionChannels.test.mjs index 95f73f6373..b9759525df 100644 --- a/desktop/src/features/projects/lib/discussionChannels.test.mjs +++ b/desktop/src/features/projects/lib/discussionChannels.test.mjs @@ -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. diff --git a/desktop/src/features/projects/lib/discussionChannels.ts b/desktop/src/features/projects/lib/discussionChannels.ts index 3776467248..e0dc896e32 100644 --- a/desktop/src/features/projects/lib/discussionChannels.ts +++ b/desktop/src/features/projects/lib/discussionChannels.ts @@ -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=&d=`, so the owner diff --git a/desktop/src/features/projects/lib/projectsViewHelpers.test.mjs b/desktop/src/features/projects/lib/projectsViewHelpers.test.mjs index b296ea3ece..5eae9e2fd0 100644 --- a/desktop/src/features/projects/lib/projectsViewHelpers.test.mjs +++ b/desktop/src/features/projects/lib/projectsViewHelpers.test.mjs @@ -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(); diff --git a/desktop/src/features/projects/ui/DiscussionChannels.tsx b/desktop/src/features/projects/ui/DiscussionChannels.tsx index aa97b8286b..80bb4f4dc3 100644 --- a/desktop/src/features/projects/ui/DiscussionChannels.tsx +++ b/desktop/src/features/projects/ui/DiscussionChannels.tsx @@ -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(); 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({
{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 (
@@ -160,37 +273,45 @@ export function DiscussedInChannels({ title={`Open the latest conversation in #${name}`} type="button" /> - + - - - - {" "} - discussed {entityLabel} in{" "} - - - - + + + + + {" "} + {latestHit && !isOriginFallback ? "discussed" : "started"}{" "} + {entityLabel} in{" "} + + + {" "} · {relativeTime(channel.lastActivityAt)} - — {discussionSnippet(latestHit.content)} + {latestHit ? ( + + + + ) : null}
); @@ -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 ( + + ); +} + /** * 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 diff --git a/desktop/src/features/projects/ui/IssueAssigneesRow.tsx b/desktop/src/features/projects/ui/IssueAssigneesRow.tsx index b1824351c8..c0bac0eb10 100644 --- a/desktop/src/features/projects/ui/IssueAssigneesRow.tsx +++ b/desktop/src/features/projects/ui/IssueAssigneesRow.tsx @@ -248,7 +248,7 @@ export function IssueAssigneesRow({ })} {canSelfAssign && viewer ? (
); } diff --git a/desktop/src/features/projects/ui/ProjectDetailMeta.tsx b/desktop/src/features/projects/ui/ProjectDetailMeta.tsx new file mode 100644 index 0000000000..add93db7bf --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectDetailMeta.tsx @@ -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 ( +
+ {children} +
+ ); +} + +export function ProjectDetailMetaRow({ + children, + icon: Icon, + label, + labelClassName, + leading, +}: { + children: React.ReactNode; + icon?: LucideIcon; + label: string; + labelClassName?: string; + leading?: React.ReactNode; +}) { + return ( +
+ {leading ?? + (Icon ? ( + + ) : ( + + ))} + + {label} + +
+ {children} +
+
+ ); +} + +export function ProjectDetailMetaPills({ labels }: { labels: string[] }) { + return ( +
+ {labels.map((label) => ( + + {label} + + ))} +
+ ); +} diff --git a/desktop/src/features/projects/ui/ProjectDetailSection.tsx b/desktop/src/features/projects/ui/ProjectDetailSection.tsx new file mode 100644 index 0000000000..1f79d44084 --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectDetailSection.tsx @@ -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 ( +
+ + {open ?
{children}
: null} +
+ ); +} diff --git a/desktop/src/features/projects/ui/ProjectIssuesPanel.tsx b/desktop/src/features/projects/ui/ProjectIssuesPanel.tsx index 49dcbcc6f3..07340927df 100644 --- a/desktop/src/features/projects/ui/ProjectIssuesPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectIssuesPanel.tsx @@ -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 ( -
-
-
-
-

- - {projectTaskCategoryLabel(issue.category)} from {authorLabel} - -

-

- {issue.title}{" "} - - #{issue.id.slice(0, 8)} - - -

-
- {issue.content ? ( - - ) : null} -
- -
+
+
+

+ {issue.title}{" "} + + #{issue.id.slice(0, 8)} + + +

+

+ + {authorLabel} + + {relativeTime(issue.createdAt)} + + +

+
+ + + + {issue.status} + + + + {projectTaskCategoryLabel(issue.category)} + + {issue.assignees.length > 0 || viewer ? ( + + + + ) : null} + {labels.length > 0 ? ( + + + + ) : null} + + {issue.content ? ( + + + + ) : null} + +
@@ -272,126 +326,12 @@ export function ProjectIssueDetail({ profiles={profiles} />
-
-
- - +
+ ); } -/** 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 ( - - ); -} - export function ProjectIssuesPanel({ onSelectedIssueIdChange, profiles, diff --git a/desktop/src/features/projects/ui/ProjectPullRequestsPanel.tsx b/desktop/src/features/projects/ui/ProjectPullRequestsPanel.tsx index d3241a5963..24e290a876 100644 --- a/desktop/src/features/projects/ui/ProjectPullRequestsPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectPullRequestsPanel.tsx @@ -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 ( -
+

{pullRequest.title}{" "} @@ -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" > - - created {relativeTime(pullRequest.createdAt)} + {relativeTime(pullRequest.createdAt)} void; onOpenCommit?: (commitHash: string) => void; onOpenTerminal?: OpenMergeRecoveryTerminal; @@ -408,10 +411,19 @@ export function ProjectPullRequestDetail({ collapsedReviewHistoryPullRequestIds, setCollapsedReviewHistoryPullRequestIds, ] = React.useState>(() => 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 ( -
-
- -

Commits

- - {commitCount} - -
-
- - {pullRequest.updates.map((update) => ( - - ))} -
-
- ); - } - - if (mode === "checks") { - return ( -

- No checks have been reported for this review yet. -

- ); - } - + const commitCount = Math.max(1, pullRequest.updates.length + 1); const reviewHistory = pullRequest.comments .map((item) => ({ item, @@ -530,268 +497,331 @@ export function ProjectPullRequestDetail({ return (
- {pullRequest.content ? ( -
- -
- ) : null} - - {pullRequest.updates.length > 0 ? ( -
-

Updates

- {pullRequest.updates.map((update) => ( -
-
- - {relativeTime(update.createdAt)} - - } - /> - {update.commit ? ( - - ) : null} -
- {update.content ? ( - - ) : null} -
- ))} -
- ) : null} - -
- -
- {reviewHistory.length > 0 ? ( - + + + {pullRequest.content || pullRequest.updates.length > 0 ? ( + + {pullRequest.content ? ( + ) : null} - {!reviewHistoryCollapsed && - earlierReviewHistoryCount > 0 && - !reviewHistoryExpanded ? ( - - ) : 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 ( -
-
- {index < displayedReviewHistory.length - 1 ? ( - - ) : ( - - )} - - {timelineKind === "approved" ? ( - Updates

+ {pullRequest.updates.map((update) => ( +
+
+ + {relativeTime(update.createdAt)} + + } + /> + {update.commit ? ( + - ) : timelineKind === "changes-requested" ? ( - - ) : timelineKind === "review-request" ? ( - - ) : ( - - )} - -
-
-
- - - {labelForPubkey(item.author, profiles)} - - {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} - - - {relativeTime(item.createdAt)} - + ) : null}
- {activityContent ? ( + {update.content ? ( ) : null} - {item.anchor ? ( -
+ ))} + + ) : null} + + ) : null} + {filesChanged ? ( + +
{filesChanged}
+
+ ) : null} + +
+ + {pullRequest.updates.map((update) => ( + + ))} +
+
+ +

+ No checks have been reported for this review yet. +

+
+ +
+ +
+ {reviewHistory.length > 0 ? ( + + ) : null} + {!reviewHistoryCollapsed && + earlierReviewHistoryCount > 0 && + !reviewHistoryExpanded ? ( + + ) : 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 ( +
+
+ {index < displayedReviewHistory.length - 1 ? ( + + ) : ( + + )} + + {timelineKind === "approved" ? ( + + ) : timelineKind === "changes-requested" ? ( + + ) : timelineKind === "review-request" ? ( + + ) : ( + + )} + +
+
+
+ + + {labelForPubkey(item.author, profiles)} + + {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} - {item.inlineCommentStatus === "outdated" ? ( - - Outdated + + {relativeTime(item.createdAt)} + +
+ {activityContent ? ( + + ) : null} + {item.anchor ? ( + - ) : null} + {item.inlineCommentStatus === "outdated" ? ( + + Outdated + + ) : null} + + ) : null} +
-
- ); - })} -
- + +
+
+
+
-
- -
- +
); } 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 ( ); } - -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 ( - - - Conversation - - {pullRequest.comments.length} - - - - Commits - - {commitCount} - - - - Checks - 0 - - - Files changed - - {filesCount} - - - - ); -} diff --git a/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx b/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx index 5918f82de6..1e46f3b4df 100644 --- a/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx +++ b/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx @@ -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 ? ( ) : null} {sectionHeader} - {selectedPullRequest ? ( -
- {/* Two full-height columns: the meta rail runs all the way to the - top of the card, alongside the header and tabs. */} -
-
- -
- -
- {(["conversation", "commits", "checks"] as const).map( - (mode) => ( - - - - ), - )} - - - -
- -
-
- ) : null} + ) : undefined + } + filesCount={repoDiff?.files.length} + forceOpenFiles={ + pullRequestCommentTarget?.pullRequestId === selectedPullRequestId + } isLoading={pullRequestsLoading} onOpenCommit={onSelectedCommitHashChange} + onOpenInlineComment={handleOpenPullRequestComment} onOpenTerminal={onOpenMergeRecoveryTerminal} onSelectedPullRequestIdChange={onSelectedPullRequestIdChange} profiles={profiles} diff --git a/desktop/src/features/projects/ui/PullRequestMetaRail.tsx b/desktop/src/features/projects/ui/PullRequestMetaRail.tsx index a291dba6dd..333588f03a 100644 --- a/desktop/src/features/projects/ui/PullRequestMetaRail.tsx +++ b/desktop/src/features/projects/ui/PullRequestMetaRail.tsx @@ -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 ( - +

+ + + + + {pullRequest.status} + + + {labels.length > 0 ? ( + + + + ) : null} + ); } diff --git a/desktop/src/features/projects/ui/PullRequestReviewersRow.tsx b/desktop/src/features/projects/ui/PullRequestReviewersRow.tsx index 6418ccdac6..a94a8d2590 100644 --- a/desktop/src/features/projects/ui/PullRequestReviewersRow.tsx +++ b/desktop/src/features/projects/ui/PullRequestReviewersRow.tsx @@ -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 ( -
-

- {reviewSummary} -

- {hasHistoricalDecision ? ( -

- - Earlier decision applies to another commit -

- ) : null} - {decisionActors.length > 0 ? ( -
- {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 ( -
- + +
+ + {reviewSummary} + + {hasHistoricalDecision ? ( + + + Earlier decision applies to another commit + + ) : null} + {canRequest ? ( + + +
- ); - })} + Add Reviewer + + + + + Add reviewer + + Choose a person or agent to review these changes. + + +
+ + setReviewerQuery(event.target.value)} + placeholder="Search people and agents" + value={reviewerQuery} + /> +
+
+ {userSearchQuery.isLoading ? ( +

+ Searching… +

+ ) : candidates.length > 0 ? ( + candidates.map((candidate) => { + const label = reviewerSearchLabel(candidate); + return ( + + ); + }) + ) : ( +

+ No matching people or agents. +

+ )} +
+
+ + ) : null}
- ) : null} - {canRequest ? ( - - - - - - - Add reviewer - - Choose a person or agent to review these changes. - - -
- - setReviewerQuery(event.target.value)} - placeholder="Search people and agents" - value={reviewerQuery} + + {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 ( + -
-
- {userSearchQuery.isLoading ? ( -

- Searching… -

- ) : candidates.length > 0 ? ( - candidates.map((candidate) => { - const label = reviewerSearchLabel(candidate); - return ( - - ); - }) - ) : ( -

- No matching people or agents. -

+ } + > + - -
- ) : null} -
+ > + {DecisionIcon ? : null} + {decisionLabel} + + + ); + })} + ); } diff --git a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx index 4a618fcf0a..e9efc8f9f6 100644 --- a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx @@ -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 ( - - - - - - - Inbox - - - {homeBadgeCount > 0 ? ( - - {Math.min(homeBadgeCount, 99)} - - ) : null} - - + <> + + - - Pulse + + + Inbox + + {homeBadgeCount > 0 ? ( + + {Math.min(homeBadgeCount, 99)} + + ) : null} - - + + + + + Pulse + + + + + + + + Projects + + + - - Projects + + + Agents + - - - - - - Agents - - - - - - - - Workflows - - - - - + + + + + Workflows + + + + + + + ); } diff --git a/desktop/src/features/sidebar/ui/SidebarProjectsSection.test.mjs b/desktop/src/features/sidebar/ui/SidebarProjectsSection.test.mjs new file mode 100644 index 0000000000..ffa346c217 --- /dev/null +++ b/desktop/src/features/sidebar/ui/SidebarProjectsSection.test.mjs @@ -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"], + ); +}); diff --git a/desktop/src/features/sidebar/ui/SidebarProjectsSection.tsx b/desktop/src/features/sidebar/ui/SidebarProjectsSection.tsx new file mode 100644 index 0000000000..0a861af771 --- /dev/null +++ b/desktop/src/features/sidebar/ui/SidebarProjectsSection.tsx @@ -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 ( + + + + ); +} + +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( + null, + ); + const [filter, setFilter] = React.useState( + readSidebarProjectsFilter, + ); + const [sort, setSort] = React.useState( + 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 ( + +
+ + + + void goProjects()} + onCreate={() => setCreateOpen(true)} + onFilterChange={handleFilterChange} + onOpenChange={setActionsOpen} + onSortChange={handleSortChange} + sort={sort} + /> +
+ {!collapsed ? ( + + {projects.length > 0 ? ( + + {projects.map((project) => ( + setProjectToDelete(project)} + onOpen={() => goProject(project.id)} + project={project} + /> + ))} + + ) : isPending ? null : ( +

+ No projects yet +

+ )} +
+ ) : null} + { + 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} + /> + { + if (!open) setProjectToDelete(null); + }} + open={projectToDelete != null} + > + + + Delete project? + + Delete {projectToDelete?.name} from Projects for everyone. This + can only be done for projects you own and cannot be undone. + + + + + + + + + + + + +
+ ); +} + +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(null); + + return ( +
+ + + + + + { + event.preventDefault(); + settingsTriggerRef.current?.blur(); + }} + > + + + + Show + + + + onFilterChange(value as SidebarProjectsFilter) + } + value={filter} + > + Mine + + Owned by me + + + + + + + + Sort + + + + onSortChange(value as SidebarProjectsSort) + } + value={sort} + > + A–Z + + Newest + + + + + + deferMenuAction(onBrowseAll)}> + + Browse all projects + + + +
+ ); +} + +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 ( + + + + + + {project.name} + + {canDelete ? ( + { + event.stopPropagation(); + onDelete(); + }} + showOnHover + type="button" + > + + + ) : null} + + + + {shareLink ? ( + + deferMenuAction(() => + copyTextToClipboard(shareLink, "Link copied to clipboard"), + ) + } + > + + + + Copy link + + ) : null} + {canDelete ? ( + <> + {shareLink ? : null} + deferMenuAction(onDelete)} + > + + + + Delete project + + + ) : null} + + + ); +} diff --git a/desktop/src/features/sidebar/ui/listSidebarProjects.ts b/desktop/src/features/sidebar/ui/listSidebarProjects.ts new file mode 100644 index 0000000000..627081058a --- /dev/null +++ b/desktop/src/features/sidebar/ui/listSidebarProjects.ts @@ -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); + }); +} diff --git a/desktop/tests/e2e/project-inbox.spec.ts b/desktop/tests/e2e/project-inbox.spec.ts index 389488acf0..fdf3e324b0 100644 --- a/desktop/tests/e2e/project-inbox.spec.ts +++ b/desktop/tests/e2e/project-inbox.spec.ts @@ -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", diff --git a/desktop/tests/e2e/project-pr-review.spec.ts b/desktop/tests/e2e/project-pr-review.spec.ts index 7f833fb5d1..8fddf03e8a 100644 --- a/desktop/tests/e2e/project-pr-review.spec.ts +++ b/desktop/tests/e2e/project-pr-review.spec.ts @@ -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(