diff --git a/crates/buzz-cli/src/links.rs b/crates/buzz-cli/src/links.rs index 7d512710d4..1552d98b35 100644 --- a/crates/buzz-cli/src/links.rs +++ b/crates/buzz-cli/src/links.rs @@ -38,6 +38,11 @@ pub fn repo_link(owner: &str, repo_id: &str) -> String { format!("buzz://repo?owner={owner}&d={repo_id}") } +/// Build a link to a specific git commit in a repository. +pub fn commit_link(commit_hash: &str, owner: &str, repo_id: &str) -> String { + format!("buzz://repo?owner={owner}&d={repo_id}&tab=commits&commit={commit_hash}") +} + /// Build a `buzz://project` link for a project announcement (kind 30621). pub fn project_link(owner: &str, project_id: &str) -> String { format!("buzz://project?owner={owner}&d={project_id}") @@ -85,6 +90,10 @@ mod tests { project_link(owner, dtag), golden["links"]["project"].as_str().unwrap() ); + assert_eq!( + commit_link(event_id, owner, dtag), + golden["links"]["commit"].as_str().unwrap() + ); } #[test] diff --git a/desktop/src-tauri/src/deep_link.rs b/desktop/src-tauri/src/deep_link.rs index 9b4a01b1c7..83ac7e59ff 100644 --- a/desktop/src-tauri/src/deep_link.rs +++ b/desktop/src-tauri/src/deep_link.rs @@ -372,6 +372,10 @@ fn is_hex64(value: &str) -> bool { value.len() == 64 && value.chars().all(|c| c.is_ascii_hexdigit()) } +fn is_git_object_id(value: &str) -> bool { + matches!(value.len(), 40 | 64) && value.chars().all(|c| c.is_ascii_hexdigit()) +} + /// Mirrors `isValidDtag` in `entityLink.ts` — the link format addresses a /// narrower d-tag charset than Nostr allows. fn is_linkable_dtag(value: &str) -> bool { @@ -416,13 +420,14 @@ fn parse_entity_deep_link(url: &Url) -> Option<()> { let needs_event_id = host == "pr" || host == "issue"; let allows_tab = host == "repo" || host == "project"; - let (mut owner, mut dtag, mut id, mut tab) = (None, None, None, None); + let (mut owner, mut dtag, mut id, mut tab, mut commit) = (None, None, None, None, None); for (key, value) in url.query_pairs() { let slot = match key.as_ref() { "owner" => &mut owner, "d" => &mut dtag, "id" if needs_event_id => &mut id, "tab" if allows_tab => &mut tab, + "commit" if host == "repo" => &mut commit, _ => return None, }; if slot.is_some() { @@ -440,8 +445,13 @@ fn parse_entity_deep_link(url: &Url) -> Option<()> { if needs_event_id && !id.is_some_and(|id| is_hex64(&id)) { return None; } - if let Some(tab) = tab { - if !ENTITY_LINK_TABS.contains(&tab.as_str()) { + if let Some(tab) = tab.as_deref() { + if !ENTITY_LINK_TABS.contains(&tab) { + return None; + } + } + if let Some(commit) = commit { + if tab.as_deref() != Some("commits") || !is_git_object_id(&commit) { return None; } } diff --git a/desktop/src-tauri/src/deep_link_tests.rs b/desktop/src-tauri/src/deep_link_tests.rs index eaddbb7a4c..84a08c4c64 100644 --- a/desktop/src-tauri/src/deep_link_tests.rs +++ b/desktop/src-tauri/src/deep_link_tests.rs @@ -34,6 +34,11 @@ fn parse_entity_deep_link_accepts_every_share_link_shape() { "{raw}" ); } + let commit_link = format!( + "buzz://repo?owner={owner}&d={dtag}&tab=commits&commit={}", + golden["eventId"].as_str().unwrap() + ); + assert!(parse_entity_deep_link(&Url::parse(&commit_link).unwrap()).is_some()); let expected_tabs = golden["tabs"] .as_array() .unwrap() @@ -64,6 +69,8 @@ fn parse_entity_deep_link_rejects_malformed_and_non_canonical_links() { // Unknown tab value, duplicate tab, and tab on an event link. format!("buzz://repo?owner={owner}&d=buzz-world&tab=overview"), format!("buzz://repo?owner={owner}&d=buzz-world&tab=prs&tab=prs"), + format!("buzz://repo?owner={owner}&d=buzz-world&tab=files&commit={event_id}"), + format!("buzz://repo?owner={owner}&d=buzz-world&tab=commits&commit=short"), format!("buzz://pr?id={event_id}&owner={owner}&d=buzz-world&tab=prs"), format!("buzz://repo/extra?owner={owner}&d=buzz-world"), format!("buzz://repo?owner={owner}&d=buzz-world#top"), diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 6257a75b72..10c37d84e9 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -8,6 +8,10 @@ import { AppShellOverlays, TerminalBootstrap } from "@/app/AppShellOverlays"; import { AppShellChannelSurface } from "@/app/AppShellChannelSurface"; import { AppHuddleShell } from "@/app/AppHuddleShell"; import { AppTopChrome } from "@/app/AppTopChrome"; +import { + type TerminalContextOverride, + TerminalContextOverrideProvider, +} from "@/app/TerminalContextOverrideContext"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useBackForwardControls } from "@/app/navigation/useBackForwardControls"; import { useCommunityNavigationTransitions } from "@/app/useCommunityNavigationTransitions"; @@ -312,6 +316,8 @@ export function AppShell() { selectedView, sidebarChannels, ]); + const [terminalContextOverride, setTerminalContextOverride] = + React.useState(null); const { activeChannel, terminalContext } = useTerminalContext({ channelId: selectedChannelId, channels, @@ -319,6 +325,14 @@ export function AppShell() { pubkey: identityQuery.data?.pubkey, relayUrl: communitiesHook.activeCommunity?.relayUrl, }); + const effectiveTerminalContext = terminalContextOverride + ? { + ...terminalContext, + channelId: terminalContextOverride.channelId, + channelName: terminalContextOverride.channelName, + threadId: null, + } + : terminalContext; const managedChannel = React.useMemo(() => { const targetChannelId = managedChannelId ?? selectedChannelId; return targetChannelId @@ -893,14 +907,20 @@ export function AppShell() { onUnstarChannel={unstarChannel} /> ) : null} - } + - - + + } + > + + + {!isHuddleRoom ? ( - {sidebar?.open ? : } + Toggle Sidebar ); @@ -131,6 +127,11 @@ export function AppTopChrome({ +
); } diff --git a/desktop/src/app/AppTopChromePortal.tsx b/desktop/src/app/AppTopChromePortal.tsx new file mode 100644 index 0000000000..1bda5741f3 --- /dev/null +++ b/desktop/src/app/AppTopChromePortal.tsx @@ -0,0 +1,18 @@ +import * as React from "react"; +import { createPortal } from "react-dom"; + +const APP_TOP_CHROME_CONTENT_ID = "app-top-chrome-content"; + +export function AppTopChromePortal({ + children, +}: { + children: React.ReactNode; +}) { + const [target, setTarget] = React.useState(null); + + React.useEffect(() => { + setTarget(document.getElementById(APP_TOP_CHROME_CONTENT_ID)); + }, []); + + return target ? createPortal(children, target) : null; +} diff --git a/desktop/src/app/TerminalContextOverrideContext.tsx b/desktop/src/app/TerminalContextOverrideContext.tsx new file mode 100644 index 0000000000..85a58c37a0 --- /dev/null +++ b/desktop/src/app/TerminalContextOverrideContext.tsx @@ -0,0 +1,38 @@ +import * as React from "react"; + +export type TerminalContextOverride = { + channelId: string; + channelName: string; +}; + +const TerminalContextOverrideContext = React.createContext +> | null>(null); + +export function TerminalContextOverrideProvider({ + children, + onChange, +}: { + children: React.ReactNode; + onChange: React.Dispatch< + React.SetStateAction + >; +}) { + return ( + + {children} + + ); +} + +export function useTerminalContextOverride( + context: TerminalContextOverride | null, +) { + const setOverride = React.useContext(TerminalContextOverrideContext); + + React.useEffect(() => { + if (!setOverride) return; + setOverride(context); + return () => setOverride(null); + }, [context, setOverride]); +} diff --git a/desktop/src/features/projects/lib/projectAgentConversation.test.mjs b/desktop/src/features/projects/lib/projectAgentConversation.test.mjs index 31e7fce0f4..578376c8a6 100644 --- a/desktop/src/features/projects/lib/projectAgentConversation.test.mjs +++ b/desktop/src/features/projects/lib/projectAgentConversation.test.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { beforeEach, test } from "node:test"; import { + mergeProjectAgentConversationEvents, restoreProjectsAgentConversation, visibleConversationMessages, } from "./projectAgentConversation.ts"; @@ -121,6 +122,25 @@ test("messages the DM held before the first Projects prompt never appear", () => assert.deepEqual(visible, [opener, reply]); }); +test("root questions and separately queried replies stay in conversation order", () => { + const firstQuestion = message(PROMPT_AT); + const firstAnswer = message(PROMPT_AT + 2, KIND_STREAM_MESSAGE_V2); + const secondQuestion = message(PROMPT_AT + 4); + const secondAnswer = message(PROMPT_AT + 6, KIND_STREAM_MESSAGE_V2); + + const merged = mergeProjectAgentConversationEvents( + [firstQuestion, secondQuestion], + [firstAnswer, secondAnswer, firstAnswer], + ); + + assert.deepEqual(merged, [ + firstQuestion, + firstAnswer, + secondQuestion, + secondAnswer, + ]); +}); + test("storage read rejects legacy pointers with a zero cutoff", () => { globalThis.localStorage.setItem( `buzz.projects.agentConversation.${encodeURIComponent(WORKSPACE_ID)}`, diff --git a/desktop/src/features/projects/lib/projectAgentConversation.ts b/desktop/src/features/projects/lib/projectAgentConversation.ts index d7f04933bf..370ad626f6 100644 --- a/desktop/src/features/projects/lib/projectAgentConversation.ts +++ b/desktop/src/features/projects/lib/projectAgentConversation.ts @@ -54,3 +54,18 @@ export function visibleConversationMessages< ) .sort((left, right) => left.created_at - right.created_at); } + +/** + * Combines the backing DM's root events with separately queried thread + * replies. Keep this chronological: appending the reply query after the root + * query would otherwise render every question before every agent answer. + */ +export function mergeProjectAgentConversationEvents< + Event extends { id: string; created_at: number }, +>(rootEvents: readonly Event[], replyEvents: readonly Event[]): Event[] { + return [ + ...new Map( + [...rootEvents, ...replyEvents].map((event) => [event.id, event]), + ).values(), + ].sort((left, right) => left.created_at - right.created_at); +} diff --git a/desktop/src/features/projects/lib/projectContributorMatching.test.mjs b/desktop/src/features/projects/lib/projectContributorMatching.test.mjs index 59877f8251..29aa1faf8d 100644 --- a/desktop/src/features/projects/lib/projectContributorMatching.test.mjs +++ b/desktop/src/features/projects/lib/projectContributorMatching.test.mjs @@ -3,8 +3,12 @@ import { test } from "node:test"; import { commitAuthorPubkeysFromPullRequests, + gitContributorPubkeysFromCommits, profileForCommit, + projectContributorActivityCounts, + signedProjectContributorPubkeys, } from "./projectContributorMatching.ts"; +import { pubkeyToNpub } from "../../../shared/lib/nostrUtils.ts"; const AGENT_PUBKEY = "a".repeat(64); const USER_PUBKEY = "b".repeat(64); @@ -34,6 +38,71 @@ function makeCommit(overrides = {}) { }; } +test("collects declared humans and signed agent activity without assignments", () => { + const reviewer = "c".repeat(64); + const commenters = ["d".repeat(64), "e".repeat(64)]; + const contributors = signedProjectContributorPubkeys({ + owner: USER_PUBKEY, + contributors: [AGENT_PUBKEY], + pullRequests: [ + { + author: AGENT_PUBKEY, + approvals: [{ author: reviewer }], + comments: [{ author: commenters[0] }], + updates: [{ author: USER_PUBKEY, commit: null }], + }, + ], + issues: [ + { + author: USER_PUBKEY, + comments: [{ author: commenters[1] }], + }, + ], + }); + + assert.deepEqual(contributors, [ + USER_PUBKEY, + AGENT_PUBKEY, + commenters[0], + reviewer, + commenters[1], + ]); +}); + +test("counts signed commits, reviews, and tasks by contributor", () => { + const counts = projectContributorActivityCounts({ + owner: USER_PUBKEY, + contributors: [AGENT_PUBKEY], + pullRequests: [ + { + author: AGENT_PUBKEY, + initialCommit: "1".repeat(40), + commit: "2".repeat(40), + approvals: [], + comments: [], + updates: [{ author: USER_PUBKEY, commit: "2".repeat(40) }], + }, + ], + issues: [ + { + author: USER_PUBKEY, + comments: [], + }, + ], + }); + + assert.deepEqual(counts[AGENT_PUBKEY], { + commits: 1, + reviews: 1, + tasks: 0, + }); + assert.deepEqual(counts[USER_PUBKEY], { + commits: 1, + reviews: 0, + tasks: 1, + }); +}); + test("maps initial, latest, and update commits to their publishers", () => { const map = commitAuthorPubkeysFromPullRequests([ { @@ -62,6 +131,55 @@ test("maps initial, latest, and update commits to their publishers", () => { ); }); +test("links Git contributors through commit hashes from signed reviews", () => { + const commit = makeCommit({ authorEmail: "wes@example.com" }); + const linked = gitContributorPubkeysFromCommits( + [commit], + [ + { + author: USER_PUBKEY, + initialCommit: commit.hash, + commit: commit.hash, + updates: [], + }, + ], + ); + + assert.equal(linked.get("wes@example.com"), USER_PUBKEY); +}); + +test("does not link a Git identity claimed by multiple signed publishers", () => { + const first = makeCommit({ + hash: "1".repeat(40), + shortHash: "1".repeat(7), + authorEmail: "shared@example.com", + }); + const second = makeCommit({ + hash: "2".repeat(40), + shortHash: "2".repeat(7), + authorEmail: "shared@example.com", + }); + const linked = gitContributorPubkeysFromCommits( + [first, second], + [ + { + author: USER_PUBKEY, + initialCommit: first.hash, + commit: first.hash, + updates: [], + }, + { + author: AGENT_PUBKEY, + initialCommit: second.hash, + commit: second.hash, + updates: [], + }, + ], + ); + + assert.equal(linked.has("shared@example.com"), false); +}); + test("profileForCommit prefers the signed PR-event mapping", () => { const commit = makeCommit(); const map = new Map([[commit.hash, AGENT_PUBKEY]]); @@ -78,6 +196,21 @@ test("profileForCommit falls back to exact git author matching", () => { assert.equal(matched?.pubkey, USER_PUBKEY); }); +test("profileForCommit resolves an npub git author to its profile", () => { + const commit = makeCommit({ authorName: pubkeyToNpub(USER_PUBKEY) }); + const matched = profileForCommit(commit, PROFILES, new Map()); + assert.equal(matched?.pubkey, USER_PUBKEY); + assert.equal(matched?.profile.displayName, "Thomas P"); +}); + +test("profileForCommit ignores malformed npub git authors", () => { + const commit = makeCommit({ + authorName: "npub1not-a-valid-key", + authorEmail: "unknown@example.com", + }); + assert.equal(profileForCommit(commit, PROFILES, new Map()), null); +}); + test("profileForCommit returns null when nothing matches", () => { const commit = makeCommit(); assert.equal(profileForCommit(commit, PROFILES, new Map()), null); diff --git a/desktop/src/features/projects/lib/projectContributorMatching.ts b/desktop/src/features/projects/lib/projectContributorMatching.ts index 15199b7e6e..c1abfe4835 100644 --- a/desktop/src/features/projects/lib/projectContributorMatching.ts +++ b/desktop/src/features/projects/lib/projectContributorMatching.ts @@ -3,6 +3,104 @@ import type { ProjectRepoCommit, ProjectRepoContributor, } from "@/shared/api/types"; +import { parsePubkeyInput } from "@/shared/lib/nostrUtils"; + +type SignedProjectContributorSources = { + owner: string; + contributors: readonly string[]; + pullRequests: readonly { + author: string; + initialCommit?: string | null; + commit?: string | null; + comments: readonly { author: string }[]; + approvals: readonly { author: string }[]; + updates: readonly { author: string; commit: string | null }[]; + }[]; + issues: readonly { + author: string; + comments: readonly { author: string }[]; + }[]; +}; + +export type ProjectContributorActivityCounts = { + commits: number; + reviews: number; + tasks: number; +}; + +/** + * Collects identities tied to a repository by its announcement or by signed + * project activity. Assignments and recipients are intentionally excluded: + * being asked to review or own a task is not itself a contribution. + */ +export function signedProjectContributorPubkeys({ + owner, + contributors, + pullRequests, + issues, +}: SignedProjectContributorSources): string[] { + return [ + ...new Set( + [ + owner, + ...contributors, + ...pullRequests.flatMap((pullRequest) => [ + pullRequest.author, + ...pullRequest.comments.map((comment) => comment.author), + ...pullRequest.approvals.map((approval) => approval.author), + ...pullRequest.updates.map((update) => update.author), + ]), + ...issues.flatMap((issue) => [ + issue.author, + ...issue.comments.map((comment) => comment.author), + ]), + ] + .filter(Boolean) + .map((pubkey) => pubkey.trim().toLowerCase()), + ), + ]; +} + +/** Counts signed repository activity by actor for contributor list columns. */ +export function projectContributorActivityCounts({ + owner, + contributors, + pullRequests, + issues, +}: SignedProjectContributorSources): Record< + string, + ProjectContributorActivityCounts +> { + const counts = Object.fromEntries( + signedProjectContributorPubkeys({ + owner, + contributors, + pullRequests, + issues, + }).map((pubkey) => [pubkey, { commits: 0, reviews: 0, tasks: 0 }]), + ); + const ensureCounts = (rawPubkey: string) => { + const pubkey = rawPubkey.trim().toLowerCase(); + const existing = counts[pubkey]; + if (existing) return existing; + const created = { commits: 0, reviews: 0, tasks: 0 }; + counts[pubkey] = created; + return created; + }; + + for (const pubkey of commitAuthorPubkeysFromPullRequests( + pullRequests, + ).values()) { + ensureCounts(pubkey).commits += 1; + } + for (const pullRequest of pullRequests) { + ensureCounts(pullRequest.author).reviews += 1; + } + for (const issue of issues) { + ensureCounts(issue.author).tasks += 1; + } + return counts; +} export function contributorKey(contributor: ProjectRepoContributor) { return (contributor.email || contributor.name).trim().toLowerCase(); @@ -55,6 +153,16 @@ export function profileForCommitAuthor( profiles: UserProfileLookup | undefined, ) { if (!profiles) return null; + // A Git author may use their Nostr key as the name/email. This is still an + // unauthenticated display hint—the signed PR mapping remains authoritative. + const claimedPubkey = + parsePubkeyInput(commit.authorName) ?? parsePubkeyInput(commit.authorEmail); + if (claimedPubkey) { + const profile = profiles[claimedPubkey]; + if (profile) { + return { pubkey: claimedPubkey, profile }; + } + } const contributor = { name: commit.authorName, email: commit.authorEmail, @@ -100,6 +208,39 @@ export function commitAuthorPubkeysFromPullRequests( return byHash; } +/** + * Links aggregate Git author rows to Buzz identities through commit hashes + * published in signed review events. Ambiguous author strings fail closed. + */ +export function gitContributorPubkeysFromCommits( + commits: readonly ProjectRepoCommit[], + pullRequests: Parameters[0], +): Map { + const pubkeysByHash = commitAuthorPubkeysFromPullRequests(pullRequests); + const candidatesByContributor = new Map>(); + for (const commit of commits) { + const pubkey = + pubkeysByHash.get(commit.hash.toLowerCase()) ?? + pubkeysByHash.get(commit.shortHash.toLowerCase()); + if (!pubkey) continue; + const key = contributorKey({ + commitCount: 0, + email: commit.authorEmail, + lastCommitAt: commit.timestamp, + name: commit.authorName, + }); + const candidates = candidatesByContributor.get(key) ?? new Set(); + candidates.add(pubkey.toLowerCase()); + candidatesByContributor.set(key, candidates); + } + + return new Map( + [...candidatesByContributor.entries()] + .filter(([, pubkeys]) => pubkeys.size === 1) + .map(([key, pubkeys]) => [key, [...pubkeys][0]]), + ); +} + /** * The viewer's own git identity (`git config user.name/user.email`) paired * with their Nostr pubkey. Only used to attribute the viewer's own commits — diff --git a/desktop/src/features/projects/lib/projectDetailAgentContext.test.mjs b/desktop/src/features/projects/lib/projectDetailAgentContext.test.mjs new file mode 100644 index 0000000000..eca4fc09a6 --- /dev/null +++ b/desktop/src/features/projects/lib/projectDetailAgentContext.test.mjs @@ -0,0 +1,63 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildProjectDetailAgentContext, + projectDetailAgentContextBlock, + stripProjectDetailAgentContext, +} from "./projectDetailAgentContext.ts"; + +const base = { + activeTab: "files", + branch: "main", + file: { kind: "file", path: "src/app.tsx" }, + project: { name: "Buzz Patrol" }, + repository: { name: "Buzz", repoAddress: "owner:buzz" }, + source: "local", + workItems: [null, null, null], +}; + +test("builds selected file context", () => { + const context = buildProjectDetailAgentContext(base); + assert.equal(context.view, "Files"); + assert.deepEqual(context.file, { kind: "file", path: "src/app.tsx" }); + assert.equal(context.workItem, null); +}); + +test("review detail takes precedence over its workspace tab", () => { + const context = buildProjectDetailAgentContext({ + ...base, + activeTab: "prs", + workItems: [ + null, + null, + { id: "review-42", status: "Open", title: "Ship the fix" }, + ], + }); + assert.equal(context.view, "Review detail"); + assert.deepEqual(context.workItem, { + id: "review-42", + kind: "review", + status: "Open", + title: "Ship the fix", + }); + assert.equal(context.file, null); +}); + +test("prompt footer contains current page details", () => { + const footer = projectDetailAgentContextBlock( + buildProjectDetailAgentContext(base), + ); + assert.match(footer, /Current Buzz project page:/); + assert.match(footer, /Repository: Buzz \(owner:buzz\)/); + assert.match(footer, /View: Files/); + assert.match(footer, /File: src\/app\.tsx/); + assert.match(footer, /Branch: main/); +}); + +test("strips hidden page context from the displayed user message", () => { + const content = `Explain this file${projectDetailAgentContextBlock( + buildProjectDetailAgentContext(base), + )}`; + assert.equal(stripProjectDetailAgentContext(content), "Explain this file"); +}); diff --git a/desktop/src/features/projects/lib/projectDetailAgentContext.ts b/desktop/src/features/projects/lib/projectDetailAgentContext.ts new file mode 100644 index 0000000000..5b2000dccc --- /dev/null +++ b/desktop/src/features/projects/lib/projectDetailAgentContext.ts @@ -0,0 +1,121 @@ +const PROJECT_PAGE_CONTEXT_MARKER = "Current Buzz project page:"; + +export type ProjectDetailAgentContext = { + branch?: string | null; + file?: { kind: "file" | "folder"; path: string } | null; + projectName: string; + repoAddress: string; + repositoryName: string; + source: "local" | "remote"; + view: string; + workItem?: { + id: string; + kind: "commit" | "review" | "task"; + status?: string; + title: string; + } | null; +}; + +export function buildProjectDetailAgentContext({ + activeTab, + branch, + file, + project, + repository, + source, + workItems, +}: { + activeTab: string; + branch?: string | null; + file?: ProjectDetailAgentContext["file"]; + project: { name: string }; + repository: { name: string; repoAddress: string }; + source: "local" | "remote"; + workItems: readonly [ + { hash: string; subject?: string | null } | null, + { id: string; status?: string; title: string } | null, + { id: string; status?: string; title: string } | null, + ]; +}): ProjectDetailAgentContext { + const [commit, issue, pullRequest] = workItems; + const viewLabels: Record = { + activity: "Commits", + channels: "Channels", + contributors: "Contributors", + files: "Files", + issues: "Tasks", + overview: "Overview", + prs: "Reviews", + }; + const workItem = pullRequest + ? { + id: pullRequest.id, + kind: "review" as const, + status: pullRequest.status, + title: pullRequest.title, + } + : issue + ? { + id: issue.id, + kind: "task" as const, + status: issue.status, + title: issue.title, + } + : commit + ? { + id: commit.hash, + kind: "commit" as const, + title: commit.subject || commit.hash.slice(0, 7), + } + : null; + return { + branch, + file: activeTab === "files" ? file : null, + projectName: project.name, + repoAddress: repository.repoAddress, + repositoryName: repository.name, + source, + view: workItem + ? `${workItem.kind[0]?.toUpperCase()}${workItem.kind.slice(1)} detail` + : (viewLabels[activeTab] ?? activeTab), + workItem, + }; +} + +export function projectDetailAgentContextBlock( + context: ProjectDetailAgentContext, +) { + const lines = [ + "", + "---", + PROJECT_PAGE_CONTEXT_MARKER, + `- Project: ${context.projectName}`, + `- Repository: ${context.repositoryName} (${context.repoAddress})`, + `- View: ${context.view}`, + `- Source: ${context.source}`, + ]; + if (context.branch) lines.push(`- Branch: ${context.branch}`); + if (context.file) { + lines.push( + `- ${context.file.kind === "file" ? "File" : "Folder"}: ${context.file.path || "/"}`, + ); + } + if (context.workItem) { + lines.push( + `- ${context.workItem.kind}: ${context.workItem.title} (${context.workItem.id})`, + ); + if (context.workItem.status) { + lines.push(`- Status: ${context.workItem.status}`); + } + } + lines.push( + "Use this current UI context to interpret the user's request. Do not claim access to data not supplied here or available through your tools.", + ); + return lines.join("\n"); +} + +export function stripProjectDetailAgentContext(content: string) { + const markerIndex = content.indexOf(`---\n${PROJECT_PAGE_CONTEXT_MARKER}`); + if (markerIndex === -1) return content; + return content.slice(0, markerIndex).replace(/\n+$/, ""); +} diff --git a/desktop/src/features/projects/lib/projectGitError.test.mjs b/desktop/src/features/projects/lib/projectGitError.test.mjs index cc691bb0d7..0cef49042e 100644 --- a/desktop/src/features/projects/lib/projectGitError.test.mjs +++ b/desktop/src/features/projects/lib/projectGitError.test.mjs @@ -24,6 +24,18 @@ test("presents missing and network failures clearly", () => { projectCloneErrorPresentation(new Error("Repository not found")).title, "Repository not found", ); + assert.deepEqual( + projectCloneErrorPresentation( + new Error("Repository not found"), + "https://relay.example/git/owner/repo", + "access", + ), + { + title: "Repository access restricted", + description: + "You need access to the repository’s channel before you can clone it.", + }, + ); assert.equal( projectCloneErrorPresentation(new Error("Could not resolve host")).title, "Couldn’t reach the repository", diff --git a/desktop/src/features/projects/lib/projectGitError.ts b/desktop/src/features/projects/lib/projectGitError.ts index b99933f1e7..0d1377a057 100644 --- a/desktop/src/features/projects/lib/projectGitError.ts +++ b/desktop/src/features/projects/lib/projectGitError.ts @@ -1,3 +1,5 @@ +import type { ProjectRepoUnavailableReason } from "./projectRepoAvailability"; + export type ProjectGitErrorPresentation = { title: string; description: string; @@ -19,10 +21,18 @@ function isGitHubUrl(cloneUrl: string | null | undefined) { export function projectCloneErrorPresentation( error: unknown, cloneUrl?: string | null, + unavailableReason?: ProjectRepoUnavailableReason, ): ProjectGitErrorPresentation { const message = errorText(error); const github = isGitHubUrl(cloneUrl); + if (unavailableReason === "access") { + return { + title: "Repository access restricted", + description: + "You need access to the repository’s channel before you can clone it.", + }; + } if ( /\b(?:401|403)\b|authenticat|authoriz|permission denied|access denied|ssh certificate/.test( message, diff --git a/desktop/src/features/projects/lib/projectRepoAvailability.test.mjs b/desktop/src/features/projects/lib/projectRepoAvailability.test.mjs index 7c9e6834bc..aa9661dde8 100644 --- a/desktop/src/features/projects/lib/projectRepoAvailability.test.mjs +++ b/desktop/src/features/projects/lib/projectRepoAvailability.test.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { + projectRepoUnavailablePresentation, projectRepoUnavailableReason, refineRepoUnavailableReason, } from "./projectRepoAvailability.ts"; @@ -105,3 +106,11 @@ test("never rewrites non-missing reasons", () => { "network", ); }); + +test("presents access failures without exposing the relay's masked 404", () => { + assert.deepEqual(projectRepoUnavailablePresentation("access"), { + description: + "Repository access is granted through its channel, and you’re not a member. Ask the repository owner for an invite.", + title: "Repository access restricted", + }); +}); diff --git a/desktop/src/features/projects/lib/projectRepoAvailability.ts b/desktop/src/features/projects/lib/projectRepoAvailability.ts index ce1b520cf0..d7911a0827 100644 --- a/desktop/src/features/projects/lib/projectRepoAvailability.ts +++ b/desktop/src/features/projects/lib/projectRepoAvailability.ts @@ -7,6 +7,60 @@ export type ProjectRepoUnavailableReason = | "ref" | "unknown"; +/** User-facing copy for a classified repository availability failure. */ +export type ProjectRepoUnavailablePresentation = { + description: string; + title: string; +}; + +const PROJECT_REPO_UNAVAILABLE_PRESENTATIONS: Record< + ProjectRepoUnavailableReason, + ProjectRepoUnavailablePresentation +> = { + authentication: { + description: + "Buzz could not authenticate with this repository. Check your access and try again.", + title: "Repository access failed", + }, + missing: { + description: + "The project announcement exists, but its git repository was not found on the Buzz relay.", + title: "Repository not initialized", + }, + access: { + description: + "Repository access is granted through its channel, and you’re not a member. Ask the repository owner for an invite.", + title: "Repository access restricted", + }, + unbound: { + description: + "This repository has no access channel binding, so the relay cannot authorize anyone to read it. The repository owner can bind a channel from the Access menu.", + title: "No access channel bound", + }, + network: { + description: + "The Buzz git service could not be reached. Check your connection and try again.", + title: "Couldn’t reach repository", + }, + ref: { + description: + "The selected branch is advertised by the project but is missing from its git remote.", + title: "Branch unavailable", + }, + unknown: { + description: + "Buzz could not load this repository. Try again or contact the project owner.", + title: "Repository unavailable", + }, +}; + +/** Returns consistent, sanitized copy for repository availability UI. */ +export function projectRepoUnavailablePresentation( + reason: ProjectRepoUnavailableReason, +): ProjectRepoUnavailablePresentation { + return PROJECT_REPO_UNAVAILABLE_PRESENTATIONS[reason]; +} + export function projectRepoUnavailableReason( error: unknown, ): ProjectRepoUnavailableReason { diff --git a/desktop/src/features/projects/lib/projectShareLinks.ts b/desktop/src/features/projects/lib/projectShareLinks.ts index 7598539f00..214beabe42 100644 --- a/desktop/src/features/projects/lib/projectShareLinks.ts +++ b/desktop/src/features/projects/lib/projectShareLinks.ts @@ -14,6 +14,7 @@ import { KIND_REPO_ANNOUNCEMENT, } from "@/shared/constants/kinds"; import { + buildCommitLink, buildIssueLink, buildProjectLink, buildPullRequestLink, @@ -29,6 +30,7 @@ import type { ProjectPullRequest } from "../projectPullRequests.mjs"; type Coordinate = { kind: number; owner: string; dtag: string }; const HEX64_RE = /^[a-fA-F0-9]{64}$/; +const GIT_OBJECT_ID_RE = /^(?:[a-fA-F0-9]{40}|[a-fA-F0-9]{64})$/; /** * Split an addressable coordinate (`::`). Only the first two @@ -118,6 +120,18 @@ export function repositoryShareLink(repository: Repository): string | null { : null; } +export function commitShareLink( + repository: Repository, + commitHash: string, +): string | null { + const coordinate = repositoryCoordinate(repository.repoAddress); + return coordinate && + GIT_OBJECT_ID_RE.test(commitHash) && + isLinkableCoordinate(coordinate.owner, coordinate.dtag) + ? buildCommitLink({ ...coordinate, commitHash }) + : null; +} + export function issueShareLink(issue: ProjectIssue): string | null { const coordinate = repositoryCoordinate(issue.repoAddress); return coordinate && diff --git a/desktop/src/features/projects/lib/projectSidebarMembership.ts b/desktop/src/features/projects/lib/projectSidebarMembership.ts new file mode 100644 index 0000000000..9d50f8fe13 --- /dev/null +++ b/desktop/src/features/projects/lib/projectSidebarMembership.ts @@ -0,0 +1,77 @@ +const PROJECT_SIDEBAR_MEMBERSHIP_PREFIX = "buzz.sidebar.projects.membership.v1"; +export const PROJECT_SIDEBAR_MEMBERSHIP_EVENT = + "buzz:project-sidebar-membership-change"; + +function membershipKey(relayOrigin: string, pubkey: string) { + return `${PROJECT_SIDEBAR_MEMBERSHIP_PREFIX}.${encodeURIComponent(relayOrigin)}.${pubkey.toLowerCase()}`; +} + +export function readProjectSidebarMembership( + relayOrigin: string | null | undefined, + pubkey: string | null | undefined, +): string[] { + if (!relayOrigin || !pubkey) return []; + try { + const parsed = JSON.parse( + globalThis.localStorage?.getItem(membershipKey(relayOrigin, pubkey)) ?? + "[]", + ); + return Array.isArray(parsed) + ? [ + ...new Set( + parsed.filter( + (address): address is string => + typeof address === "string" && address.length > 0, + ), + ), + ] + : []; + } catch { + return []; + } +} + +function writeProjectSidebarMembership( + relayOrigin: string, + pubkey: string, + addresses: readonly string[], +) { + try { + globalThis.localStorage?.setItem( + membershipKey(relayOrigin, pubkey), + JSON.stringify([...new Set(addresses)]), + ); + globalThis.dispatchEvent?.( + new CustomEvent(PROJECT_SIDEBAR_MEMBERSHIP_EVENT), + ); + } catch { + // Persistence is best-effort; callers still update their local view. + } +} + +export function addProjectToSidebar( + projectAddress: string, + relayOrigin: string | null | undefined, + pubkey: string | null | undefined, +) { + if (!relayOrigin || !pubkey) return; + writeProjectSidebarMembership(relayOrigin, pubkey, [ + ...readProjectSidebarMembership(relayOrigin, pubkey), + projectAddress, + ]); +} + +export function removeProjectFromSidebar( + projectAddress: string, + relayOrigin: string | null | undefined, + pubkey: string | null | undefined, +) { + if (!relayOrigin || !pubkey) return; + writeProjectSidebarMembership( + relayOrigin, + pubkey, + readProjectSidebarMembership(relayOrigin, pubkey).filter( + (address) => address !== projectAddress, + ), + ); +} diff --git a/desktop/src/features/projects/ui/CreateProjectDialog.tsx b/desktop/src/features/projects/ui/CreateProjectDialog.tsx index ff214d8084..d3ac4dfe84 100644 --- a/desktop/src/features/projects/ui/CreateProjectDialog.tsx +++ b/desktop/src/features/projects/ui/CreateProjectDialog.tsx @@ -1,20 +1,6 @@ -import * as React from "react"; - -import { useChannelsQuery } from "@/features/channels/hooks"; import type { CreateProjectInput } from "@/features/projects/useCreateProject"; -import { cn } from "@/shared/lib/cn"; -import { Button } from "@/shared/ui/button"; -import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content"; import { Dialog } from "@/shared/ui/dialog"; -import { Input } from "@/shared/ui/input"; -import { Textarea } from "@/shared/ui/textarea"; - -const CREATE_FIELD_SHELL_CLASS = - "rounded-xl border border-input bg-muted/40 transition-colors duration-150 ease-out hover:border-muted-foreground/40 focus-within:border-muted-foreground/50"; -const CREATE_FIELD_CONTROL_CLASS = - "border-0 bg-transparent text-muted-foreground/55 shadow-none outline-none ring-0 transition-colors duration-150 ease-out placeholder:text-muted-foreground/55 focus:bg-transparent focus:text-foreground focus:outline-hidden focus-visible:ring-0"; -const CREATE_LABEL_OPTIONAL_CLASS = - "ml-1 text-xs font-normal text-muted-foreground/50"; +import { CreateProjectFormContent } from "./CreateProjectFormContent"; type CreateProjectDialogProps = { isCreating: boolean; @@ -30,67 +16,6 @@ export function CreateProjectDialog({ onOpenChange, open, }: CreateProjectDialogProps) { - const [name, setName] = React.useState(""); - const [description, setDescription] = React.useState(""); - const [cloneUrl, setCloneUrl] = React.useState(""); - const [webUrl, setWebUrl] = React.useState(""); - const [accessChannelId, setAccessChannelId] = React.useState(""); - const [errorMessage, setErrorMessage] = React.useState(null); - const nameInputRef = React.useRef(null); - const channelsQuery = useChannelsQuery({ enabled: open }); - const accessChannels = React.useMemo( - () => - (channelsQuery.data ?? []).filter( - (channel) => - channel.isMember && - !channel.archivedAt && - channel.channelType !== "dm", - ), - [channelsQuery.data], - ); - - React.useEffect(() => { - if (!open) return; - - setName(""); - setDescription(""); - setCloneUrl(""); - setWebUrl(""); - setAccessChannelId(accessChannels[0]?.id ?? ""); - setErrorMessage(null); - - // Small delay to let the dialog animation start before focusing. - const timerId = globalThis.setTimeout(() => { - nameInputRef.current?.focus(); - }, 50); - return () => globalThis.clearTimeout(timerId); - }, [accessChannels, open]); - - async function handleSubmit(event: React.FormEvent) { - event.preventDefault(); - - const trimmedName = name.trim(); - if (!trimmedName || !accessChannelId) return; - - setErrorMessage(null); - - try { - await onCreate({ - accessChannelId, - name: trimmedName, - description: description.trim() || undefined, - cloneUrl: cloneUrl.trim() || undefined, - webUrl: webUrl.trim() || undefined, - }); - - onOpenChange(false); - } catch (error) { - setErrorMessage( - error instanceof Error ? error.message : "Failed to create project.", - ); - } - } - return ( { @@ -99,218 +24,12 @@ export function CreateProjectDialog({ }} open={open} > - - - - } - footerClassName="border-t-0 pt-0" - headerClassName="pb-2" - title="Create a new project" - > -
{ - void handleSubmit(event); - }} - > -
- -
- { - setName(event.target.value); - setErrorMessage(null); - }} - placeholder="bee-garden-game" - ref={nameInputRef} - spellCheck={false} - value={name} - /> -
-
- -
- -
- -
-

- Members of this channel can access project repositories. -

-
- -
- -
-