From 6815cb90890af35894c2ee08170866d1b34f2abb Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Thu, 23 Jul 2026 13:06:09 +0200 Subject: [PATCH 1/3] feat(desktop): improve pull request review workflow Unify comments and review decisions into a navigable timeline, keep lifecycle actions available to repository owners and their managed agents, and preserve source-channel context. Polish reviewer controls, project timestamps, and code-linked review interactions for a clearer end-to-end review flow. --- crates/buzz-acp/src/base_prompt.md | 3 + crates/buzz-cli/src/commands/pr.rs | 4 + crates/buzz-cli/src/lib.rs | 3 + crates/buzz-sdk/src/builders.rs | 12 + .../src/commands/project_git_workflow.rs | 124 +++++- desktop/src-tauri/src/lib.rs | 1 + .../src/features/forum/ui/ForumComposer.tsx | 213 ++++++---- .../features/forum/ui/ForumComposer.types.ts | 7 + desktop/src/features/projects/hooks.ts | 18 +- .../projects/lib/projectsViewHelpers.ts | 25 +- .../projects/projectPullRequests.d.mts | 2 + .../features/projects/projectPullRequests.mjs | 3 +- .../projects/projectPullRequests.test.mjs | 39 ++ .../features/projects/pullRequestReviews.ts | 67 ++- .../projects/ui/MergePullRequestButton.tsx | 2 +- .../projects/ui/ProjectDetailFeedPanels.tsx | 31 +- .../projects/ui/ProjectEventTypeIcon.tsx | 11 +- .../projects/ui/ProjectIssuesPanel.tsx | 13 +- .../ProjectPullRequestFilesChangedPanel.tsx | 62 ++- .../ui/ProjectPullRequestInlineComments.tsx | 25 +- .../projects/ui/ProjectPullRequestsPanel.tsx | 380 +++++++++++------ .../projects/ui/ProjectRepositoryPanel.tsx | 30 +- .../projects/ui/ProjectWorkspaceTabList.tsx | 2 +- .../projects/ui/ProjectWorkspaceTabs.tsx | 24 ++ .../projects/ui/ProjectsIssuesList.tsx | 28 +- .../projects/ui/ProjectsPullRequestsList.tsx | 24 +- .../projects/ui/PullRequestReviewCard.tsx | 387 ++++++++++-------- .../projects/ui/PullRequestReviewersRow.tsx | 60 +-- desktop/src/shared/api/projectGit.ts | 11 + desktop/src/testing/e2eBridge.ts | 33 ++ desktop/tests/e2e/project-pr-review.spec.ts | 220 ++++++++-- 31 files changed, 1297 insertions(+), 567 deletions(-) diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index 232baefb06..c42e65cb83 100644 --- a/crates/buzz-acp/src/base_prompt.md +++ b/crates/buzz-acp/src/base_prompt.md @@ -17,10 +17,13 @@ The `buzz` CLI is your primary interface. Auth env vars: `BUZZ_RELAY_URL`, `BUZZ | `buzz feed` | `get` | | `buzz social` | `publish`, `notes` | | `buzz repos` | `create`, `get`, `list` | +| `buzz pr` | `open`, `update`, `get`, `list`, `status` | | `buzz upload` | `file` | Run `buzz --help` or `buzz --help` for full usage. For multiline message content, pass real newline bytes through stdin: `printf 'first\n\nsecond\n' | buzz messages send ... --content -`. Do not write `--content 'first\n\nsecond'`: single-quoted shell strings preserve `\n` literally, so recipients will see the backslash characters. `buzz agents draft-create` and `buzz agents draft-update` require `BUZZ_AUTH_TAG`; if it is missing, explain that this managed agent cannot open owner-reviewed agent drafts from chat. +When opening a pull request in response to channel work, always pass `--channel ` using the UUID from `[Context]`. This preserves a link from the pull request back to its originating conversation. + ## Conversational Agent Creation When someone asks to create an agent, ask for at most two things: the agent's name and what it should do day-to-day. Turn the user's rough purpose into the `--system-prompt` yourself; do not separately ask for purpose, tone, constraints, access, runtime, provider, or model unless the user's request is genuinely ambiguous. diff --git a/crates/buzz-cli/src/commands/pr.rs b/crates/buzz-cli/src/commands/pr.rs index 22250c23e1..4272c2bfd8 100644 --- a/crates/buzz-cli/src/commands/pr.rs +++ b/crates/buzz-cli/src/commands/pr.rs @@ -31,6 +31,7 @@ pub async fn cmd_open_pr( euc: Option<&str>, labels: &[String], to: &[String], + channel: Option<&str>, revision_of: Option<&str>, ) -> Result<(), CliError> { validate_hex64(repo_owner)?; @@ -44,6 +45,7 @@ pub async fn cmd_open_pr( let meta = GitPullRequestMeta { euc: euc.map(str::to_string), recipients: to.to_vec(), + channel_id: channel.map(str::to_string), subject: subject.to_string(), labels: labels.to_vec(), commit: commit.to_string(), @@ -227,6 +229,7 @@ pub async fn dispatch(cmd: crate::PrCmd, client: &BuzzClient) -> Result<(), CliE euc, label, to, + channel, revision_of, } => { cmd_open_pr( @@ -243,6 +246,7 @@ pub async fn dispatch(cmd: crate::PrCmd, client: &BuzzClient) -> Result<(), CliE euc.as_deref(), &label, &to, + channel.as_deref(), revision_of.as_deref(), ) .await diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index d5c6b6f9ab..492e7b4028 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -1326,6 +1326,9 @@ pub enum PrCmd { /// Additional recipient pubkey(s) — can be specified multiple times #[arg(long = "to")] to: Vec, + /// Channel where this pull request originated (NIP-29 h-tag) + #[arg(long)] + channel: Option, /// Root patch event id this PR revises #[arg(long)] revision_of: Option, diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index e52c97603c..9184ab1eca 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -1293,6 +1293,8 @@ pub struct GitPullRequestMeta { pub euc: Option, /// Additional pubkeys to `p`-tag besides the repo owner. pub recipients: Vec, + /// NIP-29 channel where the pull request originated (`h` tag). + pub channel_id: Option, /// PR subject line (`subject` tag) — required, used as the header. pub subject: String, /// Labels (`t` tags). @@ -1352,6 +1354,14 @@ pub fn build_git_pull_request( tags.push(tag(&["t", label])?); } tags.push(tag(&["c", &meta.commit])?); + if let Some(ref channel_id) = meta.channel_id { + if channel_id.trim().is_empty() { + return Err(SdkError::InvalidInput( + "channel_id must not be empty".into(), + )); + } + tags.push(tag(&["h", channel_id])?); + } let mut clone_tag = vec!["clone"]; clone_tag.extend(meta.clone_urls.iter().map(String::as_str)); tags.push(tag(&clone_tag)?); @@ -3351,6 +3361,7 @@ mod tests { clone_urls: vec!["https://example.com/repo.git".to_string()], branch_name: Some("feat/x".to_string()), labels: vec!["enhancement".to_string()], + channel_id: Some("11111111-1111-4111-8111-111111111111".to_string()), ..Default::default() }; let ev = sign(build_git_pull_request(&pr_repo(), "PR body", &meta).unwrap()); @@ -3361,6 +3372,7 @@ mod tests { assert!(has_tag(&ev, "subject", "Add feature X")); assert!(has_tag(&ev, "c", &"c".repeat(40))); assert!(has_tag(&ev, "t", "enhancement")); + assert!(has_tag(&ev, "h", "11111111-1111-4111-8111-111111111111")); assert!(has_tag(&ev, "branch-name", "feat/x")); assert_eq!( full_clone_tag(&ev), diff --git a/desktop/src-tauri/src/commands/project_git_workflow.rs b/desktop/src-tauri/src/commands/project_git_workflow.rs index 2e0231069c..39832feb10 100644 --- a/desktop/src-tauri/src/commands/project_git_workflow.rs +++ b/desktop/src-tauri/src/commands/project_git_workflow.rs @@ -126,6 +126,18 @@ pub struct ProjectPullRequestReviewRequestInput { reviewer_label: String, } +/// Repository-scoped metadata for an agent-signed lifecycle status. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectPullRequestStatusInput { + target_owner: String, + repo_address: String, + pull_request_id: String, + pull_request_author: String, + status: String, + created_at: u64, +} + /// A previously signed merged-status event that needs publishing again. #[derive(Deserialize)] #[serde(rename_all = "camelCase")] @@ -249,6 +261,44 @@ fn build_merged_status_event( .map_err(|error| format!("sign merged pull request status: {error}")) } +fn build_pull_request_status_event( + keys: &Keys, + repo_address: &str, + pull_request_id: &str, + pull_request_author: &str, + status: &str, + created_at: u64, +) -> Result { + let owner = keys.public_key().to_hex(); + let (pull_request_id, pull_request_author) = + validate_merge_status_metadata(repo_address, &owner, pull_request_id, pull_request_author)?; + let kind = match status { + "open" => Kind::Custom(1630), + "closed" => Kind::Custom(1632), + "draft" => Kind::Custom(1633), + _ => return Err("Invalid pull request lifecycle status.".to_string()), + }; + let mut raw_tags = vec![ + vec!["e", pull_request_id.as_str(), "", "root"], + vec!["a", repo_address], + vec!["p", owner.as_str()], + ]; + if pull_request_author != owner { + raw_tags.push(vec!["p", pull_request_author.as_str()]); + } + let tags = raw_tags + .into_iter() + .map(Tag::parse) + .collect::, _>>() + .map_err(|error| format!("build pull request status tags: {error}"))?; + EventBuilder::new(kind, "") + .tags(tags) + .custom_created_at(Timestamp::from(created_at.max(Timestamp::now().as_secs()))) + .sign_with_keys(keys) + .map(|event| event.as_json()) + .map_err(|error| format!("sign pull request status: {error}")) +} + fn build_review_request_event( keys: &Keys, repo_address: &str, @@ -433,6 +483,31 @@ pub async fn clone_project_repository( .map_err(|error| format!("repo clone task failed: {error}"))? } +#[tauri::command] +pub async fn sign_project_pull_request_status( + input: ProjectPullRequestStatusInput, + app: AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { + let target_owner = input.target_owner.trim().to_ascii_lowercase(); + if normalize_event_id(&target_owner).is_none() { + return Err("Invalid target repository owner.".to_string()); + } + let identity = project_owner_identity(&app, &state, &target_owner)?; + let event = Event::from_json(build_pull_request_status_event( + &identity.keys, + &input.repo_address, + &input.pull_request_id, + &input.pull_request_author, + &input.status, + input.created_at, + )?) + .map_err(|error| format!("parse signed pull request status: {error}"))?; + submit_signed_event_with_keys(&event, &state, &identity.keys, identity.auth_tag.as_deref()) + .await?; + Ok(()) +} + #[tauri::command] pub async fn sign_project_pull_request_review_request( input: ProjectPullRequestReviewRequestInput, @@ -663,9 +738,9 @@ pub async fn merge_project_pull_request( #[cfg(test)] mod tests { use super::{ - align_unborn_head_branch, build_merged_status_event, build_review_request_event, - classify_merge_error, normalize_commit, same_repository, validate_merge_status_metadata, - ProjectPullRequestMergeError, + align_unborn_head_branch, build_merged_status_event, build_pull_request_status_event, + build_review_request_event, classify_merge_error, normalize_commit, same_repository, + validate_merge_status_metadata, ProjectPullRequestMergeError, }; use crate::commands::project_git_exec::{build_test_git_auth_config, run_git}; use nostr::{Event, JsonUtil, Keys, Timestamp}; @@ -834,6 +909,49 @@ mod tests { .is_err()); } + #[test] + fn lifecycle_status_is_signed_by_repository_owner() { + let keys = Keys::generate(); + let owner = keys.public_key().to_hex(); + let author = "b".repeat(64); + let event = Event::from_json( + build_pull_request_status_event( + &keys, + &format!("30617:{owner}:buzz"), + &"d".repeat(64), + &author, + "closed", + Timestamp::now().as_secs(), + ) + .unwrap(), + ) + .unwrap(); + + assert_eq!(event.pubkey, keys.public_key()); + assert_eq!(event.kind.as_u16(), 1632); + assert!(event + .tags + .iter() + .any(|tag| tag.as_slice() == ["p", author.as_str()])); + assert!(event.verify().is_ok()); + } + + #[test] + fn lifecycle_status_rejects_merged_alias() { + let keys = Keys::generate(); + let owner = keys.public_key().to_hex(); + + assert!(build_pull_request_status_event( + &keys, + &format!("30617:{owner}:buzz"), + &"d".repeat(64), + &"b".repeat(64), + "merged", + Timestamp::now().as_secs(), + ) + .is_err()); + } + #[test] fn review_request_is_signed_by_repository_owner() { let keys = Keys::generate(); diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index c22de0205f..d811e23a17 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -677,6 +677,7 @@ pub fn run() { delete_project_remote_branch, push_project_local_repository, pull_project_local_repository, + sign_project_pull_request_status, sign_project_pull_request_review_request, publish_project_pull_request_merged_status, merge_project_pull_request, diff --git a/desktop/src/features/forum/ui/ForumComposer.tsx b/desktop/src/features/forum/ui/ForumComposer.tsx index 4aa5bd47f3..79dab559c4 100644 --- a/desktop/src/features/forum/ui/ForumComposer.tsx +++ b/desktop/src/features/forum/ui/ForumComposer.tsx @@ -1,6 +1,7 @@ import * as React from "react"; import { EditorContent } from "@tiptap/react"; +import { ChevronDown } from "lucide-react"; import { buildOutgoingMessage } from "@/features/messages/lib/imetaMediaMarkdown"; import { useChannelLinks } from "@/features/messages/lib/useChannelLinks"; import type { ChannelSuggestion } from "@/features/messages/lib/useChannelLinks"; @@ -20,6 +21,13 @@ import type { MentionSuggestion } from "@/features/messages/ui/MentionAutocomple import { MessageComposerToolbar } from "@/features/messages/ui/MessageComposerToolbar"; import { Button } from "@/shared/ui/button"; import { cn } from "@/shared/lib/cn"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "@/shared/ui/dropdown-menu"; import type { ForumComposerProps } from "./ForumComposer.types"; import { ForumComposerAutocompletes } from "./ForumComposerAutocompletes"; import { ForumComposerCompactLayout } from "./ForumComposerCompactLayout"; @@ -35,7 +43,9 @@ export function ForumComposer({ header, isSending, onCancel, + onSecondarySubmit, onSubmit, + secondarySubmitLabel, compact = false, autocompleteBelow = false, profiles, @@ -47,6 +57,9 @@ export function ForumComposer({ const [isCompactExpanded, setIsCompactExpanded] = React.useState(!compact); const [isEmojiPickerOpen, setIsEmojiPickerOpen] = React.useState(false); const [isFormattingOpen, setIsFormattingOpen] = React.useState(false); + const [submitMode, setSubmitMode] = React.useState<"primary" | "secondary">( + "primary", + ); const handleFormattingToggle = React.useCallback((pressed: boolean) => { if (pressed) setIsEmojiPickerOpen(false); @@ -70,10 +83,14 @@ export function ForumComposer({ const isSendingRef = React.useRef(isSending); const isUploadingRef = React.useRef(media.isUploading); const onSubmitRef = React.useRef(onSubmit); + const onSecondarySubmitRef = React.useRef(onSecondarySubmit); + const submitModeRef = React.useRef(submitMode); disabledRef.current = disabled; isSendingRef.current = isSending; isUploadingRef.current = media.isUploading; onSubmitRef.current = onSubmit; + onSecondarySubmitRef.current = onSecondarySubmit; + submitModeRef.current = onSecondarySubmit ? submitMode : "primary"; const isAutocompleteOpenRef = React.useRef(false); isAutocompleteOpenRef.current = @@ -193,78 +210,90 @@ export function ForumComposer({ ]); // ── Submit ────────────────────────────────────────────────────────── - const submitMessage = React.useCallback(() => { - const trimmed = contentRef.current.trim(); - const currentPendingImeta = media.pendingImetaRef.current; - const hasMedia = currentPendingImeta.length > 0; - - if ( - (!trimmed && !hasMedia) || - disabledRef.current || - isSendingRef.current || - isUploadingRef.current - ) { - return; - } + const submitMessage = React.useCallback( + (submitter = onSubmitRef.current) => { + const trimmed = contentRef.current.trim(); + const currentPendingImeta = media.pendingImetaRef.current; + const hasMedia = currentPendingImeta.length > 0; - const pubkeys = mentions.extractMentionPubkeys(trimmed); + if ( + (!trimmed && !hasMedia) || + disabledRef.current || + isSendingRef.current || + isUploadingRef.current + ) { + return; + } - // Reuse the shared send-path builder so forum/notes posts emit the same - // body + imeta as chat: generic files become `[filename](url)` links with a - // `filename` imeta tag (FileCard renderer), images/video stay inline. Send - // semantics use `undefined` for "no attachments" (no imeta tags emitted). - const { content: finalContent, mediaTags } = buildOutgoingMessage( - trimmed, - currentPendingImeta, - ); + const pubkeys = mentions.extractMentionPubkeys(trimmed); - // Save draft state so we can restore on failure. - const savedContent = contentRef.current; - const savedImeta = [...currentPendingImeta]; + // Reuse the shared send-path builder so forum/notes posts emit the same + // body + imeta as chat: generic files become `[filename](url)` links with a + // `filename` imeta tag (FileCard renderer), images/video stay inline. Send + // semantics use `undefined` for "no attachments" (no imeta tags emitted). + const { content: finalContent, mediaTags } = buildOutgoingMessage( + trimmed, + currentPendingImeta, + ); - setContent(""); - contentRef.current = ""; - richText.clearContent(); - media.setPendingImeta([]); - mentions.clearMentions(); - channelLinks.clearChannels(); - setIsEmojiPickerOpen(false); + // Save draft state so we can restore on failure. + const savedContent = contentRef.current; + const savedImeta = [...currentPendingImeta]; - const result = onSubmitRef.current(finalContent, pubkeys, mediaTags); - const collapseCompactComposer = () => { - if (compact) setIsCompactExpanded(false); - }; - - // If onSubmit returns a promise, restore draft on failure. - if (result && typeof result.then === "function") { - result.then(collapseCompactComposer).catch(() => { - setContent(savedContent); - contentRef.current = savedContent; - richText.setContent(savedContent); - media.setPendingImeta(savedImeta); - if (compact) setIsCompactExpanded(true); - }); - } else { - collapseCompactComposer(); - } - }, [ - compact, - media.pendingImetaRef, - media.setPendingImeta, - mentions.extractMentionPubkeys, - mentions.clearMentions, - channelLinks.clearChannels, - richText.clearContent, - richText.setContent, - ]); - submitMessageRef.current = submitMessage; + setContent(""); + contentRef.current = ""; + richText.clearContent(); + media.setPendingImeta([]); + mentions.clearMentions(); + channelLinks.clearChannels(); + setIsEmojiPickerOpen(false); + + const result = submitter(finalContent, pubkeys, mediaTags); + const completeSubmission = () => { + setSubmitMode("primary"); + if (compact) setIsCompactExpanded(false); + }; + + // If onSubmit returns a promise, restore draft on failure. + if (result && typeof result.then === "function") { + result.then(completeSubmission).catch(() => { + setContent(savedContent); + contentRef.current = savedContent; + richText.setContent(savedContent); + media.setPendingImeta(savedImeta); + if (compact) setIsCompactExpanded(true); + }); + } else { + completeSubmission(); + } + }, + [ + compact, + media.pendingImetaRef, + media.setPendingImeta, + mentions.extractMentionPubkeys, + mentions.clearMentions, + channelLinks.clearChannels, + richText.clearContent, + richText.setContent, + ], + ); + const submitSelectedMessage = React.useCallback(() => { + const secondarySubmit = onSecondarySubmitRef.current; + submitMessage( + submitModeRef.current === "secondary" && secondarySubmit + ? secondarySubmit + : onSubmitRef.current, + ); + }, [submitMessage]); + submitMessageRef.current = submitSelectedMessage; const handleSubmit = React.useCallback( (event: React.FormEvent) => { event.preventDefault(); - submitMessage(); + submitSelectedMessage(); }, - [submitMessage], + [submitSelectedMessage], ); // ── Keyboard handling ─────────────────────────────────────────────── @@ -479,16 +508,56 @@ export function ForumComposer({ composerDisabled={disabled ?? false} editor={richText.editor} extraActions={ - onCancel ? ( - + onCancel || (onSecondarySubmit && secondarySubmitLabel) ? ( + <> + {onCancel ? ( + + ) : null} + {onSecondarySubmit && secondarySubmitLabel ? ( + + + + + + + setSubmitMode(value as "primary" | "secondary") + } + value={submitMode} + > + + Comment + + + {secondarySubmitLabel} + + + + + ) : null} + ) : undefined } formattingDisabled={disabled ?? false} diff --git a/desktop/src/features/forum/ui/ForumComposer.types.ts b/desktop/src/features/forum/ui/ForumComposer.types.ts index e72a4edcf4..a20e750186 100644 --- a/desktop/src/features/forum/ui/ForumComposer.types.ts +++ b/desktop/src/features/forum/ui/ForumComposer.types.ts @@ -18,6 +18,13 @@ export type ForumComposerProps = { mentionPubkeys: string[], mediaTags?: string[][], ) => undefined | Promise; + /** Optional alternate submission using the same composed content. */ + onSecondarySubmit?: ( + content: string, + mentionPubkeys: string[], + mediaTags?: string[][], + ) => undefined | Promise; + secondarySubmitLabel?: string; /** Render as a single-line composer until the user focuses it. */ compact?: boolean; /** When true, autocomplete renders below the input (for top-of-view composers). */ diff --git a/desktop/src/features/projects/hooks.ts b/desktop/src/features/projects/hooks.ts index a1761f0b11..bc12b38f1d 100644 --- a/desktop/src/features/projects/hooks.ts +++ b/desktop/src/features/projects/hooks.ts @@ -49,6 +49,7 @@ import type { } from "./projectPullRequests.mjs"; import { normalizeProjectPullRequestCommentAnchor, + PR_CHANGES_REQUESTED_LABEL, PR_INLINE_COMMENT_LABEL, projectPullRequestEventsToPullRequests, } from "./projectPullRequests.mjs"; @@ -60,6 +61,8 @@ export type { ProjectPullRequestCommentAnchor, }; +export type ProjectPullRequestCommentDecision = "request-changes"; + const HIDDEN_PROJECT_CARDS_KEY = "buzz.projects.hidden-cards.v1"; export type Project = { @@ -439,6 +442,7 @@ async function fetchProjectPullRequests( async function createProjectPullRequestComment({ anchor, content, + decision, mediaTags, mentionPubkeys = [], project, @@ -446,6 +450,7 @@ async function createProjectPullRequestComment({ }: { anchor?: ProjectPullRequestCommentAnchor; content: string; + decision?: ProjectPullRequestCommentDecision; mediaTags?: string[][]; mentionPubkeys?: string[]; project: Project; @@ -461,8 +466,8 @@ async function createProjectPullRequestComment({ if (anchor && !normalizedAnchor) { throw new Error("Comment location is invalid."); } - if (normalizedAnchor && !pullRequest.commit) { - throw new Error("Pull request commit is required for inline comments."); + if ((normalizedAnchor || decision) && !pullRequest.commit) { + throw new Error("Pull request commit is required for review comments."); } const recipients = new Set([ @@ -484,6 +489,12 @@ async function createProjectPullRequestComment({ ["line", String(normalizedAnchor.line)], ] : []), + ...(decision + ? [ + ["t", PR_CHANGES_REQUESTED_LABEL], + ...(!normalizedAnchor ? [["c", pullRequest.commit as string]] : []), + ] + : []), ...(mediaTags ?? []), ]; @@ -907,12 +918,14 @@ export function useCreateProjectPullRequestCommentMutation( mutationFn: ({ anchor, content, + decision, mediaTags, mentionPubkeys, pullRequest, }: { anchor?: ProjectPullRequestCommentAnchor; content: string; + decision?: ProjectPullRequestCommentDecision; mediaTags?: string[][]; mentionPubkeys?: string[]; pullRequest: ProjectPullRequest; @@ -921,6 +934,7 @@ export function useCreateProjectPullRequestCommentMutation( return createProjectPullRequestComment({ anchor, content, + decision, mediaTags, mentionPubkeys, project, diff --git a/desktop/src/features/projects/lib/projectsViewHelpers.ts b/desktop/src/features/projects/lib/projectsViewHelpers.ts index fefb75b4f8..0328271441 100644 --- a/desktop/src/features/projects/lib/projectsViewHelpers.ts +++ b/desktop/src/features/projects/lib/projectsViewHelpers.ts @@ -191,21 +191,30 @@ export function formatCreatedDate(createdAt: number) { }); } -export function relativeTime(createdAt: number) { - const elapsedSeconds = Math.max( - 1, - Math.floor(Date.now() / 1_000 - createdAt), - ); +export function relativeTime( + createdAt: number, + nowSeconds = Math.floor(Date.now() / 1_000), +) { + const elapsedSeconds = Math.max(1, Math.floor(nowSeconds - createdAt)); const units = [ - { label: "year", seconds: 365 * 24 * 60 * 60 }, - { label: "month", seconds: 30 * 24 * 60 * 60 }, - { label: "week", seconds: 7 * 24 * 60 * 60 }, { label: "day", seconds: 24 * 60 * 60 }, { label: "hour", seconds: 60 * 60 }, { label: "minute", seconds: 60 }, { label: "second", seconds: 1 }, ]; + if (elapsedSeconds >= 7 * 24 * 60 * 60) { + const createdDate = new Date(createdAt * 1_000); + const nowDate = new Date(nowSeconds * 1_000); + return createdDate.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + ...(createdDate.getFullYear() === nowDate.getFullYear() + ? {} + : { year: "numeric" }), + }); + } + for (const unit of units) { const value = Math.floor(elapsedSeconds / unit.seconds); if (value >= 1) { diff --git a/desktop/src/features/projects/projectPullRequests.d.mts b/desktop/src/features/projects/projectPullRequests.d.mts index a363344454..f4d6e0a27d 100644 --- a/desktop/src/features/projects/projectPullRequests.d.mts +++ b/desktop/src/features/projects/projectPullRequests.d.mts @@ -73,6 +73,8 @@ export type ProjectPullRequest = { author: string; createdAt: number; repoAddress: string | null; + /** Channel where the pull request originated (`h` tag), when provided. */ + channelId: string | null; labels: string[]; recipients: string[]; /** Requested reviewers (root `p` tags + trusted review-request comments). */ diff --git a/desktop/src/features/projects/projectPullRequests.mjs b/desktop/src/features/projects/projectPullRequests.mjs index 840b8cd663..a5e9b927b7 100644 --- a/desktop/src/features/projects/projectPullRequests.mjs +++ b/desktop/src/features/projects/projectPullRequests.mjs @@ -180,7 +180,7 @@ function eventToPullRequestComment(event) { const parsedLine = lineTag && /^[1-9]\d*$/.test(lineTag) ? Number(lineTag) : Number.NaN; const anchor = - isReviewRequest || isApproval || isChangeRequest + isReviewRequest || isApproval ? null : normalizeProjectPullRequestCommentAnchor({ line: parsedLine, @@ -355,6 +355,7 @@ export function eventToProjectPullRequest( author: pullRequest.pubkey, createdAt: pullRequest.created_at, repoAddress: getTag(pullRequest, "a") ?? null, + channelId: getTag(pullRequest, "h") ?? null, labels: getAllTags(pullRequest, "t"), recipients: getAllTags(pullRequest, "p"), reviewers, diff --git a/desktop/src/features/projects/projectPullRequests.test.mjs b/desktop/src/features/projects/projectPullRequests.test.mjs index 69c462d818..b9f2cf98c7 100644 --- a/desktop/src/features/projects/projectPullRequests.test.mjs +++ b/desktop/src/features/projects/projectPullRequests.test.mjs @@ -41,6 +41,14 @@ test("reads source and target branches from the pull request", () => { assert.equal(pullRequest.targetBranch, "release"); }); +test("preserves an optional source channel from the pull request", () => { + const event = pullRequestEvent(); + event.tags.push(["h", "source-channel-id"]); + + assert.equal(eventToProjectPullRequest(event).channelId, "source-channel-id"); + assert.equal(eventToProjectPullRequest(pullRequestEvent()).channelId, null); +}); + function updateEvent({ pubkey, createdAt, commit, cloneUrl }) { return { id: `update-${pubkey.slice(0, 8)}-${createdAt}`, @@ -234,6 +242,37 @@ test("parses commit-scoped inline comment anchors", () => { assert.equal(pullRequest.comments[0].isInlineComment, true); }); +test("keeps anchors on comment-backed change requests", () => { + const commit = "1111111111111111111111111111111111111111"; + const comment = commentEvent({ + pubkey: OWNER, + createdAt: 150, + content: "Please handle the empty state before merging.", + labels: ["changes-requested"], + commit, + anchor: { path: "src/example.ts", side: "new", line: 12 }, + }); + + const pullRequest = eventToProjectPullRequest( + pullRequestEvent(), + [], + [comment], + ); + const parsedComment = pullRequest.comments[0]; + + assert.deepEqual(parsedComment.anchor, { + path: "src/example.ts", + side: "new", + line: 12, + }); + assert.equal(parsedComment.isTrustedReviewDecision, true); + assert.equal( + projectPullRequestCommentTimelineKind(parsedComment), + "changes-requested", + ); + assert.equal(pullRequest.changeRequests.length, 1); +}); + test("marks inline comments on earlier commits as outdated", () => { const initialCommit = "1111111111111111111111111111111111111111"; const currentCommit = "2222222222222222222222222222222222222222"; diff --git a/desktop/src/features/projects/pullRequestReviews.ts b/desktop/src/features/projects/pullRequestReviews.ts index f038d85f13..aa72c31643 100644 --- a/desktop/src/features/projects/pullRequestReviews.ts +++ b/desktop/src/features/projects/pullRequestReviews.ts @@ -1,9 +1,13 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import * as React from "react"; -import { signProjectPullRequestReviewRequest } from "@/shared/api/projectGit"; +import { + signProjectPullRequestReviewRequest, + signProjectPullRequestStatus, +} from "@/shared/api/projectGit"; import { relayClient } from "@/shared/api/relayClient"; import { signRelayEvent } from "@/shared/api/tauri"; +import { normalizePubkey } from "@/shared/lib/pubkey"; import { KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, @@ -40,12 +44,29 @@ const PR_STATUS_KIND_BY_LIFECYCLE: Record< async function updateProjectPullRequestStatus({ project, pullRequest, + signAsManagedOwner, status, }: { project: Project; pullRequest: ProjectPullRequest; + signAsManagedOwner: boolean; status: ProjectPullRequestLifecycleStatus; }): Promise { + const createdAt = nextProjectPullRequestStatusCreatedAt( + pullRequest, + Math.floor(Date.now() / 1_000), + ); + if (signAsManagedOwner) { + await signProjectPullRequestStatus({ + targetOwner: project.owner, + repoAddress: project.repoAddress, + pullRequestId: pullRequest.id, + pullRequestAuthor: pullRequest.author, + status, + createdAt, + }); + return; + } const recipients = new Set([ project.owner.toLowerCase(), pullRequest.author.toLowerCase(), @@ -53,10 +74,7 @@ async function updateProjectPullRequestStatus({ const event = await signRelayEvent({ kind: PR_STATUS_KIND_BY_LIFECYCLE[status], content: "", - createdAt: nextProjectPullRequestStatusCreatedAt( - pullRequest, - Math.floor(Date.now() / 1_000), - ), + createdAt, tags: [ ["e", pullRequest.id, "", "root"], ["a", project.repoAddress], @@ -125,6 +143,29 @@ async function requestProjectPullRequestReview({ type ProjectPullRequestReviewDecision = "approve" | "request-changes"; +/** Whether a viewer may submit a review decision for this pull request. */ +export function canReviewProjectPullRequest( + project: Project, + pullRequest: ProjectPullRequest, + viewerPubkey: string | null | undefined, +) { + if ( + !viewerPubkey || + !pullRequest.commit || + (pullRequest.status !== "Open" && pullRequest.status !== "Draft") + ) { + return false; + } + const viewer = normalizePubkey(viewerPubkey); + if (viewer === normalizePubkey(pullRequest.author)) return false; + return ( + viewer === normalizePubkey(project.owner) || + pullRequest.reviewers.some( + (reviewer) => normalizePubkey(reviewer) === viewer, + ) + ); +} + const REVIEW_DECISION_DETAILS: Record< ProjectPullRequestReviewDecision, { @@ -149,11 +190,13 @@ const REVIEW_DECISION_DETAILS: Record< }; async function submitProjectPullRequestReview({ + content, createdAt, decision, project, pullRequest, }: { + content?: string; createdAt: number; decision: ProjectPullRequestReviewDecision; project: Project; @@ -169,7 +212,7 @@ async function submitProjectPullRequestReview({ ]); const event = await signRelayEvent({ kind: KIND_TEXT_NOTE, - content: details.content, + content: content?.trim() || details.content, createdAt, tags: [ ["e", pullRequest.id, "", "root"], @@ -212,13 +255,20 @@ export function useUpdateProjectPullRequestStatusMutation( return useMutation({ mutationFn: ({ pullRequest, + signAsManagedOwner = false, status, }: { pullRequest: ProjectPullRequest; + signAsManagedOwner?: boolean; status: ProjectPullRequestLifecycleStatus; }) => { if (!project) throw new Error("No project selected."); - return updateProjectPullRequestStatus({ project, pullRequest, status }); + return updateProjectPullRequestStatus({ + project, + pullRequest, + signAsManagedOwner, + status, + }); }, onSuccess: invalidate, }); @@ -262,14 +312,17 @@ function useProjectPullRequestReviewDecisionMutation( return useMutation({ mutationFn: ({ + content, createdAt, pullRequest, }: { + content?: string; createdAt: number; pullRequest: ProjectPullRequest; }) => { if (!project) throw new Error("No project selected."); return submitProjectPullRequestReview({ + content, createdAt, decision, project, diff --git a/desktop/src/features/projects/ui/MergePullRequestButton.tsx b/desktop/src/features/projects/ui/MergePullRequestButton.tsx index 133c41d043..40757f1529 100644 --- a/desktop/src/features/projects/ui/MergePullRequestButton.tsx +++ b/desktop/src/features/projects/ui/MergePullRequestButton.tsx @@ -178,7 +178,7 @@ export function MergePullRequestButton({
@@ -257,7 +232,7 @@ export function ActivityPanel({ className="hidden w-20 shrink-0 text-right text-xs text-muted-foreground sm:block" title={new Date(commit.timestamp * 1_000).toLocaleString()} > - {relativeCommitTime(commit.timestamp)} + {relativeTime(commit.timestamp)}
Created
- {compactDate(issue.createdAt)} + {relativeTime(issue.createdAt)}
Updated
- {compactDate(issue.updatedAt)} + {relativeTime(issue.updatedAt)}
diff --git a/desktop/src/features/projects/ui/ProjectPullRequestFilesChangedPanel.tsx b/desktop/src/features/projects/ui/ProjectPullRequestFilesChangedPanel.tsx index ba94db68e8..abe98da30a 100644 --- a/desktop/src/features/projects/ui/ProjectPullRequestFilesChangedPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectPullRequestFilesChangedPanel.tsx @@ -33,8 +33,10 @@ import { type ProjectPullRequestCommentAnchor, useCreateProjectPullRequestCommentMutation, } from "@/features/projects/hooks"; +import { canReviewProjectPullRequest } from "@/features/projects/pullRequestReviews"; import type { ProjectPullRequestComment } from "@/features/projects/projectPullRequests.mjs"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import { useIdentityQuery } from "@/shared/api/hooks"; import { cn } from "@/shared/lib/cn"; import type { ProjectRepoDiff, ProjectRepoDiffFile } from "@/shared/api/types"; import { ProjectPullRequestInlineCommentThread } from "./ProjectPullRequestInlineComments"; @@ -59,6 +61,7 @@ type DiffRow = { type InlineCommentControls = { activeAnchor: ProjectPullRequestCommentAnchor | null; + canRequestChanges: boolean; comments: ProjectPullRequestComment[]; isSending: boolean; onCancel: () => void; @@ -68,6 +71,7 @@ type InlineCommentControls = { content: string, mentionPubkeys: string[], mediaTags?: string[][], + decision?: "request-changes", ) => Promise; profiles?: UserProfileLookup; }; @@ -440,12 +444,28 @@ function errorMessage(error: unknown) { function DiffPreview({ file, + focusedAnchor, inlineComments, }: { file: ProjectRepoDiffFile; + focusedAnchor?: ProjectPullRequestCommentAnchor | null; inlineComments?: InlineCommentControls; }) { const rows = diffRows(file); + const focusedRowRef = React.useRef(null); + + React.useEffect(() => { + if (!focusedAnchor || focusedAnchor.path !== file.path) return; + const frame = window.requestAnimationFrame(() => { + focusedRowRef.current?.scrollIntoView({ + behavior: "smooth", + block: "center", + }); + focusedRowRef.current?.focus({ preventScroll: true }); + }); + return () => window.cancelAnimationFrame(frame); + }, [file.path, focusedAnchor]); + if (rows.length === 0) { return (
@@ -475,17 +495,27 @@ function DiffPreview({ inlineComments?.activeAnchor ?? null, anchor, ); + const isFocused = anchorsEqual(focusedAnchor ?? null, anchor); return (
{row.oldLine ?? " "} @@ -526,15 +556,17 @@ function DiffPreview({ {anchor && inlineComments ? ( + onSubmit={(content, mentionPubkeys, mediaTags, decision) => inlineComments.onSubmit( anchor, content, mentionPubkeys, mediaTags, + decision, ) } profiles={inlineComments.profiles} @@ -600,6 +632,7 @@ function FileTreeItems({ export function ProjectPullRequestFilesChangedPanel({ error, diff, + focusedAnchor, isLoading, profiles, project, @@ -607,11 +640,13 @@ export function ProjectPullRequestFilesChangedPanel({ }: { error: unknown; diff: ProjectRepoDiff | null | undefined; + focusedAnchor?: ProjectPullRequestCommentAnchor | null; isLoading: boolean; profiles?: UserProfileLookup; project: Project; pullRequest: ProjectPullRequest | null; }) { + const identityQuery = useIdentityQuery(); const [activeAnchor, setActiveAnchor] = React.useState(null); const { isPending: isPostingComment, mutateAsync: postComment } = @@ -630,18 +665,24 @@ export function ProjectPullRequestFilesChangedPanel({ content: string, mentionPubkeys: string[], mediaTags?: string[][], + decision?: "request-changes", ) => { if (!pullRequest) throw new Error("No pull request selected."); try { await postComment({ anchor, content, + decision, mediaTags, mentionPubkeys, pullRequest, }); setActiveAnchor(null); - toast.success("Line comment posted."); + toast.success( + decision === "request-changes" + ? "Changes requested." + : "Line comment posted.", + ); } catch (error) { toast.error( error instanceof Error @@ -659,6 +700,7 @@ export function ProjectPullRequestFilesChangedPanel({ diff={pullRequest ? diff : null} embedded error={error} + focusedAnchor={focusedAnchor} headerLabel={ pullRequest ? `${pullRequest.title} · ${pullRequest.commit?.slice(0, 7) ?? "PR"}` @@ -668,6 +710,11 @@ export function ProjectPullRequestFilesChangedPanel({ pullRequest ? { activeAnchor, + canRequestChanges: canReviewProjectPullRequest( + project, + pullRequest, + identityQuery.data?.pubkey, + ), comments: inlineComments, isSending: isPostingComment, onCancel: () => setActiveAnchor(null), @@ -688,6 +735,7 @@ export function ProjectDiffFilesPanel({ diff, isLoading, embedded = false, + focusedAnchor, headerLabel, inlineComments, subjectLabel, @@ -697,6 +745,7 @@ export function ProjectDiffFilesPanel({ isLoading: boolean; /** Render without an outer border, for nesting inside an existing card. */ embedded?: boolean; + focusedAnchor?: ProjectPullRequestCommentAnchor | null; headerLabel: string; inlineComments?: InlineCommentControls; subjectLabel: string; @@ -724,6 +773,12 @@ export function ProjectDiffFilesPanel({ filteredFiles[0] ?? null; + React.useEffect(() => { + if (!focusedAnchor) return; + setQuery(""); + setSelectedPath(focusedAnchor.path); + }, [focusedAnchor]); + React.useEffect(() => { if (filteredFiles.length === 0) { setSelectedPath(null); @@ -854,6 +909,7 @@ export function ProjectDiffFilesPanel({ diff --git a/desktop/src/features/projects/ui/ProjectPullRequestInlineComments.tsx b/desktop/src/features/projects/ui/ProjectPullRequestInlineComments.tsx index 2d399f0c97..e3170b3605 100644 --- a/desktop/src/features/projects/ui/ProjectPullRequestInlineComments.tsx +++ b/desktop/src/features/projects/ui/ProjectPullRequestInlineComments.tsx @@ -5,6 +5,7 @@ import type { ProjectPullRequestComment, ProjectPullRequestCommentAnchor, } from "@/features/projects/projectPullRequests.mjs"; +import { relativeTime } from "@/features/projects/lib/projectsViewHelpers"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; import { Markdown } from "@/shared/ui/markdown"; @@ -21,22 +22,17 @@ function commentAuthor( ); } -function commentDate(createdAt: number) { - return new Date(createdAt * 1_000).toLocaleDateString(undefined, { - day: "numeric", - month: "short", - }); -} - export function ProjectPullRequestInlineCommentThread({ activeAnchor, comments, + canRequestChanges, isSending, onCancel, onSubmit, profiles, }: { activeAnchor: ProjectPullRequestCommentAnchor | null; + canRequestChanges: boolean; comments: ProjectPullRequestComment[]; isSending: boolean; onCancel: () => void; @@ -44,6 +40,7 @@ export function ProjectPullRequestInlineCommentThread({ content: string, mentionPubkeys: string[], mediaTags?: string[][], + decision?: "request-changes", ) => Promise; profiles?: UserProfileLookup; }) { @@ -68,7 +65,7 @@ export function ProjectPullRequestInlineCommentThread({ {commentAuthor(comment.author, profiles)} - {commentDate(comment.createdAt)} + {relativeTime(comment.createdAt)}
+ onSubmit( + content, + mentionPubkeys, + mediaTags, + "request-changes", + ) + : undefined + } onSubmit={onSubmit} placeholder="Leave a comment on this line…" profiles={profiles} + secondarySubmitLabel="Request changes" /> ) : null}
diff --git a/desktop/src/features/projects/ui/ProjectPullRequestsPanel.tsx b/desktop/src/features/projects/ui/ProjectPullRequestsPanel.tsx index 4e2dbb2a53..6af10b6428 100644 --- a/desktop/src/features/projects/ui/ProjectPullRequestsPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectPullRequestsPanel.tsx @@ -1,26 +1,37 @@ import { Check, + ChevronDown, + ChevronUp, FileCode2, GitBranch, GitCommitHorizontal, GitMerge, GitPullRequest, + History, MessageSquare, + TriangleAlert, UserPlus, X, } from "lucide-react"; import * as React from "react"; import { toast } from "sonner"; +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useIsManagedAgent } from "@/features/agent-memory/hooks"; +import { useChannelsQuery } from "@/features/channels/hooks"; import { ForumComposer } from "@/features/forum/ui/ForumComposer"; import { type Project, type ProjectPullRequest, + type ProjectPullRequestCommentAnchor, useCreateProjectPullRequestCommentMutation, } from "@/features/projects/hooks"; import { projectPullRequestCommentTimelineKind } from "@/features/projects/projectPullRequests.mjs"; -import { relativeTime } from "@/features/projects/lib/projectsViewHelpers"; +import { + formatExactTimestamp, + relativeTime, +} from "@/features/projects/lib/projectsViewHelpers"; +import { canReviewProjectPullRequest } from "@/features/projects/pullRequestReviews"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { useIdentityQuery } from "@/shared/api/hooks"; import type { ChannelMember } from "@/shared/api/types"; @@ -41,13 +52,6 @@ import { import { PullRequestReviewersRow } from "./PullRequestReviewersRow"; import { PullRequestReviewCard } from "./PullRequestReviewCard"; -function compactDate(createdAt: number) { - return new Date(createdAt * 1_000).toLocaleDateString(undefined, { - month: "short", - day: "numeric", - }); -} - function profileForPubkey(pubkey: string, profiles?: UserProfileLookup) { return profiles?.[normalizePubkey(pubkey)] ?? null; } @@ -61,25 +65,6 @@ function labelForPubkey(pubkey: string, profiles?: UserProfileLookup) { ); } -function relativeCreatedAt(createdAt: number) { - const elapsedSeconds = Math.max( - 1, - Math.floor(Date.now() / 1_000 - createdAt), - ); - const units = [ - { label: "year", seconds: 365 * 24 * 60 * 60 }, - { label: "month", seconds: 30 * 24 * 60 * 60 }, - { label: "day", seconds: 24 * 60 * 60 }, - { label: "hour", seconds: 60 * 60 }, - { label: "minute", seconds: 60 }, - ]; - const unit = - units.find((item) => elapsedSeconds >= item.seconds) ?? - units[units.length - 1]; - const value = Math.max(1, Math.floor(elapsedSeconds / unit.seconds)); - return `${value} ${unit.label}${value === 1 ? "" : "s"} ago`; -} - function pluralize(count: number, singular: string, plural = `${singular}s`) { return `${count} ${count === 1 ? singular : plural}`; } @@ -128,11 +113,13 @@ function AuthorIdentity({ profiles, pubkey, role, + showLabel = true, }: { avatarSize?: "xs" | "sm" | "md"; profiles?: UserProfileLookup; pubkey: string; role?: React.ReactNode; + showLabel?: boolean; }) { const profile = profileForPubkey(pubkey, profiles); return ( @@ -144,6 +131,7 @@ function AuthorIdentity({ label={labelForPubkey(pubkey, profiles)} pubkey={pubkey} role={role} + showLabel={showLabel} /> ); } @@ -213,7 +201,10 @@ function PullRequestCommitRow({ /> {authorLabel}{" "} - authored {relativeTime(createdAt)} + authored{" "} + + {relativeTime(createdAt)} + {branch ? ( @@ -276,7 +267,10 @@ function PullRequestRow({ {authorLabel} {" "} - created this pull request {relativeCreatedAt(pullRequest.createdAt)} + created this pull request{" "} + + {relativeTime(pullRequest.createdAt)} + {pullRequest.branchName ? ( @@ -335,18 +329,51 @@ export function PullRequestDetailHeader({ pullRequest: ProjectPullRequest; }) { const authorLabel = labelForPubkey(pullRequest.author, profiles); + const sourceChannelId = pullRequest.channelId; + const { goChannel } = useAppNavigation(); + const channelsQuery = useChannelsQuery({ + enabled: Boolean(sourceChannelId), + }); + const sourceChannel = channelsQuery.data?.find( + (channel) => channel.id === sourceChannelId, + ); return ( -
-

+
+

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

-

+

- Created {compactDate(pullRequest.createdAt)} by {authorLabel} + + Created {relativeTime(pullRequest.createdAt)} by + + + + + {authorLabel} + + + {sourceChannelId ? ( + <> + in + + + ) : null}

); @@ -430,14 +457,20 @@ export function PullRequestMetaRail({
Created
-
- {compactDate(pullRequest.createdAt)} +
+ {relativeTime(pullRequest.createdAt)}
Updated
-
- {compactDate(pullRequest.updatedAt)} +
+ {relativeTime(pullRequest.updatedAt)}
@@ -448,6 +481,7 @@ export function PullRequestMetaRail({ function PullRequestDetail({ mode, + onOpenInlineComment, onOpenCommit, onOpenTerminal, profiles, @@ -455,31 +489,43 @@ function PullRequestDetail({ pullRequest, }: { mode: PullRequestPanelMode; + onOpenInlineComment?: (anchor: ProjectPullRequestCommentAnchor) => void; onOpenCommit?: (commitHash: string) => void; onOpenTerminal?: OpenMergeRecoveryTerminal; profiles?: UserProfileLookup; project: Project; pullRequest: ProjectPullRequest; }) { + const identityQuery = useIdentityQuery(); const commentMutation = useCreateProjectPullRequestCommentMutation(project); + const [ + expandedReviewHistoryBoxPullRequestId, + setExpandedReviewHistoryBoxPullRequestId, + ] = React.useState(null); const members = React.useMemo( () => pullRequestMembers(project, pullRequest, profiles), [profiles, project, pullRequest], ); - const handleCommentSubmit = React.useCallback( + const submitComment = React.useCallback( async ( content: string, mentionPubkeys: string[], mediaTags?: string[][], + decision?: "request-changes", ) => { try { await commentMutation.mutateAsync({ content, + decision, mediaTags, mentionPubkeys, pullRequest, }); - toast.success("Comment posted."); + toast.success( + decision === "request-changes" + ? "Changes requested." + : "Comment posted.", + ); } catch (error) { toast.error( error instanceof Error ? error.message : "Failed to post comment.", @@ -489,6 +535,16 @@ function PullRequestDetail({ }, [commentMutation, pullRequest], ); + const handleCommentSubmit = React.useCallback( + (content: string, mentionPubkeys: string[], mediaTags?: string[][]) => + submitComment(content, mentionPubkeys, mediaTags), + [submitComment], + ); + const handleChangeRequestSubmit = React.useCallback( + (content: string, mentionPubkeys: string[], mediaTags?: string[][]) => + submitComment(content, mentionPubkeys, mediaTags, "request-changes"), + [submitComment], + ); if (mode === "commits") { const commitCount = Math.max(1, pullRequest.updates.length + 1); @@ -536,8 +592,27 @@ function PullRequestDetail({ ); } + const reviewHistory = pullRequest.comments + .map((item) => ({ + item, + timelineKind: projectPullRequestCommentTimelineKind(item), + })) + .sort( + (left, right) => + left.item.createdAt - right.item.createdAt || + left.item.id.localeCompare(right.item.id), + ); + const reviewHistoryCollapsed = + expandedReviewHistoryBoxPullRequestId !== pullRequest.id; + const displayedReviewHistory = reviewHistoryCollapsed ? [] : reviewHistory; + const canRequestChanges = canReviewProjectPullRequest( + project, + pullRequest, + identityQuery.data?.pubkey, + ); + return ( -
+
{pullRequest.content ? (
0 ? ( -
+

Updates

{pullRequest.updates.map((update) => (
@@ -557,7 +632,11 @@ function PullRequestDetail({ + {relativeTime(update.createdAt)} + + } /> {update.commit ? ( - {pullRequest.comments.length > 0 ? ( -
- {pullRequest.comments.map((item) => { - // Review decisions and requests render as compact timeline - // rows (GitHub-style) rather than full comment cards. - const timelineKind = projectPullRequestCommentTimelineKind(item); - if (timelineKind) { - const isHistoricalDecision = - item.reviewDecisionStatus === "historical"; - return ( -
+
+ {reviewHistory.length > 0 ? ( + + ) : 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 === "approved" - ? isHistoricalDecision - ? "approved an earlier commit" - : "approved these changes" - : timelineKind === "changes-requested" + + {timelineKind ? ( + <> + {" "} + {timelineKind === "approved" ? isHistoricalDecision - ? "requested changes on an earlier commit" - : "requested changes" - : item.content.trim() || "requested a review"} - + ? "approved an earlier commit" + : "approved these changes" + : timelineKind === "changes-requested" + ? isHistoricalDecision + ? "requested changes on an earlier commit" + : "requested changes" + : trimmedContent || "requested a review"} + + ) : null} - - {compactDate(item.createdAt)} + + {relativeTime(item.createdAt)}
- ); - } - return ( -
+ {activityContent ? ( + + ) : null} {item.anchor ? ( -
- +
+ ) : null} -
- -
- -
- ); - })} +
+
+ ); + })} +
+
- ) : null} - +

Add Your Comment

- +
+ +
); @@ -693,6 +840,7 @@ export function PullRequestsPanel({ error, isLoading, mode = "conversation", + onOpenInlineComment, onOpenCommit, onOpenTerminal, onSelectedPullRequestIdChange, @@ -704,6 +852,7 @@ export function PullRequestsPanel({ error: unknown; isLoading: boolean; mode?: PullRequestPanelMode; + onOpenInlineComment?: (anchor: ProjectPullRequestCommentAnchor) => void; onOpenCommit?: (commitHash: string) => void; onOpenTerminal?: OpenMergeRecoveryTerminal; onSelectedPullRequestIdChange: (id: string | null) => void; @@ -746,6 +895,7 @@ export function PullRequestsPanel({ return ( = 1) { - return `${value} ${unit.label}${value === 1 ? "" : "s"} ago`; - } - } - - return "just now"; -} - function pluralize(count: number, singular: string) { return `${count} ${singular}${count === 1 ? "" : "s"}`; } @@ -852,7 +828,7 @@ export function RepositoryFilesPanel({ latestCommit.timestamp * 1_000, ).toISOString()} > - {relativeCommitTime(latestCommit.timestamp)} + {relativeTime(latestCommit.timestamp)}
) : ( @@ -905,7 +881,7 @@ export function RepositoryFilesPanel({ latestCommit.timestamp * 1_000, ).toISOString()} > - {relativeCommitTime(latestCommit.timestamp)} + {relativeTime(latestCommit.timestamp)} ) : ( "—" diff --git a/desktop/src/features/projects/ui/ProjectWorkspaceTabList.tsx b/desktop/src/features/projects/ui/ProjectWorkspaceTabList.tsx index e8eca37dec..152e76845f 100644 --- a/desktop/src/features/projects/ui/ProjectWorkspaceTabList.tsx +++ b/desktop/src/features/projects/ui/ProjectWorkspaceTabList.tsx @@ -5,7 +5,7 @@ import { cn } from "@/shared/lib/cn"; import { TabsList, TabsTrigger } from "@/shared/ui/tabs"; const PROJECT_TAB_TRIGGER_CLASS = - "relative h-full shrink-0 rounded-none px-2.5 text-base leading-5 tracking-tight text-muted-foreground shadow-none after:absolute after:inset-x-2.5 after:bottom-0 after:h-0.5 after:bg-current after:opacity-0 after:transition-opacity after:content-[''] hover:bg-transparent hover:font-semibold hover:text-foreground hover:after:opacity-100 data-[state=active]:bg-transparent data-[state=active]:font-semibold data-[state=active]:text-foreground data-[state=active]:shadow-none data-[state=active]:after:opacity-100"; + "relative h-full shrink-0 rounded-none px-2.5 text-base leading-5 tracking-tight text-muted-foreground shadow-none after:absolute after:inset-x-2.5 after:bottom-0 after:h-0.5 after:bg-current after:opacity-0 after:transition-opacity after:content-[''] hover:bg-transparent hover:text-foreground hover:after:opacity-100 data-[state=active]:bg-transparent data-[state=active]:font-semibold data-[state=active]:text-foreground data-[state=active]:shadow-none data-[state=active]:after:opacity-100"; const PROJECT_TAB_SELECTED_CLASS = "font-semibold text-foreground after:opacity-100"; diff --git a/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx b/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx index 8fdfba194b..4dc97635d4 100644 --- a/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx +++ b/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx @@ -12,6 +12,7 @@ import type { Project, ProjectLocalRepoSnapshot, ProjectPullRequest, + ProjectPullRequestCommentAnchor, ProjectRepoContributor, ProjectRepoDiff, ProjectRepoSnapshot, @@ -196,6 +197,11 @@ export function WorkspaceTabs({ ) ?? null; const isPullRequestSelected = Boolean(selectedPullRequest); const [selectedTab, setSelectedTab] = React.useState("overview"); + const [pullRequestCommentTarget, setPullRequestCommentTarget] = + React.useState<{ + anchor: ProjectPullRequestCommentAnchor; + pullRequestId: string; + } | null>(null); const [createIssueOpen, setCreateIssueOpen] = React.useState(false); const [createPullRequestOpen, setCreatePullRequestOpen] = React.useState(false); @@ -250,6 +256,17 @@ export function WorkspaceTabs({ onSelectedPullRequestIdChange, ], ); + const handleOpenPullRequestComment = React.useCallback( + (anchor: ProjectPullRequestCommentAnchor) => { + if (!selectedPullRequestId) return; + setPullRequestCommentTarget({ + anchor: { ...anchor }, + pullRequestId: selectedPullRequestId, + }); + setSelectedTab("pr-files"); + }, + [selectedPullRequestId], + ); return ( elapsedSeconds >= item.seconds) ?? - units[units.length - 1]; - const value = Math.max(1, Math.floor(elapsedSeconds / unit.seconds)); - return `${value} ${unit.label}${value === 1 ? "" : "s"} ago`; -} - function nextStepLabel(status: ProjectIssue["status"]) { if (status === "Done" || status === "Closed") return "View issue"; if (status === "In Review") return "Review issue"; @@ -86,10 +68,8 @@ function IssueHeader({

{project.name} - {includeDate - ? ` · created ${formatRelativeTime(issue.createdAt)}` - : null}{" "} - · by{" "} + {includeDate ? ` · created ${relativeTime(issue.createdAt)}` : null} · + by{" "} - ) : null} - {canRequestChanges ? ( - - ) : null} - {canMerge ? ( - - ) : null} - {canChangeStatus && isDraft ? ( - - ) : null} - - ) : null} + <> +

+ +
+ {canApprove ? ( + + ) : null} + {canMerge ? ( + + ) : null} + {canMarkReady ? ( + + ) : null} + {canReopen ? ( + + ) : null} + {hasOverflowAction ? ( + + + + + + {canConvertToDraft ? ( + { + void handleStatusChange("draft"); + }} + > + + Convert to draft + + ) : null} + {canClose ? ( + { + void handleStatusChange("closed"); + }} + > + + Close pull request + + ) : null} + + + ) : null} +
- {showDraftControl ? ( -

- Still in progress?{" "} - -

- ) : null} - + { + setApproveDialogOpen(open); + if (!open && !isApproving) setApprovalSummary(""); + }} + open={approveDialogOpen} + > + + + Approve pull request + + Add an optional summary for the author and other reviewers. + + +