feat(desktop): make project workspaces repository-aware

Unify project navigation, repository controls, contextual agent chat, and compact work-item views so repository state and access recovery stay visible in one workspace.

Signed-off-by: Thomas Petersen <thomasp@squareup.com>
This commit is contained in:
Thomas Petersen
2026-08-15 09:57:50 -04:00
parent c55849fdf1
commit bdc8e5a480
81 changed files with 4881 additions and 1781 deletions

View File

@@ -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]

View File

@@ -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;
}
}

View File

@@ -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"),

View File

@@ -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<TerminalContextOverride | null>(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}
<AppShellChannelSurface
isHuddleRoom={isHuddleRoom}
isHuddleRoomStarting={isHuddleRoomStarting}
mainInsetRef={mainInsetRef}
terminal={<TerminalBootstrap {...terminalContext} />}
<TerminalContextOverrideProvider
onChange={setTerminalContextOverride}
>
<Outlet />
</AppShellChannelSurface>
<AppShellChannelSurface
isHuddleRoom={isHuddleRoom}
isHuddleRoomStarting={isHuddleRoomStarting}
mainInsetRef={mainInsetRef}
terminal={
<TerminalBootstrap {...effectiveTerminalContext} />
}
>
<Outlet />
</AppShellChannelSurface>
</TerminalContextOverrideProvider>
{!isHuddleRoom ? (
<RelayConnectionOverlay
card={relayConnectionCard}

View File

@@ -1,14 +1,10 @@
import * as React from "react";
import {
ChevronLeft,
ChevronRight,
PanelLeftClose,
PanelLeftOpen,
} from "lucide-react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { isMacPlatform } from "@/shared/lib/platform";
import { useIsFullscreen } from "@/shared/lib/useIsFullscreen";
import { Button } from "@/shared/ui/button";
import { DrawerPanelIcon } from "@/shared/ui/DrawerPanelIcon";
import { cn } from "@/shared/lib/cn";
import { topChromeBackdrop } from "@/shared/layout/chromeLayout";
import { useOptionalSidebar } from "@/shared/ui/sidebar";
@@ -26,7 +22,7 @@ type AppTopChromeProps = {
// the row must not grow or shrink with the rem scale. Deliberate exception
// to the rem-first rule.
const TOP_CHROME_ICON_BUTTON_CLASS =
"h-[28px] w-[28px] rounded-[4px] text-sidebar-foreground/65 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [&_svg]:size-[16px]";
"h-[28px] w-[28px] rounded-[4px] text-sidebar-foreground/65 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground";
const HISTORY_ICON_BUTTON_CLASS =
"h-[28px] w-[24px] rounded-[4px] text-sidebar-foreground/65 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [&_svg]:size-[16px]";
@@ -50,7 +46,7 @@ function TopChromeSidebarTrigger() {
type="button"
variant="ghost"
>
{sidebar?.open ? <PanelLeftClose /> : <PanelLeftOpen />}
<DrawerPanelIcon side={sidebar?.open ? "left" : "right"} />
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
@@ -131,6 +127,11 @@ export function AppTopChrome({
<ChevronRight />
</Button>
</div>
<div
className={cn("flex min-w-0 flex-1 items-center", navRowAlignmentClass)}
data-tauri-drag-region
id="app-top-chrome-content"
/>
</div>
);
}

View File

@@ -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<HTMLElement | null>(null);
React.useEffect(() => {
setTarget(document.getElementById(APP_TOP_CHROME_CONTENT_ID));
}, []);
return target ? createPortal(children, target) : null;
}

View File

@@ -0,0 +1,38 @@
import * as React from "react";
export type TerminalContextOverride = {
channelId: string;
channelName: string;
};
const TerminalContextOverrideContext = React.createContext<React.Dispatch<
React.SetStateAction<TerminalContextOverride | null>
> | null>(null);
export function TerminalContextOverrideProvider({
children,
onChange,
}: {
children: React.ReactNode;
onChange: React.Dispatch<
React.SetStateAction<TerminalContextOverride | null>
>;
}) {
return (
<TerminalContextOverrideContext.Provider value={onChange}>
{children}
</TerminalContextOverrideContext.Provider>
);
}
export function useTerminalContextOverride(
context: TerminalContextOverride | null,
) {
const setOverride = React.useContext(TerminalContextOverrideContext);
React.useEffect(() => {
if (!setOverride) return;
setOverride(context);
return () => setOverride(null);
}, [context, setOverride]);
}

View File

@@ -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)}`,

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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<typeof commitAuthorPubkeysFromPullRequests>[0],
): Map<string, string> {
const pubkeysByHash = commitAuthorPubkeysFromPullRequests(pullRequests);
const candidatesByContributor = new Map<string, Set<string>>();
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<string>();
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 —

View File

@@ -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");
});

View File

@@ -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<string, string> = {
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+$/, "");
}

View File

@@ -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",

View File

@@ -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,

View File

@@ -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",
});
});

View File

@@ -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 {

View File

@@ -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 (`<kind>:<owner>:<d>`). 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 &&

View File

@@ -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,
),
);
}

View File

@@ -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<string | null>(null);
const nameInputRef = React.useRef<HTMLInputElement>(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<HTMLFormElement>) {
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 (
<Dialog
onOpenChange={(nextOpen) => {
@@ -99,218 +24,12 @@ export function CreateProjectDialog({
}}
open={open}
>
<ChooserDialogContent
className="max-w-lg"
contentClassName="pt-3"
data-testid="create-project-dialog"
description="Projects group one or more repositories published to this workspace's relay."
footer={
<div className="flex w-full items-center justify-end gap-3">
<Button
data-testid="create-project-submit"
disabled={
isCreating || name.trim().length === 0 || !accessChannelId
}
form="create-project-form"
type="submit"
>
{isCreating ? "Creating..." : "Create project"}
</Button>
</div>
}
footerClassName="border-t-0 pt-0"
headerClassName="pb-2"
title="Create a new project"
>
<form
className="space-y-5"
id="create-project-form"
onSubmit={(event) => {
void handleSubmit(event);
}}
>
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="create-project-name"
>
Name
</label>
<div
className={cn(
"flex min-h-11 items-center px-3",
CREATE_FIELD_SHELL_CLASS,
)}
>
<Input
autoCapitalize="none"
autoComplete="off"
autoCorrect="off"
className={cn(
"h-8 px-0 py-0 leading-6",
CREATE_FIELD_CONTROL_CLASS,
)}
data-testid="create-project-name"
disabled={isCreating}
id="create-project-name"
onChange={(event) => {
setName(event.target.value);
setErrorMessage(null);
}}
placeholder="bee-garden-game"
ref={nameInputRef}
spellCheck={false}
value={name}
/>
</div>
</div>
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="create-project-access-channel"
>
Repository access channel
</label>
<div
className={cn(
"flex min-h-11 items-center px-3",
CREATE_FIELD_SHELL_CLASS,
)}
>
<select
className={cn(
"h-8 w-full px-0 py-0",
CREATE_FIELD_CONTROL_CLASS,
)}
data-testid="create-project-access-channel"
disabled={isCreating}
id="create-project-access-channel"
onChange={(event) => {
setAccessChannelId(event.target.value);
setErrorMessage(null);
}}
required
value={accessChannelId}
>
<option value="">Select a channel</option>
{accessChannels.map((channel) => (
<option key={channel.id} value={channel.id}>
{channel.name}
</option>
))}
</select>
</div>
<p className="text-xs text-muted-foreground">
Members of this channel can access project repositories.
</p>
</div>
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="create-project-description"
>
Description
<span className={CREATE_LABEL_OPTIONAL_CLASS}>Optional</span>
</label>
<div className={CREATE_FIELD_SHELL_CLASS}>
<Textarea
className={cn(
"min-h-20 resize-none px-3 py-3 leading-5",
CREATE_FIELD_CONTROL_CLASS,
)}
data-testid="create-project-description"
disabled={isCreating}
id="create-project-description"
onChange={(event) => {
setDescription(event.target.value);
setErrorMessage(null);
}}
placeholder="What this project is about"
rows={2}
value={description}
/>
</div>
</div>
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="create-project-clone-url"
>
Initial repository clone URL
<span className={CREATE_LABEL_OPTIONAL_CLASS}>Optional</span>
</label>
<div
className={cn(
"flex min-h-11 items-center px-3",
CREATE_FIELD_SHELL_CLASS,
)}
>
<Input
autoCapitalize="none"
autoComplete="off"
autoCorrect="off"
className={cn(
"h-8 px-0 py-0 leading-6",
CREATE_FIELD_CONTROL_CLASS,
)}
data-testid="create-project-clone-url"
disabled={isCreating}
id="create-project-clone-url"
onChange={(event) => {
setCloneUrl(event.target.value);
setErrorMessage(null);
}}
placeholder="https://relay.example.com/git/bee-garden-game.git"
spellCheck={false}
value={cloneUrl}
/>
</div>
</div>
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="create-project-web-url"
>
Initial repository web URL
<span className={CREATE_LABEL_OPTIONAL_CLASS}>Optional</span>
</label>
<div
className={cn(
"flex min-h-11 items-center px-3",
CREATE_FIELD_SHELL_CLASS,
)}
>
<Input
autoCapitalize="none"
autoComplete="off"
autoCorrect="off"
className={cn(
"h-8 px-0 py-0 leading-6",
CREATE_FIELD_CONTROL_CLASS,
)}
data-testid="create-project-web-url"
disabled={isCreating}
id="create-project-web-url"
onChange={(event) => {
setWebUrl(event.target.value);
setErrorMessage(null);
}}
placeholder="https://github.com/owner/repo"
spellCheck={false}
value={webUrl}
/>
</div>
</div>
{errorMessage ? (
<p className="text-sm text-destructive">{errorMessage}</p>
) : null}
</form>
</ChooserDialogContent>
<CreateProjectFormContent
active={open}
isCreating={isCreating}
onCreate={onCreate}
onCreated={() => onOpenChange(false)}
/>
</Dialog>
);
}

View File

@@ -0,0 +1,314 @@
import { ArrowLeft } from "lucide-react";
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 { 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";
export function CreateProjectFormContent({
active,
initialName = "",
isCreating,
onBack,
onCreate,
onCreated,
}: {
active: boolean;
initialName?: string;
isCreating: boolean;
onBack?: () => void;
onCreate: (input: CreateProjectInput) => Promise<void>;
onCreated: () => void;
}) {
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<string | null>(null);
const nameInputRef = React.useRef<HTMLInputElement>(null);
const channelsQuery = useChannelsQuery({ enabled: active });
const accessChannels = React.useMemo(
() =>
(channelsQuery.data ?? []).filter(
(channel) =>
channel.isMember &&
!channel.archivedAt &&
channel.channelType !== "dm",
),
[channelsQuery.data],
);
React.useEffect(() => {
if (!active) return;
setName(initialName);
setDescription("");
setCloneUrl("");
setWebUrl("");
setAccessChannelId(accessChannels[0]?.id ?? "");
setErrorMessage(null);
const timerId = globalThis.setTimeout(() => {
nameInputRef.current?.focus();
}, 50);
return () => globalThis.clearTimeout(timerId);
}, [accessChannels, active, initialName]);
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
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,
});
onCreated();
} catch (error) {
setErrorMessage(
error instanceof Error ? error.message : "Failed to create project.",
);
}
}
return (
<ChooserDialogContent
className="max-w-lg"
contentClassName="pt-3"
data-testid="create-project-dialog"
description="Projects group one or more repositories published to this workspace's relay."
footer={
<div className="flex w-full items-center justify-end gap-3">
<Button
data-testid="create-project-submit"
disabled={
isCreating || name.trim().length === 0 || !accessChannelId
}
form="create-project-form"
type="submit"
>
{isCreating ? "Creating..." : "Create project"}
</Button>
</div>
}
footerClassName="border-t-0 pt-0"
headerClassName="pb-2"
title="Create a new project"
>
{onBack ? (
<Button
className="mb-3 h-8 gap-1.5 px-2 text-muted-foreground"
disabled={isCreating}
onClick={onBack}
size="sm"
type="button"
variant="ghost"
>
<ArrowLeft className="h-4 w-4" />
Back to projects
</Button>
) : null}
<form
className="space-y-5"
id="create-project-form"
onSubmit={(event) => {
void handleSubmit(event);
}}
>
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="create-project-name"
>
Name
</label>
<div
className={cn(
"flex min-h-11 items-center px-3",
CREATE_FIELD_SHELL_CLASS,
)}
>
<Input
autoCapitalize="none"
autoComplete="off"
autoCorrect="off"
className={cn(
"h-8 px-0 py-0 leading-6",
CREATE_FIELD_CONTROL_CLASS,
)}
data-testid="create-project-name"
disabled={isCreating}
id="create-project-name"
onChange={(event) => {
setName(event.target.value);
setErrorMessage(null);
}}
placeholder="bee-garden-game"
ref={nameInputRef}
spellCheck={false}
value={name}
/>
</div>
</div>
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="create-project-access-channel"
>
Repository access channel
</label>
<div
className={cn(
"flex min-h-11 items-center px-3",
CREATE_FIELD_SHELL_CLASS,
)}
>
<select
className={cn("h-8 w-full px-0 py-0", CREATE_FIELD_CONTROL_CLASS)}
data-testid="create-project-access-channel"
disabled={isCreating}
id="create-project-access-channel"
onChange={(event) => {
setAccessChannelId(event.target.value);
setErrorMessage(null);
}}
required
value={accessChannelId}
>
<option value="">Select a channel</option>
{accessChannels.map((channel) => (
<option key={channel.id} value={channel.id}>
{channel.name}
</option>
))}
</select>
</div>
<p className="text-xs text-muted-foreground">
Members of this channel can access project repositories.
</p>
</div>
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="create-project-description"
>
Description
<span className={CREATE_LABEL_OPTIONAL_CLASS}>Optional</span>
</label>
<div className={CREATE_FIELD_SHELL_CLASS}>
<Textarea
className={cn(
"min-h-20 resize-none px-3 py-3 leading-5",
CREATE_FIELD_CONTROL_CLASS,
)}
data-testid="create-project-description"
disabled={isCreating}
id="create-project-description"
onChange={(event) => {
setDescription(event.target.value);
setErrorMessage(null);
}}
placeholder="What this project is about"
rows={2}
value={description}
/>
</div>
</div>
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="create-project-clone-url"
>
Initial repository clone URL
<span className={CREATE_LABEL_OPTIONAL_CLASS}>Optional</span>
</label>
<div
className={cn(
"flex min-h-11 items-center px-3",
CREATE_FIELD_SHELL_CLASS,
)}
>
<Input
autoCapitalize="none"
autoComplete="off"
autoCorrect="off"
className={cn(
"h-8 px-0 py-0 leading-6",
CREATE_FIELD_CONTROL_CLASS,
)}
data-testid="create-project-clone-url"
disabled={isCreating}
id="create-project-clone-url"
onChange={(event) => {
setCloneUrl(event.target.value);
setErrorMessage(null);
}}
placeholder="https://relay.example.com/git/bee-garden-game.git"
spellCheck={false}
value={cloneUrl}
/>
</div>
</div>
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="create-project-web-url"
>
Initial repository web URL
<span className={CREATE_LABEL_OPTIONAL_CLASS}>Optional</span>
</label>
<div
className={cn(
"flex min-h-11 items-center px-3",
CREATE_FIELD_SHELL_CLASS,
)}
>
<Input
autoCapitalize="none"
autoComplete="off"
autoCorrect="off"
className={cn(
"h-8 px-0 py-0 leading-6",
CREATE_FIELD_CONTROL_CLASS,
)}
data-testid="create-project-web-url"
disabled={isCreating}
id="create-project-web-url"
onChange={(event) => {
setWebUrl(event.target.value);
setErrorMessage(null);
}}
placeholder="https://github.com/owner/repo"
spellCheck={false}
value={webUrl}
/>
</div>
</div>
{errorMessage ? (
<p className="text-sm text-destructive">{errorMessage}</p>
) : null}
</form>
</ChooserDialogContent>
);
}

View File

@@ -1,4 +1,4 @@
import { Hash } from "lucide-react";
import { Hash, MessageSquare } from "lucide-react";
import * as React from "react";
import { useQuery } from "@tanstack/react-query";
@@ -425,16 +425,18 @@ function ParticipantFacepile({
const label = resolveUserLabel({ profiles, pubkey });
if (!interactive) {
return (
<UserAvatar
avatarUrl={profiles?.[pubkey]?.avatarUrl ?? null}
className={cn(
"rounded-full ring-2 ring-background",
index > 0 && "-ml-1.5",
)}
displayName={label}
<span
className={cn(index > 0 && "-ml-1.5")}
key={pubkey}
size="xs"
/>
title={label}
>
<UserAvatar
avatarUrl={profiles?.[pubkey]?.avatarUrl ?? null}
className="rounded-full ring-2 ring-background"
displayName={label}
size="xs"
/>
</span>
);
}
return (
@@ -472,7 +474,13 @@ function ParticipantFacepile({
* where the repository (or its PRs/issues) is linked in chat, with the
* people who discussed it there.
*/
export function DiscussionChannelsPanel({ query }: { query: string }) {
export function DiscussionChannelsPanel({
query,
repositoryName,
}: {
query: string;
repositoryName: string;
}) {
const { channels, isLoading, isTruncated } = useDiscussionChannels(query);
const { goChannel } = useAppNavigation();
const channelName = useChannelNameLookup(channels.length > 0);
@@ -500,46 +508,53 @@ export function DiscussionChannelsPanel({ query }: { query: string }) {
return (
<div>
<ul
className="divide-y divide-border/50"
data-testid="discussion-channels"
>
<ul data-testid="discussion-channels">
{channels.map((channel) => {
const name = channelName(channel.id, channel.name);
const speakers = channel.participants
.slice(0, 2)
.map((pubkey) => resolveUserLabel({ profiles, pubkey }));
const others = channel.participants.length - speakers.length;
return (
<li className="relative" key={channel.id}>
<button
className="flex w-full min-w-0 items-center gap-2.5 px-4 py-3 text-left transition-colors hover:bg-muted/30"
className="flex min-h-9 w-full min-w-0 items-center gap-2 px-4 py-1.5 text-left transition-colors hover:bg-muted/30"
data-testid="project-channel-row"
onClick={() => void goChannel(channel.id)}
title={`Open #${name}`}
type="button"
>
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-muted/50">
<Hash className="h-4 w-4 text-muted-foreground" />
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
<Hash className="h-3.5 w-3.5 text-muted-foreground/70" />
</span>
<span className="min-w-0 flex-1 space-y-1">
<span className="block truncate text-sm font-medium text-foreground">
#{name}
</span>
<span className="block truncate text-xs text-muted-foreground">
{speakers.join(", ")}
{others > 0
? ` and ${others} ${others === 1 ? "other" : "others"}`
: ""}{" "}
· {channel.messageCount}
{isTruncated ? "+" : ""}{" "}
{channel.messageCount === 1 ? "message" : "messages"}
</span>
<span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
#{name}
</span>
<ParticipantFacepile
participants={channel.participants}
profiles={profiles}
/>
<span
className="hidden w-20 shrink-0 text-right text-xs text-muted-foreground sm:block"
className="hidden w-28 shrink-0 truncate text-right text-xs text-muted-foreground md:block"
data-testid="project-channel-repository"
title={repositoryName}
>
{repositoryName}
</span>
<span
className="flex w-20 shrink-0 justify-end"
data-testid="project-channel-participants"
>
<ParticipantFacepile
participants={channel.participants}
profiles={profiles}
/>
</span>
<span
className="flex w-12 shrink-0 items-center justify-end gap-1 text-xs text-muted-foreground"
data-testid="project-channel-message-count"
title={`${channel.messageCount}${isTruncated ? "+" : ""} ${
channel.messageCount === 1 ? "message" : "messages"
}`}
>
<MessageSquare className="h-3.5 w-3.5" />
{channel.messageCount}
{isTruncated ? "+" : ""}
</span>
<span
className="hidden w-20 shrink-0 whitespace-nowrap text-right text-xs text-muted-foreground/70 sm:block"
data-testid="project-channel-row-date"
title={new Date(
channel.lastActivityAt * 1_000,

View File

@@ -0,0 +1,235 @@
import { Trash2 } from "lucide-react";
import * as React from "react";
import { toast } from "sonner";
import { useStartManagedAgentMutation } from "@/features/agents/hooks";
import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane";
import { useChannelsQuery, useOpenDmMutation } from "@/features/channels/hooks";
import type { ProjectDetailAgentContext } from "@/features/projects/lib/projectDetailAgentContext";
import {
projectDetailAgentContextBlock,
stripProjectDetailAgentContext,
} from "@/features/projects/lib/projectDetailAgentContext";
import { restoreProjectsAgentConversation } from "@/features/projects/lib/projectAgentConversation";
import {
clearStoredProjectsAgentConversation,
readStoredProjectsAgentConversation,
type StoredProjectsAgentConversation,
writeStoredProjectsAgentConversation,
} from "@/features/projects/lib/projectAgentConversationStorage";
import { MessageComposer } from "@/features/messages/ui/MessageComposer";
import { useProfileQuery, useUsersBatchQuery } from "@/features/profile/hooks";
import { useIdentityQuery } from "@/shared/api/hooks";
import { sendChannelMessage } from "@/shared/api/tauri";
import type { Channel } from "@/shared/api/types";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { Button } from "@/shared/ui/button";
import {
type AgentCandidate,
ConversationThread,
useAgentCandidates,
} from "./ProjectsAgentPromptPage";
import { ProjectAgentContextStrip } from "./ProjectAgentContextStrip";
type ProjectAgentConversation = {
agent: AgentCandidate;
channel: Channel;
visibleAfter: number;
};
export function ProjectAgentChatPanel({
canResetWidth,
context,
onResetWidth,
onResizeStart,
sharedHeaderBackdrop,
widthPx,
}: {
canResetWidth: boolean;
context: ProjectDetailAgentContext;
onResetWidth: () => void;
onResizeStart: (event: React.PointerEvent<HTMLButtonElement>) => void;
sharedHeaderBackdrop?: boolean;
widthPx: number;
}) {
const storageScope = `detail:${context.repoAddress}`;
const [isSending, setIsSending] = React.useState(false);
const [storedConversation, setStoredConversation] =
React.useState<StoredProjectsAgentConversation | null>(() =>
readStoredProjectsAgentConversation(storageScope),
);
const [conversation, setConversation] =
React.useState<ProjectAgentConversation | null>(null);
const candidates = useAgentCandidates();
const channelsQuery = useChannelsQuery();
const identityQuery = useIdentityQuery();
const profileQuery = useProfileQuery();
const openDmMutation = useOpenDmMutation();
const startAgentMutation = useStartManagedAgentMutation();
const selectedAgent = conversation?.agent ?? candidates[0] ?? null;
const candidateProfilesQuery = useUsersBatchQuery(
selectedAgent ? [selectedAgent.pubkey] : [],
);
const selectedAgentAvatarUrl = selectedAgent
? (candidateProfilesQuery.data?.profiles[
normalizePubkey(selectedAgent.pubkey)
]?.avatarUrl ?? null)
: null;
const restorableConversation = React.useMemo(
() =>
restoreProjectsAgentConversation({
candidates,
channels: channelsQuery.data ?? [],
stored: storedConversation,
}),
[candidates, channelsQuery.data, storedConversation],
);
React.useEffect(() => {
if (conversation || !restorableConversation) return;
setConversation(restorableConversation);
}, [conversation, restorableConversation]);
const handleSubmit = React.useCallback(
async (
content: string,
mentionPubkeys: string[],
mediaTags?: string[][],
) => {
const trimmed = content.trim();
if (!trimmed || !selectedAgent || isSending) return;
setIsSending(true);
const visibleAfter =
conversation?.visibleAfter ?? Math.floor(Date.now() / 1_000);
try {
if (selectedAgent.isManaged && !selectedAgent.isActive) {
await startAgentMutation.mutateAsync(selectedAgent.pubkey);
}
const channel =
conversation?.channel ??
(await openDmMutation.mutateAsync({
pubkeys: [selectedAgent.pubkey],
}));
await sendChannelMessage(
channel.id,
`${trimmed}${projectDetailAgentContextBlock(context)}`,
undefined,
mediaTags,
[...new Set([...mentionPubkeys, selectedAgent.pubkey])],
);
if (!conversation) {
const nextConversation = {
agent: selectedAgent,
channel,
visibleAfter,
};
const stored = {
agentPubkey: selectedAgent.pubkey,
channelId: channel.id,
visibleAfter,
};
setConversation(nextConversation);
setStoredConversation(stored);
writeStoredProjectsAgentConversation(storageScope, stored);
}
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Failed to reach the agent",
);
} finally {
setIsSending(false);
}
},
[
context,
conversation,
isSending,
openDmMutation,
selectedAgent,
startAgentMutation,
storageScope,
],
);
const handleClear = React.useCallback(() => {
clearStoredProjectsAgentConversation(storageScope);
setStoredConversation(null);
setConversation(null);
}, [storageScope]);
return (
<RightAuxiliaryPane
canResetWidth={canResetWidth}
onResetWidth={onResetWidth}
onResizeStart={onResizeStart}
testId="project-agent-chat-panel"
widthPx={widthPx}
>
<ProjectAgentContextStrip
context={context}
sharedBackdrop={sharedHeaderBackdrop}
/>
<div className="flex min-h-0 flex-1 flex-col">
<div
className="min-h-0 flex-1 overflow-y-auto px-4 pb-4 pt-[4.25rem]"
data-testid="project-agent-conversation-scroll"
>
{conversation ? (
<ConversationThread
agent={conversation.agent}
agentAvatarUrl={selectedAgentAvatarUrl}
channel={conversation.channel}
currentPubkey={identityQuery.data?.pubkey ?? null}
selfAvatarUrl={profileQuery.data?.avatarUrl ?? null}
stripSelfContent={stripProjectDetailAgentContext}
visibleAfter={conversation.visibleAfter}
/>
) : (
<div className="flex h-full min-h-40 flex-col items-center justify-center gap-2 text-center">
<p className="text-sm font-medium text-foreground">
Ask about this page
</p>
<p className="max-w-56 text-xs text-muted-foreground">
Start a conversation with the project agent.
</p>
</div>
)}
</div>
<MessageComposer
channelId={conversation?.channel.id ?? null}
channelName={selectedAgent?.name ?? "project agent"}
channelType="dm"
containerClassName="px-3 pb-3"
disabled={!selectedAgent || isSending}
draftKey={`project-agent:${storageScope}`}
isSending={isSending}
layoutMode="standalone"
onSend={handleSubmit}
placeholder={
selectedAgent
? `Message ${selectedAgent.name}`
: "No agents available"
}
profiles={candidateProfilesQuery.data?.profiles}
showBackgroundUploadProgress={false}
showTopBorder={false}
toolbarExtraActions={
conversation ? (
<Button
aria-label="Clear project agent chat"
className="h-7 w-7"
onClick={handleClear}
size="icon"
title="Clear conversation"
type="button"
variant="ghost"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
) : null
}
/>
</div>
</RightAuxiliaryPane>
);
}

View File

@@ -0,0 +1,34 @@
import type { ProjectDetailAgentContext } from "@/features/projects/lib/projectDetailAgentContext";
import { PROJECT_COLUMN_HEADER_BACKDROP_CLASS } from "./projectPanelStyles";
function contextLabel(context: ProjectDetailAgentContext) {
if (context.workItem) {
return context.workItem.title;
}
if (context.file?.path) {
return context.file.path.split("/").filter(Boolean).at(-1) ?? context.view;
}
return context.view;
}
export function ProjectAgentContextStrip({
context,
sharedBackdrop = false,
}: {
context: ProjectDetailAgentContext;
sharedBackdrop?: boolean;
}) {
const label = contextLabel(context);
return (
<div
className={`absolute inset-x-0 top-0 z-30 flex h-13 shrink-0 items-center px-4 ${
sharedBackdrop ? "" : PROJECT_COLUMN_HEADER_BACKDROP_CLASS
}`}
data-testid="project-agent-context"
title={label}
>
<p className="truncate text-sm font-medium text-foreground">{label}</p>
</div>
);
}

View File

@@ -0,0 +1,192 @@
import { Check, Folders, Plus, Search } from "lucide-react";
import * as React from "react";
import type { Project } from "@/features/projects/hooks";
import type { CreateProjectInput } from "@/features/projects/useCreateProject";
import { Button } from "@/shared/ui/button";
import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content";
import { Dialog } from "@/shared/ui/dialog";
import {
MODAL_SEARCH_INPUT_CLASS,
MODAL_SEARCH_SHELL_CLASS,
} from "@/shared/ui/modalSearchStyles";
import { CreateProjectFormContent } from "./CreateProjectFormContent";
function projectSearchText(project: Project) {
return [
project.name,
project.description,
...project.repositories.flatMap((repository) => [
repository.name,
repository.description,
]),
]
.filter(Boolean)
.join(" ")
.toLocaleLowerCase();
}
export function ProjectBrowserDialog({
isCreating,
onCreate,
onOpenChange,
onSelectProject,
open,
projects,
selectedProjectAddresses,
}: {
isCreating: boolean;
onCreate: (input: CreateProjectInput) => Promise<void>;
onOpenChange: (open: boolean) => void;
onSelectProject: (project: Project) => void;
open: boolean;
projects: readonly Project[];
selectedProjectAddresses: ReadonlySet<string>;
}) {
const [mode, setMode] = React.useState<"browse" | "create">("browse");
const [query, setQuery] = React.useState("");
const inputRef = React.useRef<HTMLInputElement>(null);
const deferredQuery = React.useDeferredValue(
query.trim().toLocaleLowerCase(),
);
React.useEffect(() => {
if (!open) return;
setMode("browse");
setQuery("");
const timerId = globalThis.setTimeout(() => inputRef.current?.focus(), 50);
return () => globalThis.clearTimeout(timerId);
}, [open]);
const visibleProjects = React.useMemo(() => {
const sorted = [...projects].sort((left, right) =>
left.name.localeCompare(right.name, undefined, { sensitivity: "base" }),
);
if (!deferredQuery) return sorted;
return sorted.filter((project) =>
projectSearchText(project).includes(deferredQuery),
);
}, [deferredQuery, projects]);
const hasExactMatch = projects.some(
(project) =>
project.name.trim().toLocaleLowerCase() ===
query.trim().toLocaleLowerCase(),
);
return (
<Dialog
onOpenChange={(nextOpen) => {
if (!nextOpen && isCreating) return;
onOpenChange(nextOpen);
}}
open={open}
>
{mode === "create" ? (
<CreateProjectFormContent
active={open}
initialName={query.trim()}
isCreating={isCreating}
onBack={() => setMode("browse")}
onCreate={onCreate}
onCreated={() => onOpenChange(false)}
/>
) : (
<ChooserDialogContent
className="max-w-lg"
contentClassName="space-y-3 pt-1"
data-testid="project-browser-dialog"
headerClassName="pb-2"
scrollAreaClassName="px-3"
title="Add a project"
>
<div className={MODAL_SEARCH_SHELL_CLASS}>
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
<input
aria-label="Search projects"
className={MODAL_SEARCH_INPUT_CLASS}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search or create a project"
ref={inputRef}
type="search"
value={query}
/>
</div>
{!hasExactMatch ? (
<Button
className="h-auto w-full justify-start gap-3 rounded-lg px-3 py-2.5 text-left"
data-testid="project-browser-create"
onClick={() => setMode("create")}
type="button"
variant="ghost"
>
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Plus className="h-4 w-4" />
</span>
<span className="min-w-0">
<span className="block text-sm font-medium text-foreground">
{query.trim()
? `Create “${query.trim()}”`
: "Create a new project"}
</span>
<span className="block text-xs font-normal text-muted-foreground">
Configure its repository and access options
</span>
</span>
</Button>
) : null}
<div className="border-t border-border/60 pt-2">
{visibleProjects.length > 0 ? (
<div className="space-y-0.5">
{visibleProjects.map((project) => (
<Button
className="h-auto w-full justify-start gap-3 rounded-lg px-3 py-2.5 text-left"
data-testid={`project-browser-result-${project.dtag}`}
key={project.id}
onClick={() => {
onOpenChange(false);
onSelectProject(project);
}}
type="button"
variant="ghost"
>
<Folders className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-foreground">
{project.name}
</span>
<span className="block truncate text-xs font-normal text-muted-foreground">
{project.description ||
`${project.repositories.length} ${
project.repositories.length === 1
? "repository"
: "repositories"
}`}
</span>
</span>
{selectedProjectAddresses.has(project.projectAddress) ? (
<span className="flex shrink-0 items-center gap-1 text-xs text-muted-foreground">
<Check className="h-3.5 w-3.5" />
Added
</span>
) : null}
</Button>
))}
</div>
) : (
<div className="px-4 py-10 text-center">
<p className="text-sm font-medium text-foreground">
No projects found
</p>
<p className="mt-1 text-xs text-muted-foreground">
Try another search or create a new project.
</p>
</div>
)}
</div>
</ChooserDialogContent>
)}
</Dialog>
);
}

View File

@@ -8,7 +8,9 @@ import {
resolveUserLabel,
type UserProfileLookup,
} from "@/features/profile/lib/identity";
import type { Repository } from "@/features/projects/hooks";
import { commitDiscussionQuery } from "@/features/projects/lib/discussionChannels";
import { commitShareLink } from "@/features/projects/lib/projectShareLinks";
import type { ProjectRepoCommit, ProjectRepoDiff } from "@/shared/api/types";
import { DiscussedInChannels } from "./DiscussionChannels";
import { CopyCommitHashButton } from "./ProjectCommitCopyButton";
@@ -22,6 +24,7 @@ import { ProfileIdentityButton } from "./ProjectProfileIdentity";
import { ProjectDiffFilesPanel } from "./ProjectPullRequestFilesChangedPanel";
import { ProjectOriginReference } from "./ProjectOriginReference";
import { ProjectRichContent } from "./ProjectRichContent";
import { ShareLinkButton } from "./ShareLinkButton";
function commitDateLabel(timestamp: number) {
return new Date(timestamp * 1_000).toLocaleString(undefined, {
@@ -44,6 +47,7 @@ export function ProjectCommitDetailPanel({
originAgentName,
originChannelId,
profiles,
project,
viewerGitIdentity,
}: {
commit: ProjectRepoCommit | null;
@@ -56,6 +60,7 @@ export function ProjectCommitDetailPanel({
originAgentName?: string | null;
originChannelId?: string | null;
profiles?: UserProfileLookup;
project: Repository;
viewerGitIdentity?: ViewerGitIdentity | null;
}) {
const matchedProfile = commit
@@ -71,7 +76,13 @@ export function ProjectCommitDetailPanel({
<div className={PROJECT_DETAIL_PANEL_CLASS} data-project-detail-panel>
<header className="space-y-1 px-4 pb-1 pt-3">
<h3 className="line-clamp-2 text-xl font-semibold text-foreground">
{commit?.subject ?? shortHash}
{commit?.subject ?? shortHash}{" "}
<ShareLinkButton
className="ml-1 inline-flex h-7 w-7 align-text-bottom"
label="Copy commit link"
link={commitShareLink(project, commit?.hash ?? commitHash)}
testId="project-commit-copy-link"
/>
</h3>
<p className="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs font-medium text-muted-foreground">
<ProfileIdentityButton

View File

@@ -2,6 +2,7 @@ import * as React from "react";
import type { SearchHit } from "@/shared/api/searchTypes";
import { ProjectConversationPanel } from "./ProjectConversationPanel";
import { PROJECT_COLUMN_HEADER_BACKDROP_CLASS } from "./projectPanelStyles";
type ProjectConversationPanelContextValue = {
openConversation: (hit: SearchHit) => void;
@@ -34,19 +35,23 @@ export function ProjectConversationPanelController({
canResetWidth,
children,
closeWhen,
fallbackPanel,
onOpenConversation,
onResetWidth,
onResizeStart,
resetKey,
sharedHeaderBackdrop,
widthPx,
}: {
canResetWidth: boolean;
children: React.ReactNode;
closeWhen: boolean;
fallbackPanel?: React.ReactNode;
onOpenConversation: () => void;
onResetWidth: () => void;
onResizeStart: (event: React.PointerEvent<HTMLButtonElement>) => void;
resetKey: string;
sharedHeaderBackdrop?: boolean;
widthPx: number;
}) {
const [hit, setHit] = React.useState<SearchHit | null>(null);
@@ -65,7 +70,14 @@ export function ProjectConversationPanelController({
);
return (
<ProjectConversationPanelProvider onOpenConversation={openConversation}>
<div className="flex min-h-0 min-w-0 flex-1 flex-row overflow-hidden">
<div className="relative flex min-h-0 min-w-0 flex-1 flex-row overflow-hidden">
{sharedHeaderBackdrop ? (
<div
aria-hidden="true"
className={`pointer-events-none absolute inset-x-0 top-0 z-20 h-13 ${PROJECT_COLUMN_HEADER_BACKDROP_CLASS}`}
data-testid="project-shared-header-backdrop"
/>
) : null}
{children}
{hit ? (
<ProjectConversationPanel
@@ -76,7 +88,9 @@ export function ProjectConversationPanelController({
onResizeStart={onResizeStart}
widthPx={widthPx}
/>
) : null}
) : (
fallbackPanel
)}
</div>
</ProjectConversationPanelProvider>
);

View File

@@ -1,10 +1,9 @@
import { ChevronRight, FolderGit2 } from "lucide-react";
import { ChevronRight, Folders } from "lucide-react";
import type * as React from "react";
import type { Project } from "@/features/projects/hooks";
import { AppTopChromePortal } from "@/app/AppTopChromePortal";
import type { Project, Repository } from "@/features/projects/hooks";
import { projectShareLink } from "@/features/projects/lib/projectShareLinks";
import { channelChrome, topChromeInset } from "@/shared/layout/chromeLayout";
import { cn } from "@/shared/lib/cn";
import type { EntityLinkTab } from "@/shared/lib/entityLink";
import { ShareLinkButton } from "./ShareLinkButton";
@@ -18,73 +17,73 @@ export function ProjectDetailChrome({
actions,
activeTabCrumb,
activeWorkItemCrumb,
chromeRef,
onGoProjectHome,
onGoProjects,
project,
repository,
shareTab,
}: {
/** Repository-scoped controls, rendered left of the project-wide ones. */
actions?: React.ReactNode;
activeTabCrumb: string | null;
activeWorkItemCrumb: ProjectDetailWorkItemCrumb | null;
chromeRef: React.Ref<HTMLDivElement>;
onGoProjectHome: () => void;
onGoProjects: () => void;
project: Project;
/**
* Workspace tab the copied link should open (`undefined` = overview), so
* sharing from the PR or issue list lands recipients on that same list.
*/
repository: Repository;
shareTab?: EntityLinkTab;
}) {
return (
<div
className={cn(
"pointer-events-none relative z-30 overflow-hidden rounded-tl-xl bg-background/80 backdrop-blur-md supports-backdrop-filter:bg-background/70 dark:bg-background/70 dark:backdrop-blur-xl dark:supports-backdrop-filter:bg-background/55",
channelChrome.negativeMargin,
topChromeInset.divider,
)}
ref={chromeRef}
>
<AppTopChromePortal>
<div
className="pointer-events-auto flex min-h-[2.75rem] items-center justify-between gap-3 px-4 py-1.5"
className="flex min-w-0 flex-1 items-center justify-between gap-3 pl-2"
data-tauri-drag-region
data-testid="project-detail-chrome"
>
<nav
aria-label="Project breadcrumb"
className="-ml-1 flex min-w-0 items-center gap-0.5 text-xs text-muted-foreground"
className="absolute left-1/2 flex max-w-[50%] min-w-0 -translate-x-1/2 -translate-y-px items-center gap-0.5 text-xs text-sidebar-foreground/65"
>
<button
className="flex shrink-0 items-center gap-1.5 rounded-md px-1 py-1 font-medium transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
className="flex shrink-0 items-center gap-1.5 rounded-md px-1 py-1 font-medium transition-colors hover:text-sidebar-accent-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
onClick={onGoProjects}
type="button"
>
<FolderGit2 className="h-3.5 w-3.5" />
<Folders className="h-3.5 w-3.5" />
Projects
</button>
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground/60" />
<ChevronRight className="h-3 w-3 shrink-0 opacity-60" />
<button
className="min-w-0 truncate rounded-md px-0.5 py-1 font-medium transition-colors hover:text-sidebar-accent-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
data-testid="project-breadcrumb-project"
onClick={onGoProjectHome}
type="button"
>
{project.name}
</button>
<ChevronRight className="h-3 w-3 shrink-0 opacity-60" />
{activeWorkItemCrumb ? (
<>
<button
className="min-w-0 truncate rounded-md px-0.5 py-1 font-medium transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
className="min-w-0 truncate rounded-md px-0.5 py-1 font-medium transition-colors hover:text-sidebar-accent-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
data-testid="project-breadcrumb-repository"
onClick={onGoProjectHome}
type="button"
>
{project.name}
{repository.name}
</button>
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground/60" />
<ChevronRight className="h-3 w-3 shrink-0 opacity-60" />
<button
className="shrink-0 rounded-md px-0.5 py-1 font-medium transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
className="shrink-0 rounded-md px-0.5 py-1 font-medium transition-colors hover:text-sidebar-accent-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
onClick={activeWorkItemCrumb.clear}
type="button"
>
{activeWorkItemCrumb.category}
</button>
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground/60" />
<ChevronRight className="h-3 w-3 shrink-0 opacity-60" />
<span
aria-current="page"
className="min-w-0 truncate px-0.5 font-medium text-muted-foreground/60"
className="min-w-0 truncate px-0.5 font-medium opacity-60"
>
{activeWorkItemCrumb.title}
</span>
@@ -92,16 +91,17 @@ export function ProjectDetailChrome({
) : activeTabCrumb ? (
<>
<button
className="min-w-0 truncate rounded-md px-0.5 py-1 font-medium transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
className="min-w-0 truncate rounded-md px-0.5 py-1 font-medium transition-colors hover:text-sidebar-accent-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
data-testid="project-breadcrumb-repository"
onClick={onGoProjectHome}
type="button"
>
{project.name}
{repository.name}
</button>
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground/60" />
<ChevronRight className="h-3 w-3 shrink-0 opacity-60" />
<span
aria-current="page"
className="min-w-0 truncate px-0.5 font-medium text-muted-foreground/60"
className="min-w-0 truncate px-0.5 font-medium opacity-60"
>
{activeTabCrumb}
</span>
@@ -109,21 +109,23 @@ export function ProjectDetailChrome({
) : (
<span
aria-current="page"
className="min-w-0 truncate px-0.5 font-medium text-muted-foreground/60"
className="min-w-0 truncate px-0.5 font-medium opacity-60"
data-testid="project-breadcrumb-repository"
>
{project.name}
{repository.name}
</span>
)}
</nav>
<div className="flex shrink-0 items-center gap-1.5">
{actions}
<div className="ml-auto flex shrink-0 items-center gap-0.5">
<ShareLinkButton
className="text-sidebar-foreground/65 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
label="Copy project link"
link={projectShareLink(project, shareTab)}
testId="project-detail-copy-link"
/>
{actions}
</div>
</div>
</div>
</AppTopChromePortal>
);
}

View File

@@ -1,30 +0,0 @@
import type { Project, Repository } from "@/features/projects/hooks";
import { ProjectRepositoryManagement } from "./ProjectRepositoryManagement";
/**
* Repository-scoped controls for the project detail chrome. Extracted from
* `ProjectDetailScreen` to keep the screen under the file-size ratchet.
*/
export function ProjectDetailChromeActions({
identityPubkey,
onRepositoryChange,
project,
projects,
repository,
}: {
identityPubkey?: string;
onRepositoryChange: (repositoryId: string) => void;
project: Project;
projects: Project[];
repository: Repository;
}) {
return (
<ProjectRepositoryManagement
identityPubkey={identityPubkey}
onChange={onRepositoryChange}
project={project}
projects={projects}
repository={repository}
/>
);
}

View File

@@ -3,6 +3,7 @@ import {
contributorKey,
profileForCommit,
profileForContributor,
type ProjectContributorActivityCounts,
type ViewerGitIdentity,
} from "@/features/projects/lib/projectContributorMatching";
import type {
@@ -16,57 +17,117 @@ import {
resolveUserLabel,
type UserProfileLookup,
} from "@/features/profile/lib/identity";
import { GitBranch } from "lucide-react";
import { cn } from "@/shared/lib/cn";
import { CopyCommitHashButton } from "./ProjectCommitCopyButton";
import {
ProjectFeedRow,
ProjectFeedRowCluster,
ProjectFeedRowMonoCell,
} from "./ProjectFeedRow";
CircleDot,
GitBranch,
GitCommitHorizontal,
GitPullRequest,
} from "lucide-react";
import { CopyCommitHashButton } from "./ProjectCommitCopyButton";
import {
PROJECT_DETAIL_PANEL_CLASS,
PROJECT_DETAIL_PANEL_MESSAGE_CLASS,
} from "./projectPanelStyles";
import { ProfileIdentityButton } from "./ProjectProfileIdentity";
import { ProjectWorkItemRow } from "./ProjectWorkItemRow";
function pluralize(count: number, singular: string, plural = `${singular}s`) {
return `${count} ${count === 1 ? singular : plural}`;
}
export function ContributorsPanel({
activityCounts,
contributorPubkeys,
contributorPubkeysByGitIdentity,
profiles,
repoContributors,
}: {
activityCounts: Record<string, ProjectContributorActivityCounts>;
contributorPubkeys: string[];
contributorPubkeysByGitIdentity: ReadonlyMap<string, string>;
profiles?: UserProfileLookup;
repoContributors: ProjectRepoContributor[];
}) {
const rows = repoContributors.map((contributor) => {
const matchedProfile = profileForContributor(contributor, profiles);
const gitRows = repoContributors.map((contributor) => {
const signedPubkey = contributorPubkeysByGitIdentity.get(
contributorKey(contributor),
);
const signedProfile = signedPubkey ? profiles?.[signedPubkey] : undefined;
const heuristicProfile = signedPubkey
? null
: profileForContributor(contributor, profiles);
const matchedPubkey = signedPubkey ?? heuristicProfile?.pubkey ?? null;
const matchedProfile = signedProfile ?? heuristicProfile?.profile;
const label = matchedProfile
? resolveUserLabel({ pubkey: matchedProfile.pubkey, profiles })
? resolveUserLabel({ pubkey: matchedPubkey ?? "", profiles })
: contributor.name || contributor.email || "Unknown contributor";
const signedCounts = matchedPubkey
? activityCounts[matchedPubkey]
: undefined;
return {
avatarUrl: matchedProfile?.profile.avatarUrl ?? null,
avatarUrl: matchedProfile?.avatarUrl ?? null,
commitCount: contributor.commitCount,
id: `git:${contributorKey(contributor)}`,
isAgent: matchedProfile?.profile.isAgent === true,
isAgent: matchedProfile?.isAgent === true,
label,
lastCommitAt: contributor.lastCommitAt,
pubkey: matchedProfile?.pubkey ?? null,
// Profile matches come from unauthenticated git author strings, so
// they are surfaced as unverified rather than as a confirmed identity.
role: matchedProfile
? `${
matchedProfile.profile.nip05Handle ||
contributor.email ||
"Git contributor"
} · unverified match`
: contributor.email || "Git contributor",
pubkey: matchedPubkey,
profileLinked: matchedPubkey !== null,
reviewCount: signedCounts?.reviews ?? null,
role: signedPubkey
? matchedProfile?.nip05Handle || contributor.email || "Buzz contributor"
: heuristicProfile
? `${
heuristicProfile.profile.nip05Handle ||
contributor.email ||
"Git contributor"
} · unverified match`
: contributor.email || "Git contributor",
taskCount: signedCounts?.tasks ?? null,
};
});
const matchedPubkeys = new Set(
gitRows
.map((row) => row.pubkey)
.filter((pubkey): pubkey is string => pubkey !== null),
);
const linkedRows = contributorPubkeys
.filter((pubkey) => !matchedPubkeys.has(pubkey))
.map((pubkey) => {
const profile = profiles?.[pubkey];
const isAgent = profile?.isAgent === true;
const signedCounts = activityCounts[pubkey] ?? {
commits: 0,
reviews: 0,
tasks: 0,
};
return {
avatarUrl: profile?.avatarUrl ?? null,
commitCount: signedCounts.commits,
id: `buzz:${pubkey}`,
isAgent,
label: profile
? resolveUserLabel({ profiles, pubkey })
: `${pubkey.slice(0, 8)}…`,
profileLinked: true,
pubkey,
reviewCount: signedCounts.reviews,
role:
profile?.nip05Handle ||
(isAgent ? "Agent contributor" : "Buzz contributor"),
taskCount: signedCounts.tasks,
};
});
const rows = [
...gitRows.filter((row) => row.profileLinked),
...linkedRows,
...gitRows.filter((row) => !row.profileLinked),
].sort(
(left, right) =>
(right.commitCount ?? -1) - (left.commitCount ?? -1) ||
left.label.localeCompare(right.label),
);
if (rows.length === 0) {
return (
@@ -81,46 +142,71 @@ export function ContributorsPanel({
return (
<div className={PROJECT_DETAIL_PANEL_CLASS} data-project-detail-panel>
{rows.map((row, index) => (
{rows.map((row) => (
<div
className={cn(
"flex min-w-0 items-center gap-3 p-3 transition-colors hover:bg-muted/35",
index !== rows.length - 1 && "border-border/50 border-b",
)}
className="flex min-h-9 min-w-0 items-center gap-2 px-4 py-1.5 transition-colors hover:bg-muted/35"
data-project-contributor-kind={row.isAgent ? "agent" : "human"}
data-testid="project-contributor-row"
key={row.id}
>
<ProfileIdentityButton
avatarClassName="shrink-0"
avatarSize="md"
avatarSize="xs"
avatarUrl={row.avatarUrl}
isAgent={row.isAgent}
label={row.label}
pubkey={row.pubkey}
showLabel={false}
/>
<div className="min-w-0 flex-1 space-y-0.5">
<p className="truncate text-sm font-semibold leading-5 text-foreground">
{row.label}
</p>
<div className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs leading-4 text-muted-foreground">
<span className="truncate">{row.role}</span>
<span className="rounded-full border border-border/60 px-1.5 py-0.5 text-2xs">
{row.commitCount === null
? "No git commits"
: `${row.commitCount} commit${row.commitCount === 1 ? "" : "s"}`}
</span>
</div>
</div>
{row.lastCommitAt ? (
<span
className="hidden w-20 shrink-0 text-right text-xs text-muted-foreground sm:block"
data-testid="project-contributor-row-date"
title={new Date(row.lastCommitAt * 1_000).toLocaleString()}
>
{relativeTime(row.lastCommitAt)}
</span>
) : null}
<span
className="min-w-0 flex-1 truncate text-sm font-medium text-foreground"
title={row.label}
>
{row.label}
</span>
<span
className="hidden min-w-0 flex-1 truncate text-xs text-muted-foreground md:block"
data-testid="project-contributor-identity"
title={row.role}
>
{row.role}
</span>
<span
className="flex w-14 shrink-0 items-center justify-end gap-1 text-xs tabular-nums text-muted-foreground"
data-testid="project-contributor-commit-count"
title={
row.commitCount === null
? "No git commits"
: pluralize(row.commitCount, "commit")
}
>
<GitCommitHorizontal className="h-3.5 w-3.5" />
{row.commitCount ?? 0}
</span>
<span
className="flex w-14 shrink-0 items-center justify-end gap-1 text-xs tabular-nums text-muted-foreground"
data-testid="project-contributor-review-count"
title={
row.reviewCount === null
? "No linked reviews"
: pluralize(row.reviewCount, "review")
}
>
<GitPullRequest className="h-3.5 w-3.5" />
{row.reviewCount ?? 0}
</span>
<span
className="flex w-14 shrink-0 items-center justify-end gap-1 text-xs tabular-nums text-muted-foreground"
data-testid="project-contributor-task-count"
title={
row.taskCount === null
? "No linked tasks"
: pluralize(row.taskCount, "task")
}
>
<CircleDot className="h-3.5 w-3.5" />
{row.taskCount ?? 0}
</span>
</div>
))}
</div>
@@ -179,7 +265,7 @@ export function ActivityPanel({
return (
<section className={PROJECT_DETAIL_PANEL_CLASS} data-project-detail-panel>
<div className="divide-y divide-border/50">
<div>
{commits.map((commit) => {
const matchedProfile = profileForCommit(
commit,
@@ -202,57 +288,56 @@ export function ActivityPanel({
);
return (
<ProjectFeedRow
<ProjectWorkItemRow
eventId={commit.hash}
identifier={commit.shortHash}
identifierClassName="font-mono"
identifierTitle={`View commit ${commit.shortHash}`}
key={commit.hash}
meta={
<>
<ProfileIdentityButton
avatarClassName="shrink-0"
avatarSize="xs"
avatarUrl={matchedProfile?.profile.avatarUrl ?? null}
isAgent={matchedProfile?.profile.isAgent === true}
label={authorLabel}
pubkey={matchedProfile?.pubkey ?? null}
showLabel={false}
/>
<span className="truncate">
<span className="font-medium text-foreground/80">
{authorLabel}
</span>{" "}
committed
metadata={
branch ? (
<span className="inline-flex min-w-0 items-center gap-1">
<GitBranch className="h-3 w-3 shrink-0" />
<span className="truncate">{branch}</span>
</span>
{branch ? (
<span className="inline-flex min-w-0 items-center gap-1 rounded-full border border-border/60 px-1.5 py-0.5 font-mono text-2xs">
<GitBranch className="h-3 w-3 shrink-0" />
<span className="truncate">{branch}</span>
</span>
) : null}
{matchingContributor?.commitCount ? (
<span className="rounded-full border border-border/60 px-1.5 py-0.5 text-2xs">
{pluralize(matchingContributor.commitCount, "commit")}
</span>
) : null}
</>
) : undefined
}
onOpen={onSelectCommit ? () => onSelectCommit(commit) : undefined}
statusIcon={
<GitCommitHorizontal className="h-3.5 w-3.5 text-muted-foreground/70" />
}
testId="project-activity-feed-item"
title={commit.subject}
trailing={
<>
<ProjectFeedRowCluster>
<ProjectFeedRowMonoCell
label={commit.shortHash}
onClick={
onSelectCommit
? () => onSelectCommit(commit)
: undefined
}
title={`View commit ${commit.shortHash}`}
/>
<CopyCommitHashButton hash={commit.hash} />
</ProjectFeedRowCluster>
<span
className="hidden w-20 shrink-0 text-right text-xs text-muted-foreground sm:block"
className="flex h-5 w-5 shrink-0 items-center justify-center"
data-testid="project-commit-author"
title={`Committed by ${authorLabel}${
matchingContributor?.commitCount
? ` · ${pluralize(
matchingContributor.commitCount,
"commit",
)}`
: ""
}`}
>
<ProfileIdentityButton
avatarClassName="shrink-0"
avatarSize="xs"
avatarUrl={matchedProfile?.profile.avatarUrl ?? null}
isAgent={matchedProfile?.profile.isAgent === true}
label={authorLabel}
pubkey={matchedProfile?.pubkey ?? null}
showLabel={false}
/>
</span>
<CopyCommitHashButton
className="h-5 w-5 shrink-0 text-muted-foreground/70"
hash={commit.hash}
/>
<span
className="hidden w-20 shrink-0 whitespace-nowrap text-right text-xs text-muted-foreground/70 sm:block"
data-testid="project-commit-row-date"
title={new Date(commit.timestamp * 1_000).toLocaleString()}
>

View File

@@ -54,14 +54,14 @@ export function ProjectDetailMetaRow({
))}
<span
className={cn(
"w-20 shrink-0 truncate whitespace-nowrap text-muted-foreground",
"w-20 shrink-0 truncate whitespace-nowrap font-normal text-muted-foreground/75",
labelClassName,
)}
title={label}
>
{label}
</span>
<div className="flex min-h-5 min-w-0 flex-1 items-center text-foreground">
<div className="flex min-h-5 min-w-0 flex-1 items-center font-medium text-foreground">
{children}
</div>
</div>

View File

@@ -0,0 +1,36 @@
import type * as React from "react";
import type { ProjectDetailAgentContext } from "@/features/projects/lib/projectDetailAgentContext";
import { ProjectAgentChatPanel } from "./ProjectAgentChatPanel";
import { ProjectRepositoryActionsPanel } from "./ProjectRepositoryActionsPanel";
import type { ProjectRightPanelMode } from "./ProjectRightPanelControls";
type RepositoryPanelProps = React.ComponentProps<
typeof ProjectRepositoryActionsPanel
>;
export function ProjectDetailRightPanel({
context,
mode,
sharedHeaderBackdrop,
...repositoryProps
}: RepositoryPanelProps & {
context: ProjectDetailAgentContext;
mode: ProjectRightPanelMode;
sharedHeaderBackdrop?: boolean;
}) {
if (mode === "chat") {
return (
<ProjectAgentChatPanel
canResetWidth={repositoryProps.canResetWidth}
context={context}
key={context.repoAddress}
onResetWidth={repositoryProps.onResetWidth}
onResizeStart={repositoryProps.onResizeStart}
sharedHeaderBackdrop={sharedHeaderBackdrop}
widthPx={repositoryProps.widthPx}
/>
);
}
return <ProjectRepositoryActionsPanel {...repositoryProps} />;
}

View File

@@ -3,7 +3,7 @@ import * as React from "react";
import { toast } from "sonner";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { useOpenDmMutation } from "@/features/channels/hooks";
import { useTerminalContextOverride } from "@/app/TerminalContextOverrideContext";
import {
type Project,
type Repository,
@@ -28,22 +28,8 @@ import { useOptimisticProjectBranches } from "@/features/projects/useOptimisticP
import { useProjectRepositoryRefSelection } from "@/features/projects/useProjectRepositoryRefSelection";
import { useUpdateProjectPullRequestMutation } from "@/features/projects/pullRequestMutations";
import { useCreateProjectIssueMutation } from "@/features/projects/issueMutations";
import { useProfileQuery, useUsersBatchQuery } from "@/features/profile/hooks";
import { mergeCurrentProfileIntoLookup } from "@/features/profile/lib/identity";
import {
type ProfilePanelTab,
type ProfilePanelView,
UserProfilePanel,
} from "@/features/profile/ui/UserProfilePanel";
import {
profilePanelTabFromSearch,
profilePanelViewFromSearch,
} from "@/features/profile/ui/UserProfilePanelUtils";
import { useIdentityQuery } from "@/shared/api/hooks";
import { UserProfilePanel } from "@/features/profile/ui/UserProfilePanel";
import { openProjectMergeRecoveryTerminal } from "@/shared/api/projectGit";
import { useMainInsetRef } from "@/shared/layout/MainInsetContext";
import { channelContentTopPaddingMeasurement } from "@/shared/layout/chromeLayout";
import { useMeasuredCssVariable } from "@/shared/layout/useMeasuredCssVariable";
import { ProfilePanelProvider } from "@/shared/context/ProfilePanelContext";
import { useHistorySearchState } from "@/shared/hooks/useHistorySearchState";
import { useThreadPanelWidth } from "@/shared/hooks/useThreadPanelWidth";
@@ -51,8 +37,6 @@ import { Button } from "@/shared/ui/button";
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
import { useCommunities } from "@/features/communities/useCommunities";
import { useProjectCommitDiffQuery } from "@/features/projects/useProjectCommitDiff";
import { useGitIdentityQuery } from "@/features/projects/useGitIdentity";
import type { ViewerGitIdentity } from "@/features/projects/lib/projectContributorMatching";
import {
projectBranchCreationReason,
projectBranchManagementState,
@@ -64,7 +48,17 @@ import {
shareTabForWorkspaceTab,
workspaceTabForShareTab,
} from "@/features/projects/lib/projectShareLinks";
import {
buildProjectDetailAgentContext,
type ProjectDetailAgentContext,
} from "@/features/projects/lib/projectDetailAgentContext";
import {
projectRepoUnavailablePresentation,
projectRepoUnavailableReason,
refineRepoUnavailableReason,
} from "@/features/projects/lib/projectRepoAvailability";
import { selectProjectRepository } from "@/features/projects/projectModels";
import { useMemberChannelIds } from "@/features/projects/useRepositoryAccess";
import { KIND_REPO_ANNOUNCEMENT } from "@/shared/constants/kinds";
import type { EntityLinkTab } from "@/shared/lib/entityLink";
import { useProjectRepoPresentation } from "@/features/projects/useProjectRepoHost";
@@ -78,15 +72,15 @@ import {
import type { CreateIssueDialogInput } from "./CreateIssueDialog";
import { ProjectBranchActionDialogs } from "./ProjectBranchActionDialogs";
import { ProjectDetailChrome } from "./ProjectDetailChrome";
import { ProjectDetailChromeActions } from "./ProjectDetailChromeActions";
import { ProjectConversationPanelController } from "./ProjectConversationPanelContext";
import { ProjectDetailRightPanel } from "./ProjectDetailRightPanel";
import { ProjectRightPanelControls } from "./ProjectRightPanelControls";
import { UnavailableProjectRepositories } from "./UnavailableProjectRepositories";
import {
PROJECT_TAB_CRUMB_LABELS,
projectPeople,
pushPullTitle,
snapshotHasContent,
} from "./projectDetailHelpers";
import { buildProjectDetailCrumbs } from "./useProjectDetailCrumbs";
import { useProjectDetailPeople } from "./useProjectDetailPeople";
import { useProjectProfilePanel } from "./useProjectProfilePanel";
import { useProjectRepositoryPanel } from "./useProjectRepositoryPanel";
import { pushPullTitle, snapshotHasContent } from "./projectDetailHelpers";
type ProjectDetailScreenProps = {
commitHash?: string;
@@ -99,11 +93,6 @@ type ProjectDetailScreenProps = {
tab?: EntityLinkTab;
};
const PROJECT_DETAIL_PANEL_SEARCH_KEYS = [
"profile",
"profileTab",
"profileView",
] as const;
const PROJECT_REPOSITORY_SEARCH_KEYS = [
"repositoryId",
"issueId",
@@ -121,28 +110,15 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
repositoryId,
tab,
} = props;
const { goChannel, goProject, goProjects } = useAppNavigation();
const { goProject, goProjects } = useAppNavigation();
const { activeCommunity } = useCommunities();
const mainInsetRef = useMainInsetRef();
const projectDetailHeaderChromeRef = useMeasuredCssVariable({
targetRef: mainInsetRef,
resetKey: projectId,
...channelContentTopPaddingMeasurement,
});
const projectQuery = useProjectQuery(projectId);
const projectsQuery = useProjectsQuery();
const project = projectQuery.data;
// When the projectId is a canonical 30617:<owner>:<d> coordinate (emitted by
// entity links in #4695), derive the repository selection directly from the
// <owner>:<d> portion rather than falling back to the project's primary
// repository. Repository.id is "<owner>:<dtag>", so stripping the kind+colon
// prefix gives the exact repository id. This ensures a linked PR/issue on a
// non-primary member opens from the correct repository instead of the primary.
const routeRepositoryId: string | undefined = React.useMemo(() => {
if (repositoryId) return repositoryId;
const kindStr = `${String(KIND_REPO_ANNOUNCEMENT)}:`;
if (!projectId.startsWith(kindStr)) return undefined;
// projectId is "30617:<owner>:<dtag>" — strip "30617:" to get "<owner>:<dtag>"
return projectId.slice(kindStr.length);
}, [projectId, repositoryId]);
const repository = selectProjectRepository(project, routeRepositoryId);
@@ -193,19 +169,15 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
() => setSelectedCommitHash(commitHash ?? null),
[commitHash],
);
// Remounts WorkspaceTabs when breadcrumb navigation should open Overview.
const [tabsResetKey, setTabsResetKey] = React.useState(0);
// Local state lets breadcrumb and repository resets drop a share-link tab.
const [requestedTab, setRequestedTab] = React.useState<
EntityLinkTab | undefined
>(tab);
// biome-ignore lint/correctness/useExhaustiveDependencies: the transient request ID deliberately reapplies an unchanged share-link tab.
React.useEffect(() => setRequestedTab(tab), [entityNavigationId, tab]);
// Mirror of the WorkspaceTabs selection so the breadcrumb can name the
// active sub-tab. The Overview (readme) tab is "home" and gets no crumb.
const [activeTab, setActiveTab] = React.useState("overview");
// Commit, PR, and issue details are mutually exclusive views, so opening
// one clears the others.
const [filesContext, setFilesContext] =
React.useState<ProjectDetailAgentContext["file"]>(null);
const handleSelectedPullRequestIdChange = React.useCallback(
(id: string | null) => {
setSelectedPullRequestId(id);
@@ -253,6 +225,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
const [repoSource, setRepoSource] = React.useState<"remote" | "local">(
"remote",
);
const repositoryPanel = useProjectRepositoryPanel();
const repoSnapshotQuery = useProjectRepoSnapshotQuery(
repository,
activeBranch,
@@ -260,6 +233,17 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
activeTag,
repoRemote.host.kind === "buzz",
);
const memberChannelIds = useMemberChannelIds();
const remoteUnavailableReason =
repoRemote.host.kind === "buzz" &&
!repoSnapshotQuery.isLoading &&
(!repoSnapshotQuery.data || repoSnapshotQuery.error)
? refineRepoUnavailableReason({
reason: projectRepoUnavailableReason(repoSnapshotQuery.error),
repositoryChannelId: repository?.channelId,
memberChannelIds,
})
: undefined;
const repoDiffQuery = useProjectRepoDiffQuery(
repository,
activeBranch,
@@ -384,16 +368,29 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
]);
const error = results.find((result) => result.error)?.error;
if (error) {
toast.error("Could not fetch repository.", {
description:
error instanceof Error ? error.message : "The Git fetch failed.",
const reason = refineRepoUnavailableReason({
reason: projectRepoUnavailableReason(error),
repositoryChannelId: repository?.channelId,
memberChannelIds,
});
const presentation = projectRepoUnavailablePresentation(reason);
toast.error(presentation.title, {
description: presentation.description,
});
return;
}
toast.success("Remote state refreshed.");
}, [repoSnapshotQuery, repoStateQuery, repoSyncStatusQuery]);
// Compact branch + remote/local controls shared by the readme and Files
// tab headers.
}, [
memberChannelIds,
repoSnapshotQuery,
repoStateQuery,
repoSyncStatusQuery,
repository?.channelId,
]);
const cloneBlockedByRemote =
remoteUnavailableReason !== undefined &&
remoteUnavailableReason !== "ref" &&
remoteUnavailableReason !== "unknown";
const filesSourceControls: RepoSourceHeaderControls = {
branch: activeBranch ?? "",
branchOptions: branchOptionsWithLocal,
@@ -421,8 +418,16 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
? "Local"
: "Local missing",
...repoRemote.controls,
remoteUnavailableReason,
onAskForAccess: () => {
repositoryPanel.setMode("chat");
repositoryPanel.expand();
},
onCloneLocal:
!selectedTag && repository?.cloneUrls[0] && repoRemote.canCloneLocally
!selectedTag &&
!cloneBlockedByRemote &&
repository?.cloneUrls[0] &&
repoRemote.canCloneLocally
? () => {
void handleCloneRepo();
}
@@ -467,9 +472,6 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
const projectPending = projectQuery.isPending;
React.useEffect(() => {
if (!repository) {
// While the project query is still loading, keep the URL-seeded
// pullRequestId/issueId selections — clearing here would discard them
// before the detail view ever gets a chance to open.
if (projectPending) return;
setSelectedPullRequestId(null);
setSelectedIssueId(null);
@@ -490,90 +492,28 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
return currentSource;
});
}, [hasLocalCheckout, hasRemoteSnapshot, selectedTag]);
const peoplePubkeys = React.useMemo(() => {
if (!repository) return [];
// Include PR authors/updaters so commit rows can resolve avatars for
// publishers who are not listed as project contributors.
const pullRequestPubkeys = (pullRequestsQuery.data ?? []).flatMap(
(pullRequest) => [
pullRequest.author,
...pullRequest.updates.map((update) => update.author),
...pullRequest.comments.map((comment) => comment.author),
...pullRequest.reviewers,
...pullRequest.approvals.map((approval) => approval.author),
],
);
const issuePubkeys = (issuesQuery.data ?? []).flatMap((issue) => [
issue.author,
...issue.recipients,
...issue.assignees,
...issue.comments.map((comment) => comment.author),
]);
return [
...new Set([
...projectPeople(repository),
...pullRequestPubkeys,
...issuePubkeys,
]),
];
}, [issuesQuery.data, pullRequestsQuery.data, repository]);
const profilesQuery = useUsersBatchQuery(peoplePubkeys, {
enabled: peoplePubkeys.length > 0,
const {
contributorActivityCounts,
contributorPubkeys,
identityPubkey,
profiles,
viewerGitIdentity,
} = useProjectDetailPeople({
issues: issuesQuery.data ?? [],
pullRequests: pullRequestsQuery.data ?? [],
repository,
});
const currentProfileQuery = useProfileQuery();
const profiles = React.useMemo(
() =>
mergeCurrentProfileIntoLookup(
profilesQuery.data?.profiles,
currentProfileQuery.data,
),
[currentProfileQuery.data, profilesQuery.data?.profiles],
);
const identityQuery = useIdentityQuery();
const gitIdentityQuery = useGitIdentityQuery();
const viewerGitIdentity = React.useMemo<ViewerGitIdentity | null>(() => {
const pubkey = identityQuery.data?.pubkey ?? null;
if (!pubkey || !gitIdentityQuery.data) return null;
return {
pubkey,
name: gitIdentityQuery.data.name,
email: gitIdentityQuery.data.email,
};
}, [gitIdentityQuery.data, identityQuery.data?.pubkey]);
const { applyPatch, values } = useHistorySearchState(
PROJECT_DETAIL_PANEL_SEARCH_KEYS,
);
const profilePanelPubkey = values.profile;
const profilePanelTab = profilePanelTabFromSearch(values.profileTab);
const profilePanelView = profilePanelViewFromSearch(values.profileView);
const handleOpenProfilePanel = React.useCallback(
(pubkey: string) =>
applyPatch({ profile: pubkey, profileTab: null, profileView: null }),
[applyPatch],
);
const handleCloseProfilePanel = React.useCallback(
() => applyPatch({ profile: null, profileTab: null, profileView: null }),
[applyPatch],
);
const handleProfilePanelViewChange = React.useCallback(
(view: ProfilePanelView, options?: { replace?: boolean }) =>
applyPatch({ profileView: view === "summary" ? null : view }, options),
[applyPatch],
);
const handleProfilePanelTabChange = React.useCallback(
(tab: ProfilePanelTab, options?: { replace?: boolean }) =>
applyPatch({ profileTab: tab === "info" ? null : tab }, options),
[applyPatch],
);
const {
handleCloseProfilePanel,
handleOpenDm,
handleOpenProfilePanel,
handleProfilePanelTabChange,
handleProfilePanelViewChange,
profilePanelPubkey,
profilePanelTab,
profilePanelView,
} = useProjectProfilePanel();
const threadPanelWidth = useThreadPanelWidth();
const openDmMutation = useOpenDmMutation();
const handleOpenDm = React.useCallback(
async (pubkeys: string[]) => {
const dm = await openDmMutation.mutateAsync({ pubkeys });
await goChannel(dm.id);
},
[goChannel, openDmMutation],
);
const handlePushLocalRepo = React.useCallback(async () => {
try {
const result = await pushLocalRepoMutation.mutateAsync();
@@ -612,9 +552,23 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
toast.success(result.message);
setRepoSource("local");
} catch (error) {
showProjectCloneErrorToast(error, repository?.cloneUrls[0]);
const unavailableReason = refineRepoUnavailableReason({
reason: projectRepoUnavailableReason(error),
repositoryChannelId: repository?.channelId,
memberChannelIds,
});
showProjectCloneErrorToast(
error,
repository?.cloneUrls[0],
unavailableReason,
);
}
}, [cloneRepoMutation, repository?.cloneUrls]);
}, [
cloneRepoMutation,
memberChannelIds,
repository?.channelId,
repository?.cloneUrls,
]);
const handlePullRequestCreated = React.useCallback(
async (
createdProject: Project,
@@ -723,6 +677,20 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
},
[activeCommunity?.reposDir, repository],
);
const projectTerminalContext = React.useMemo(() => {
const channelId = repository?.channelId ?? project?.projectChannelId;
if (!channelId) return null;
return {
channelId,
channelName: repository?.name ?? project?.name ?? "Project",
};
}, [
project?.name,
project?.projectChannelId,
repository?.channelId,
repository?.name,
]);
useTerminalContextOverride(projectTerminalContext);
if (projectQuery.isLoading) {
return <ViewLoadingFallback kind="projects" />;
@@ -788,6 +756,13 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
}
const repoContributors = repoSnapshotQuery.data?.contributors ?? [];
const displayedRepositorySnapshot =
repoSource === "local"
? (localRepoSnapshotQuery.data?.snapshot ?? null)
: repoSnapshotQuery.data;
const displayedRepositoryContributors =
displayedRepositorySnapshot?.contributors ?? repoContributors;
const displayedRepositoryFiles = displayedRepositorySnapshot?.files ?? [];
const selectedPullRequest =
pullRequestsQuery.data?.find((item) => item.id === selectedPullRequestId) ??
null;
@@ -802,42 +777,44 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
(commit) => commit.hash === selectedCommitHash,
) ?? null)
: null;
// The active work item drives the breadcrumb trail: Projects › project ›
// sub-tab › title. `clear` steps back to the item's list tab. Categories
// match the workspace tab labels.
const activeWorkItemCrumb = selectedPullRequest
? {
category: "Review",
title: selectedPullRequest.title,
clear: () => setSelectedPullRequestId(null),
}
: selectedIssue
? {
category: "Tasks",
title: selectedIssue.title,
clear: () => setSelectedIssueId(null),
}
: selectedCommitHash
? {
category: "Commits",
title: selectedCommit?.subject ?? selectedCommitHash.slice(0, 7),
clear: () => setSelectedCommitHash(null),
}
: null;
// Sub-tab crumb when no work item is open. Overview (readme) is home.
const activeTabCrumb = activeWorkItemCrumb
? null
: (PROJECT_TAB_CRUMB_LABELS[activeTab] ?? null);
const handleGoToProjectHome = () => {
setSelectedPullRequestId(null);
setSelectedIssueId(null);
setSelectedCommitHash(null);
setRequestedTab(undefined);
// Remount the workspace tabs so the project page opens on Overview
// instead of whatever tab the work item left behind.
setTabsResetKey((key) => key + 1);
};
const { activeTabCrumb, activeWorkItemCrumb, handleGoToProjectHome } =
buildProjectDetailCrumbs({
activeTab,
commit: selectedCommit,
issue: selectedIssue,
pullRequest: selectedPullRequest,
setRequestedTab,
setSelectedCommitHash,
setSelectedIssueId,
setSelectedPullRequestId,
setTabsResetKey,
});
const agentPageContext = buildProjectDetailAgentContext({
activeTab,
branch: activeBranch,
file: filesContext,
project,
repository,
source: repoSource,
workItems: [selectedCommit, selectedIssue, selectedPullRequest],
});
const repositoryPanelAction = (
<ProjectRightPanelControls
collapsed={repositoryPanel.collapsed}
mode={repositoryPanel.mode}
onCollapse={repositoryPanel.collapse}
onExpand={repositoryPanel.expand}
onModeChange={repositoryPanel.setMode}
terminalAvailable={projectTerminalContext !== null}
/>
);
const sharedHeaderBackdrop =
repositoryPanel.mode === "chat" &&
!repositoryPanel.collapsed &&
!profilePanelPubkey &&
!selectedPullRequestId &&
!selectedIssueId &&
!selectedCommitHash;
const handleRepositoryChange = (nextRepositoryId: string) => {
applyRepositorySearch({
repositoryId: nextRepositoryId,
@@ -865,24 +842,52 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
<ProjectConversationPanelController
canResetWidth={threadPanelWidth.canReset}
closeWhen={Boolean(profilePanelPubkey)}
onOpenConversation={() =>
applyPatch({ profile: null, profileTab: null, profileView: null })
fallbackPanel={
profilePanelPubkey || repositoryPanel.collapsed ? null : (
<ProjectDetailRightPanel
canResetWidth={threadPanelWidth.canReset}
contributors={displayedRepositoryContributors}
context={agentPageContext}
files={displayedRepositoryFiles}
identityPubkey={identityPubkey}
mode={repositoryPanel.mode}
onOpenTerminal={() => {
void handleOpenTerminal();
}}
onRepositoryChange={handleRepositoryChange}
onResetWidth={threadPanelWidth.onResetWidth}
onResizeStart={threadPanelWidth.onResizeStart}
profiles={profiles}
pullRequests={pullRequestsQuery.data ?? []}
project={project}
projects={projectsQuery.data ?? []}
repository={repository}
sharedHeaderBackdrop={sharedHeaderBackdrop}
snapshot={displayedRepositorySnapshot}
sourceControls={filesSourceControls}
terminalTitle={projectTerminalLabel(hasLocalCheckout)}
widthPx={threadPanelWidth.widthPx}
/>
)
}
onOpenConversation={handleCloseProfilePanel}
onResetWidth={threadPanelWidth.onResetWidth}
onResizeStart={threadPanelWidth.onResizeStart}
resetKey={`${repository.id}:${selectedPullRequestId ?? ""}:${selectedIssueId ?? ""}:${selectedCommitHash ?? ""}`}
sharedHeaderBackdrop={sharedHeaderBackdrop}
widthPx={threadPanelWidth.widthPx}
>
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden [container-type:inline-size]">
<ProjectDetailChrome
actions={repositoryPanelAction}
activeTabCrumb={activeTabCrumb}
activeWorkItemCrumb={activeWorkItemCrumb}
chromeRef={projectDetailHeaderChromeRef}
onGoProjectHome={handleGoToProjectHome}
onGoProjects={() => {
void goProjects();
}}
project={project}
repository={repository}
shareTab={
activeWorkItemCrumb
? undefined
@@ -890,8 +895,11 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
}
/>
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden overflow-y-auto px-4 pb-4">
<div className="w-full space-y-3 pt-[calc(var(--buzz-channel-content-top-padding,5.75rem)_+_1px)]">
<div
className="flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden overflow-y-auto overscroll-y-none px-4 pb-4"
data-testid="project-detail-scroll"
>
<div className="w-full space-y-3">
<WorkspaceTabs
key={`${project.id}:${repository.id}:${tabsResetKey}`}
initialTab={
@@ -903,6 +911,8 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
commitDiff={commitDiffQuery.data}
commitDiffError={commitDiffQuery.error}
commitDiffLoading={commitDiffQuery.isLoading}
contributorActivityCounts={contributorActivityCounts}
contributorPubkeys={contributorPubkeys}
createIssueAction={{
onCreate: handleCreateIssue,
pending: createIssueMutation.isPending,
@@ -929,11 +939,8 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
localSnapshotError={localRepoSnapshotQuery.error}
localSnapshotLoading={localRepoSnapshotQuery.isLoading}
onBranchChange={handleBranchChange}
onFilesContextChange={setFilesContext}
onOpenMergeRecoveryTerminal={handleOpenMergeRecoveryTerminal}
onOpenTerminal={() => {
void handleOpenTerminal();
}}
terminalTitle={projectTerminalLabel(hasLocalCheckout)}
onSelectedCommitHashChange={handleSelectedCommitHashChange}
onSelectedIssueIdChange={handleSelectedIssueIdChange}
onSelectedPullRequestIdChange={
@@ -942,15 +949,6 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
onSelectedTabChange={setActiveTab}
profiles={profiles}
project={repository}
repositoryControls={
<ProjectDetailChromeActions
identityPubkey={identityQuery.data?.pubkey}
onRepositoryChange={handleRepositoryChange}
project={project}
projects={projectsQuery.data ?? []}
repository={repository}
/>
}
projectId={project.id}
repoDiff={displayedRepoDiff}
repoDiffError={displayedRepoDiffError}
@@ -964,6 +962,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
selectedCommitHash={selectedCommitHash}
selectedIssueId={selectedIssueId}
selectedPullRequestId={selectedPullRequestId}
sharedHeaderBackdrop={sharedHeaderBackdrop}
snapshot={repoSnapshotQuery.data}
snapshotError={repoSnapshotQuery.error}
snapshotLoading={repoSnapshotQuery.isLoading}
@@ -976,7 +975,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
{profilePanelPubkey ? (
<UserProfilePanel
canResetWidth={threadPanelWidth.canReset}
currentPubkey={identityQuery.data?.pubkey}
currentPubkey={identityPubkey}
onClose={handleCloseProfilePanel}
onOpenDm={handleOpenDm}
onOpenProfile={handleOpenProfilePanel}

View File

@@ -40,7 +40,7 @@ export function ProjectDetailSection({
>
<button
aria-expanded={open}
className="flex min-h-8 w-full min-w-0 items-center gap-2 px-4 py-1.5 text-left text-sm font-semibold leading-5 text-foreground transition-colors hover:bg-muted/20"
className="flex min-h-9 w-full min-w-0 items-center gap-2 px-4 py-1.5 text-left text-base font-normal leading-5 text-foreground transition-colors hover:bg-muted/20"
onClick={() => setOpen(!open)}
type="button"
>
@@ -52,7 +52,7 @@ export function ProjectDetailSection({
!open && "-rotate-90",
)}
/>
{count != null ? (
{open && count != null ? (
<span className="shrink-0 font-medium text-muted-foreground">
{count}
</span>

View File

@@ -25,7 +25,7 @@ export function ProjectFeedRow({
}) {
return (
<article
className="group/feed-item flex min-w-0 items-center justify-between gap-3 p-3 transition-colors hover:bg-muted/35"
className="group/feed-item flex min-w-0 items-center justify-between gap-3 px-4 py-3 transition-colors hover:bg-muted/35"
data-project-event-id={eventId}
data-testid={testId}
>

View File

@@ -1,9 +1,12 @@
import {
Circle,
CircleCheck,
CircleDashed,
CircleDot,
CircleX,
MessageSquare,
Tag,
type LucideIcon,
User,
} from "lucide-react";
import * as React from "react";
@@ -32,11 +35,6 @@ import { useIdentityQuery } from "@/shared/api/hooks";
import type { ChannelMember } from "@/shared/api/types";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { IssueAssigneeFacepile, IssueAssigneesRow } from "./IssueAssigneesRow";
import {
ProjectFeedRow,
ProjectFeedRowCluster,
ProjectFeedRowMonoCell,
} from "./ProjectFeedRow";
import { DiscussedInChannels } from "./DiscussionChannels";
import { ProjectIssueCommentTimeline } from "./ProjectIssueCommentTimeline";
import { ProjectOriginReference } from "./ProjectOriginReference";
@@ -49,23 +47,71 @@ import { ProjectDetailSection } from "./ProjectDetailSection";
import { ProfileIdentityButton } from "./ProjectProfileIdentity";
import { ProjectRichContent } from "./ProjectRichContent";
import { ShareLinkButton } from "./ShareLinkButton";
import {
ProjectStatusProgressIcon,
type ProjectStatusProgressState,
} from "./ProjectStatusProgressIcon";
import { ProjectWorkItemGroup } from "./ProjectWorkItemGroup";
import { ProjectWorkItemRow } from "./ProjectWorkItemRow";
export function issueStatusClassName(status: ProjectIssue["status"]) {
if (status === "Triage" || status === "In Progress") return "text-amber-500";
if (status === "Backlog") return "text-muted-foreground";
if (status === "In Review") return "text-green-500";
if (status === "Done") return "text-purple-400";
if (status === "Closed") return "text-destructive";
return "text-green-500";
return "text-muted-foreground";
}
function issueStatusVisual(status: ProjectIssue["status"]) {
function issueStatusVisual(status: ProjectIssue["status"]): {
className: string;
icon: LucideIcon;
progress: ProjectStatusProgressState;
} {
if (status === "Done") {
return { className: "text-purple-400", icon: CircleCheck };
return {
className: "text-purple-400",
icon: CircleCheck,
progress: "completed",
};
}
if (status === "Closed") {
return { className: "text-destructive", icon: CircleX };
return {
className: "text-destructive",
icon: CircleX,
progress: "canceled",
};
}
return { className: "text-green-500", icon: CircleDot };
if (status === "Backlog") {
return {
className: issueStatusClassName(status),
icon: Circle,
progress: "queued",
};
}
if (status === "Triage") {
return {
className: issueStatusClassName(status),
icon: CircleDashed,
progress: "queued",
};
}
return {
className: issueStatusClassName(status),
icon: CircleDot,
progress: status === "In Review" ? "review" : "started",
};
}
const ISSUE_STATUS_ORDER: readonly ProjectIssue["status"][] = [
"In Review",
"In Progress",
"Triage",
"Backlog",
"Done",
"Closed",
];
function issueMembers(
project: Project,
issue: ProjectIssue,
@@ -103,71 +149,89 @@ function IssueRow({
const authorProfile = profiles?.[normalizePubkey(issue.author)];
const authorLabel = resolveUserLabel({ profiles, pubkey: issue.author });
const status = issueStatusVisual(issue.status);
const labels = projectTaskUserLabels(issue.labels);
return (
<ProjectFeedRow
meta={
<>
<ProfileIdentityButton
avatarClassName="shrink-0"
avatarSize="xs"
avatarUrl={authorProfile?.avatarUrl ?? null}
isAgent={authorProfile?.isAgent === true}
label={authorLabel}
pubkey={issue.author}
showLabel={false}
/>
<span className="truncate text-foreground/80">
<span className="font-medium">{authorLabel}</span> created this task
</span>
<span>·</span>
<span>{projectTaskCategoryLabel(issue.category)}</span>
<span>·</span>
<span>{issue.status}</span>
{labels.map((label) => (
<span
className="rounded-full border border-border/60 px-1.5 py-0.5 text-2xs"
key={label}
>
{label}
</span>
))}
</>
}
<ProjectWorkItemRow
eventId={issue.id}
identifier={`#${issue.id.slice(0, 8)}`}
identifierTitle="View task"
onOpen={onOpen}
statusIcon={
<status.icon className={`h-3.5 w-3.5 shrink-0 ${status.className}`} />
<ProjectStatusProgressIcon
aria-label={issue.status}
className={`h-3.5 w-3.5 shrink-0 ${status.className}`}
state={status.progress}
/>
}
testId="project-issue-row"
title={issue.title}
trailing={
<>
<IssueAssigneeFacepile
assignees={issue.assignees}
profiles={profiles}
/>
{issue.comments.length > 0 ? (
<span
className="hidden w-24 shrink-0 truncate text-right text-xs text-muted-foreground md:block"
data-testid="project-issue-row-category"
>
{projectTaskCategoryLabel(issue.category)}
</span>
<span className="flex w-20 shrink-0 items-center justify-end gap-1">
<span
className="flex h-5 w-5 shrink-0 items-center justify-center"
data-testid="project-issue-creator"
title={`Created by ${authorLabel}`}
>
<ProfileIdentityButton
avatarClassName="shrink-0"
avatarSize="xs"
avatarUrl={authorProfile?.avatarUrl ?? null}
isAgent={authorProfile?.isAgent === true}
label={authorLabel}
pubkey={issue.author}
showLabel={false}
/>
</span>
<span
className="flex min-w-5 shrink-0 justify-end text-muted-foreground/45"
data-testid="project-issue-assignee-cell"
>
{issue.assignees.length > 0 ? (
<IssueAssigneeFacepile
assignees={issue.assignees}
profiles={profiles}
/>
) : (
<span
className="flex h-5 w-5 items-center justify-center rounded-full border border-border/70 bg-muted/35"
data-testid="project-issue-assignee-placeholder"
title="Unassigned"
>
<User aria-hidden="true" className="h-3 w-3" />
<span className="sr-only">Unassigned</span>
</span>
)}
</span>
</span>
<span className="flex w-8 shrink-0 justify-end">
<button
aria-label={`View ${issue.comments.length} comments`}
className="flex items-center gap-1 rounded-md text-xs text-muted-foreground hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
aria-label={
issue.comments.length > 0
? `View ${issue.comments.length} comments`
: "View comments"
}
className={`flex items-center gap-1 rounded-md text-xs hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring ${
issue.comments.length > 0
? "text-muted-foreground"
: "text-muted-foreground/45"
}`}
data-testid="project-issue-comments"
onClick={onOpen}
type="button"
>
<MessageSquare className="h-3.5 w-3.5" />
{issue.comments.length}
</button>
) : null}
<ProjectFeedRowCluster>
<ProjectFeedRowMonoCell
label={`#${issue.id.slice(0, 8)}`}
onClick={onOpen}
title="View task"
/>
</ProjectFeedRowCluster>
</span>
<span
className="hidden w-20 shrink-0 text-right text-xs text-muted-foreground sm:block"
className="hidden w-20 shrink-0 text-right text-xs text-muted-foreground/70 sm:block"
data-testid="project-issue-row-date"
title={new Date(issue.createdAt * 1_000).toLocaleString()}
>
@@ -315,19 +379,22 @@ export function ProjectIssueDetail({
key={issue.id}
profiles={profiles}
/>
<div data-testid="project-issue-comment-composer">
<ForumComposer
className="border border-border/60 bg-background/45"
disabled={commentMutation.isPending}
isSending={commentMutation.isPending}
members={members}
onSubmit={handleCommentSubmit}
placeholder="Add a comment…"
profiles={profiles}
/>
</div>
</div>
</ProjectDetailSection>
<div
className="px-4 pb-4 pt-2"
data-testid="project-issue-comment-composer"
>
<ForumComposer
className="border border-border/60 bg-background/45"
disabled={commentMutation.isPending}
isSending={commentMutation.isPending}
members={members}
onSubmit={handleCommentSubmit}
placeholder="Add a comment…"
profiles={profiles}
/>
</div>
</div>
);
}
@@ -372,16 +439,38 @@ export function ProjectIssuesPanel({
);
}
const groups = ISSUE_STATUS_ORDER.map((status) => ({
items: issues.filter((issue) => issue.status === status),
status,
})).filter((group) => group.items.length > 0);
return (
<div className="divide-y divide-border/50">
{issues.map((issue) => (
<IssueRow
issue={issue}
key={issue.id}
onOpen={() => onSelectedIssueIdChange(issue.id)}
profiles={profiles}
/>
))}
<div>
{groups.map(({ items, status }) => {
const visual = issueStatusVisual(status);
return (
<ProjectWorkItemGroup
count={items.length}
icon={
<ProjectStatusProgressIcon
className={`h-4 w-4 ${visual.className}`}
state={visual.progress}
/>
}
key={status}
label={status}
>
{items.map((issue) => (
<IssueRow
issue={issue}
key={issue.id}
onOpen={() => onSelectedIssueIdChange(issue.id)}
profiles={profiles}
/>
))}
</ProjectWorkItemGroup>
);
})}
</div>
);
}

View File

@@ -1,53 +1,22 @@
import {
FileCode2,
GitBranch,
GitCommitHorizontal,
GitPullRequest,
Users,
} from "lucide-react";
import type * as React from "react";
import { cn } from "@/shared/lib/cn";
import type {
ProjectPullRequest,
ProjectRepoContributor,
ProjectRepoFile,
ProjectRepoSnapshot,
Repository as Project,
} from "@/features/projects/hooks";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import {
LANGUAGE_DOT_CLASSES,
languageForPath,
topLanguagesFromCounts,
} from "@/features/projects/lib/projectLanguages";
import type { ProjectRepoFile } from "@/features/projects/hooks";
import type { ProjectRepoUnavailableReason } from "@/features/projects/lib/projectRepoAvailability";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import {
PROJECT_DETAIL_META_GRID_WIDE_CLASS,
PROJECT_DETAIL_META_RAIL_WIDE_BORDER_CLASS,
PROJECT_DETAIL_PANEL_CLASS,
} from "./projectPanelStyles";
import type * as React from "react";
import { cn } from "@/shared/lib/cn";
import { ReadmePanel } from "./ProjectReadmePanel";
import type { RepoSourceHeaderControls } from "./ProjectRepositorySource";
type ProjectOverviewPanelProps = {
/** `buzz-channel` binding of the repository, for access-restricted copy. */
accessChannelId?: string | null;
contributors: ProjectRepoContributor[];
externalHost?: string;
externalUrl?: string | null;
files: ProjectRepoFile[];
gitDataState: GitDataState;
/** Hide the readme header rows when the workspace renders them itself. */
hideReadmeHeader?: boolean;
project: Project;
onViewContributors: () => void;
profiles?: UserProfileLookup;
pullRequests: ProjectPullRequest[];
ownerAvatarUrl?: string | null;
ownerIsAgent?: boolean;
ownerName?: string;
readmeFile: ProjectRepoFile | null;
snapshot: ProjectRepoSnapshot | null | undefined;
/** Branch picker + remote/local toggle for the readme header. */
sourceControls?: RepoSourceHeaderControls;
unavailableReason?: ProjectRepoUnavailableReason;
@@ -55,78 +24,6 @@ type ProjectOverviewPanelProps = {
export type GitDataState = "checking" | "available" | "empty" | "unavailable";
function topLanguages(files: ProjectRepoFile[]) {
const counts: Record<string, number> = {};
for (const file of files) {
const language = languageForPath(file.path);
if (language) counts[language] = (counts[language] ?? 0) + 1;
}
return topLanguagesFromCounts(counts);
}
function projectPeople(project: Project) {
return [
...new Set(
[project.owner, ...project.contributors]
.filter(Boolean)
.map(normalizePubkey),
),
];
}
function PeopleAvatars({
people,
profiles,
}: {
people: string[];
profiles?: UserProfileLookup;
}) {
return (
<div className="flex flex-wrap gap-1.5">
{people.slice(0, 18).map((person) => {
const profile = profiles?.[normalizePubkey(person)];
const label =
profile?.displayName?.trim() ||
profile?.nip05Handle?.trim() ||
person;
return (
<UserAvatar
accent={profile?.isAgent === true}
avatarUrl={profile?.avatarUrl ?? null}
displayName={label}
key={person}
size="sm"
/>
);
})}
</div>
);
}
export function LanguageChips({
languages,
}: {
languages: Array<[string, number]>;
}) {
return (
<div className="flex flex-wrap gap-1.5">
{languages.map(([language], index) => (
<span
className="inline-flex items-center gap-1.5 rounded-full border border-border/60 bg-muted/40 px-2 py-1 text-xs text-muted-foreground"
key={language}
>
<span
className={`h-2 w-2 rounded-full ${
LANGUAGE_DOT_CLASSES[index % LANGUAGE_DOT_CLASSES.length]
}`}
/>
{language}
</span>
))}
</div>
);
}
export function OverviewRailSection({
children,
title,
@@ -150,135 +47,34 @@ export function OverviewRailSection({
export function ProjectOverviewPanel({
accessChannelId,
contributors,
externalHost,
externalUrl,
files,
gitDataState,
hideReadmeHeader,
onViewContributors,
project,
profiles,
pullRequests,
ownerAvatarUrl,
ownerIsAgent,
ownerName,
readmeFile,
snapshot,
sourceControls,
unavailableReason,
}: ProjectOverviewPanelProps) {
const languages = topLanguages(files);
const people = projectPeople(project);
const latestCommit = snapshot?.latestCommit ?? null;
const gitDataAvailable = gitDataState === "available";
const unavailableSplash = gitDataState === "unavailable";
return (
<div
className={cn(
"grid",
PROJECT_DETAIL_PANEL_CLASS,
!unavailableSplash && PROJECT_DETAIL_META_GRID_WIDE_CLASS,
)}
data-project-detail-panel
>
<div className="min-w-0">
{/* ReadmePanel renders its own "no README" fallback while keeping
the branch + source controls reachable. */}
<ReadmePanel
accessChannelId={accessChannelId}
externalHost={externalHost}
externalUrl={externalUrl}
file={readmeFile}
gitDataState={gitDataState}
hideHeader={hideReadmeHeader}
sourceControls={sourceControls}
unavailableReason={unavailableReason}
/>
</div>
{!unavailableSplash ? (
<aside
className={cn(
"space-y-6 border-t border-border/60 p-4",
PROJECT_DETAIL_META_RAIL_WIDE_BORDER_CLASS,
)}
>
<OverviewRailSection title="People">
<div className="flex items-center justify-between gap-3">
<PeopleAvatars people={people} profiles={profiles} />
<button
className="shrink-0 rounded-md text-xs font-medium text-primary hover:underline focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
onClick={onViewContributors}
type="button"
>
View all
</button>
</div>
</OverviewRailSection>
<OverviewRailSection title="Top Languages">
{languages.length > 0 ? (
<LanguageChips languages={languages} />
) : (
<p className="text-sm text-muted-foreground">
No language data is available yet.
</p>
)}
</OverviewRailSection>
<OverviewRailSection title="Buzz Activity">
<dl className="space-y-2 text-sm">
<div className="flex items-center justify-between gap-3">
<dt className="flex items-center gap-1.5 text-muted-foreground">
<GitPullRequest className="h-3.5 w-3.5" />
Reviews
</dt>
<dd className="font-medium text-foreground">
{pullRequests.length}
</dd>
</div>
</dl>
</OverviewRailSection>
<OverviewRailSection title="Git">
<dl className="space-y-2 text-sm">
<div className="flex items-center justify-between gap-3">
<dt className="flex items-center gap-1.5 text-muted-foreground">
<GitBranch className="h-3.5 w-3.5" />
Branch
</dt>
<dd className="font-medium text-foreground">
{project.defaultBranch}
</dd>
</div>
<div className="flex items-center justify-between gap-3">
<dt className="flex items-center gap-1.5 text-muted-foreground">
<GitCommitHorizontal className="h-3.5 w-3.5" />
Latest
</dt>
<dd className="font-mono text-xs text-foreground">
{gitDataAvailable && latestCommit
? latestCommit.hash.slice(0, 7)
: "—"}
</dd>
</div>
<div className="flex items-center justify-between gap-3">
<dt className="flex items-center gap-1.5 text-muted-foreground">
<FileCode2 className="h-3.5 w-3.5" />
Files
</dt>
<dd className="font-medium text-foreground">
{gitDataAvailable ? files.length : "—"}
</dd>
</div>
<div className="flex items-center justify-between gap-3">
<dt className="flex items-center gap-1.5 text-muted-foreground">
<Users className="h-3.5 w-3.5" />
Contributors
</dt>
<dd className="font-medium text-foreground">
{gitDataAvailable ? contributors.length : "—"}
</dd>
</div>
</dl>
</OverviewRailSection>
</aside>
) : null}
<div className="min-w-0">
{/* ReadmePanel renders its own "no README" fallback while keeping
repository recovery actions reachable. */}
<ReadmePanel
accessChannelId={accessChannelId}
externalHost={externalHost}
externalUrl={externalUrl}
file={readmeFile}
gitDataState={gitDataState}
hideHeader={hideReadmeHeader}
ownerAvatarUrl={ownerAvatarUrl}
ownerIsAgent={ownerIsAgent}
ownerName={ownerName}
sourceControls={sourceControls}
unavailableReason={unavailableReason}
/>
</div>
);
}

View File

@@ -8,7 +8,6 @@ import {
MessageSquare,
TriangleAlert,
UserPlus,
X,
} from "lucide-react";
import * as React from "react";
import { toast } from "sonner";
@@ -50,6 +49,12 @@ import { ProjectRichContent } from "./ProjectRichContent";
import { PullRequestMetaHeader } from "./PullRequestMetaRail";
import { PullRequestReviewCard } from "./PullRequestReviewCard";
import { ShareLinkButton } from "./ShareLinkButton";
import {
ProjectStatusProgressIcon,
type ProjectStatusProgressState,
} from "./ProjectStatusProgressIcon";
import { ProjectWorkItemGroup } from "./ProjectWorkItemGroup";
import { ProjectWorkItemRow } from "./ProjectWorkItemRow";
export { PullRequestMetaHeader } from "./PullRequestMetaRail";
@@ -73,6 +78,22 @@ function pullRequestStatusClassName(status: ProjectPullRequest["status"]) {
return "text-green-500";
}
const PULL_REQUEST_STATUS_ORDER: readonly ProjectPullRequest["status"][] = [
"Open",
"Draft",
"Merged",
"Closed",
];
function pullRequestProgressState(
status: ProjectPullRequest["status"],
): ProjectStatusProgressState {
if (status === "Draft") return "queued";
if (status === "Merged") return "completed";
if (status === "Closed") return "canceled";
return "review";
}
function pullRequestMembers(
project: Project,
pullRequest: ProjectPullRequest,
@@ -240,17 +261,53 @@ function PullRequestRow({
}) {
const authorProfile = profileForPubkey(pullRequest.author, profiles);
const authorLabel = labelForPubkey(pullRequest.author, profiles);
const StatusIcon =
pullRequest.status === "Closed" || pullRequest.status === "Draft"
? X
: Check;
const statusClassName = pullRequestStatusClassName(pullRequest.status);
return (
<ProjectFeedRow
<ProjectWorkItemRow
eventId={pullRequest.id}
meta={
identifier={`#${pullRequest.id.slice(0, 8)}`}
identifierTitle="View review"
metadata={
pullRequest.branchName ? (
<span className="inline-flex min-w-0 items-center gap-1">
<GitBranch className="h-3 w-3 shrink-0" />
<span className="truncate">{pullRequest.branchName}</span>
</span>
) : undefined
}
onOpen={onOpen}
statusIcon={
<ProjectStatusProgressIcon
aria-label={pullRequest.status}
className={`h-3.5 w-3.5 shrink-0 ${statusClassName}`}
state={pullRequestProgressState(pullRequest.status)}
/>
}
testId="project-pull-request-row"
title={pullRequest.title}
trailing={
<>
<span className="flex w-8 shrink-0 justify-end">
<button
aria-label={
pullRequest.comments.length > 0
? `View ${pullRequest.comments.length} comments`
: "View comments"
}
className={`flex items-center gap-1 rounded-md text-xs hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring ${
pullRequest.comments.length > 0
? "text-muted-foreground"
: "text-muted-foreground/45"
}`}
data-testid="project-pull-request-comments"
onClick={onOpen}
type="button"
>
<MessageSquare className="h-3.5 w-3.5" />
{pullRequest.comments.length}
</button>
</span>
<ProfileIdentityButton
avatarClassName="shrink-0"
avatarSize="xs"
@@ -260,53 +317,8 @@ function PullRequestRow({
pubkey={pullRequest.author}
showLabel={false}
/>
<span className="truncate">
<ProfileAuthorName pubkey={pullRequest.author}>
{authorLabel}
</ProfileAuthorName>{" "}
created this review
</span>
{pullRequest.branchName ? (
<span className="inline-flex min-w-0 items-center gap-1 rounded-full border border-border/60 px-1.5 py-0.5 font-mono text-2xs">
<GitBranch className="h-3 w-3 shrink-0" />
<span className="truncate">{pullRequest.branchName}</span>
</span>
) : null}
<span
className={`rounded-full border border-border/60 px-1.5 py-0.5 text-2xs font-medium ${statusClassName}`}
>
{pullRequest.status}
</span>
</>
}
onOpen={onOpen}
statusIcon={
<StatusIcon className={`h-3.5 w-3.5 shrink-0 ${statusClassName}`} />
}
testId="project-pull-request-row"
title={pullRequest.title}
trailing={
<>
{pullRequest.comments.length > 0 ? (
<button
aria-label={`View ${pullRequest.comments.length} comments`}
className="flex items-center gap-1 rounded-md text-xs text-muted-foreground hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
onClick={onOpen}
type="button"
>
<MessageSquare className="h-3.5 w-3.5" />
{pullRequest.comments.length}
</button>
) : null}
<ProjectFeedRowCluster>
<ProjectFeedRowMonoCell
label={`#${pullRequest.id.slice(0, 8)}`}
onClick={onOpen}
title="View review"
/>
</ProjectFeedRowCluster>
<span
className="hidden w-20 shrink-0 text-right text-xs text-muted-foreground sm:block"
className="hidden w-20 shrink-0 text-right text-xs text-muted-foreground/70 sm:block"
data-testid="project-pull-request-row-date"
title={formatExactTimestamp(pullRequest.createdAt)}
>
@@ -794,23 +806,26 @@ export function ProjectPullRequestDetail({
/>
</div>
</div>
<div data-testid="project-pull-request-comment-composer">
<ForumComposer
className="border border-border/60 bg-background/45"
disabled={commentMutation.isPending}
isSending={commentMutation.isPending}
members={members}
onSecondarySubmit={
canRequestChanges ? handleChangeRequestSubmit : undefined
}
onSubmit={handleCommentSubmit}
placeholder="Add a comment…"
profiles={profiles}
secondarySubmitLabel="Request changes"
/>
</div>
</div>
</ProjectDetailSection>
<div
className="px-4 pb-4 pt-2"
data-testid="project-pull-request-comment-composer"
>
<ForumComposer
className="border border-border/60 bg-background/45"
disabled={commentMutation.isPending}
isSending={commentMutation.isPending}
members={members}
onSecondarySubmit={
canRequestChanges ? handleChangeRequestSubmit : undefined
}
onSubmit={handleCommentSubmit}
placeholder="Add a comment…"
profiles={profiles}
secondarySubmitLabel="Request changes"
/>
</div>
</div>
);
}
@@ -891,16 +906,37 @@ export function PullRequestsPanel({
);
}
const groups = PULL_REQUEST_STATUS_ORDER.map((status) => ({
items: pullRequests.filter((pullRequest) => pullRequest.status === status),
status,
})).filter((group) => group.items.length > 0);
return (
<div className="divide-y divide-border/50">
{pullRequests.map((pullRequest) => (
<PullRequestRow
key={pullRequest.id}
onOpen={() => onSelectedPullRequestIdChange(pullRequest.id)}
profiles={profiles}
pullRequest={pullRequest}
/>
))}
<div>
{groups.map(({ items, status }) => {
return (
<ProjectWorkItemGroup
count={items.length}
icon={
<ProjectStatusProgressIcon
className={`h-4 w-4 ${pullRequestStatusClassName(status)}`}
state={pullRequestProgressState(status)}
/>
}
key={status}
label={status}
>
{items.map((pullRequest) => (
<PullRequestRow
key={pullRequest.id}
onOpen={() => onSelectedPullRequestIdChange(pullRequest.id)}
profiles={profiles}
pullRequest={pullRequest}
/>
))}
</ProjectWorkItemGroup>
);
})}
</div>
);
}

View File

@@ -1,18 +1,11 @@
import {
BookOpen,
CircleAlert,
CloudOff,
DownloadCloud,
ExternalLink,
GitBranch,
Globe,
Loader2,
LockKeyhole,
RefreshCw,
} from "lucide-react";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { useChannelsQuery } from "@/features/channels/hooks";
import type { ProjectRepoFile } from "@/features/projects/hooks";
import type { ProjectRepoUnavailableReason } from "@/features/projects/lib/projectRepoAvailability";
import { Button } from "@/shared/ui/button";
@@ -29,6 +22,7 @@ import {
RepositoryBranchDropdown,
} from "./ProjectRepositorySource";
import { GitHubMark } from "./GitHubMark";
import { ProjectRepositoryUnavailableState } from "./ProjectRepositoryUnavailableState";
export function findReadmeFile(files: ProjectRepoFile[]) {
const readmes = files.filter((file) =>
@@ -92,48 +86,6 @@ function normalizeReadmeMarkdown(content: string) {
.trim();
}
/**
* Description for the access-restricted state. Links to the bound channel
* when it is visible to the viewer (public channels appear in the channel
* list even before joining); private channels fall back to generic copy.
*/
function AccessRestrictedDescription({
accessChannelId,
}: {
accessChannelId: string;
}) {
const { goChannel } = useAppNavigation();
const channelsQuery = useChannelsQuery();
const channel = channelsQuery.data?.find(
(candidate) => candidate.id === accessChannelId,
);
if (!channel) {
return (
<>
Repository access is granted through a channel you can’t see. Ask the
repository owner for an invite.
</>
);
}
return (
<>
Repository access is granted through{" "}
<button
aria-label={`Open repository access channel #${channel.name}`}
className="font-medium text-foreground underline-offset-2 hover:underline"
onClick={() => void goChannel(channel.id)}
type="button"
>
#{channel.name}
</button>
, and you’re not a member. Join the channel or ask the repository owner
for an invite.
</>
);
}
export function ReadmePanel({
accessChannelId,
file,
@@ -141,6 +93,9 @@ export function ReadmePanel({
externalHost,
externalUrl,
hideHeader,
ownerAvatarUrl,
ownerIsAgent,
ownerName,
sourceControls,
unavailableReason,
}: {
@@ -155,6 +110,9 @@ export function ReadmePanel({
* controls and last-changed timestamp itself.
*/
hideHeader?: boolean;
ownerAvatarUrl?: string | null;
ownerIsAgent?: boolean;
ownerName?: string;
unavailableReason?: ProjectRepoUnavailableReason;
/** Branch picker + remote/local toggle rendered in the panel header. */
sourceControls?: RepoSourceHeaderControls;
@@ -212,60 +170,22 @@ export function ReadmePanel({
}
if (gitDataState === "unavailable") {
const reason = unavailableReason ?? "unknown";
const unavailableContent = {
authentication: {
description:
"Buzz could not authenticate with this repository. Check your access and try again.",
icon: LockKeyhole,
title: "Repository access failed",
},
missing: {
description:
"The project announcement exists, but its git repository was not found on the Buzz relay.",
icon: CircleAlert,
title: "Repository not initialized",
},
access: {
description:
"Repository access is granted through its channel, and you’re not a member of the channel bound to this repository. Ask the repository owner for an invite.",
icon: LockKeyhole,
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.",
icon: LockKeyhole,
title: "No access channel bound",
},
network: {
description:
"The Buzz git service could not be reached. Check your connection and try again.",
icon: CloudOff,
title: "Couldn’t reach repository",
},
ref: {
description:
"The selected branch is advertised by the project but is missing from its git remote.",
icon: GitBranch,
title: "Branch unavailable",
},
unknown: {
description:
"Buzz could not load this repository. Try again or contact the project owner.",
icon: CircleAlert,
title: "Repository unavailable",
},
} satisfies Record<
ProjectRepoUnavailableReason,
{
description: string;
icon: typeof CircleAlert;
title: string;
}
>;
const unavailable = unavailableContent[reason];
const UnavailableIcon = unavailable.icon;
if (!externalHost) {
return (
<section className="overflow-hidden">
<ProjectRepositoryUnavailableState
accessChannelId={accessChannelId}
onAskForAccess={sourceControls?.onAskForAccess}
onRetry={sourceControls?.onFetch}
ownerAvatarUrl={ownerAvatarUrl}
ownerIsAgent={ownerIsAgent}
ownerName={ownerName}
reason={unavailableReason}
retryPending={sourceControls?.fetchPending}
/>
</section>
);
}
return (
<section className="overflow-hidden">
@@ -273,25 +193,16 @@ export function ReadmePanel({
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-xl border border-border/60 bg-muted/40 text-muted-foreground">
{externalHost === "github.com" ? (
<GitHubMark className="h-6 w-6" />
) : externalHost ? (
<Globe className="h-6 w-6" />
) : (
<UnavailableIcon className="h-6 w-6" />
<Globe className="h-6 w-6" />
)}
</div>
<h3 className="text-base font-semibold text-foreground">
{externalHost
? `Code hosted on ${externalHost}`
: unavailable.title}
Code hosted on {externalHost}
</h3>
<p className="mt-1 max-w-lg text-sm text-muted-foreground">
{externalHost ? (
"Clone this repository locally to explore its files, commits, and contributors in Buzz."
) : reason === "access" && accessChannelId ? (
<AccessRestrictedDescription accessChannelId={accessChannelId} />
) : (
unavailable.description
)}
Clone this repository locally to explore its files, commits, and
contributors in Buzz.
</p>
{externalUrl ? (
<a
@@ -304,22 +215,7 @@ export function ReadmePanel({
</a>
) : null}
<div className="mt-4 flex flex-wrap items-center justify-center gap-2">
{!externalHost && sourceControls?.onFetch ? (
<Button
disabled={sourceControls.fetchPending}
onClick={sourceControls.onFetch}
size="sm"
variant="outline"
>
{sourceControls.fetchPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
{sourceControls.fetchPending ? "Retrying…" : "Retry"}
</Button>
) : null}
{externalHost && sourceControls?.onCloneLocal ? (
{sourceControls?.onCloneLocal ? (
<Button
disabled={sourceControls.clonePending}
onClick={sourceControls.onCloneLocal}

View File

@@ -0,0 +1,381 @@
import {
DownloadCloud,
ExternalLink,
FileCode2,
GitCommitHorizontal,
GitPullRequest,
Loader2,
MessageCircle,
RefreshCw,
SquareTerminal,
UploadCloud,
Users,
} from "lucide-react";
import type * as React from "react";
import type {
Project,
ProjectPullRequest,
ProjectRepoContributor,
ProjectRepoFile,
ProjectRepoSnapshot,
Repository,
} from "@/features/projects/hooks";
import {
LANGUAGE_DOT_CLASSES,
languageForPath,
topLanguagesFromCounts,
} from "@/features/projects/lib/projectLanguages";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { Button } from "@/shared/ui/button";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import {
RepoSourceDropdown,
type RepoSourceHeaderControls,
RepositoryBranchDropdown,
} from "./ProjectRepositorySource";
import { ProjectRepositoryManagement } from "./ProjectRepositoryManagement";
type ProjectRepositoryActionsPanelProps = {
canResetWidth: boolean;
contributors: ProjectRepoContributor[];
files: ProjectRepoFile[];
identityPubkey?: string;
onOpenTerminal: () => void;
onRepositoryChange: (repositoryId: string) => void;
onResetWidth: () => void;
onResizeStart: (event: React.PointerEvent<HTMLButtonElement>) => void;
profiles?: UserProfileLookup;
pullRequests: ProjectPullRequest[];
project: Project;
projects: Project[];
repository: Repository;
snapshot: ProjectRepoSnapshot | null | undefined;
sourceControls: RepoSourceHeaderControls;
terminalTitle?: string;
widthPx: number;
};
function topLanguages(files: ProjectRepoFile[]) {
const counts: Record<string, number> = {};
for (const file of files) {
const language = languageForPath(file.path);
if (language) counts[language] = (counts[language] ?? 0) + 1;
}
return topLanguagesFromCounts(counts);
}
function RepositoryPanelSection({
children,
title,
}: {
children: React.ReactNode;
title: string;
}) {
return (
<section className="space-y-2.5">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{title}
</h3>
{children}
</section>
);
}
function RepositoryActionButton({
children,
disabled,
onClick,
title,
}: {
children: React.ReactNode;
disabled?: boolean;
onClick: () => void;
title?: string;
}) {
return (
<Button
className="h-8 justify-start gap-2 text-sm"
disabled={disabled}
onClick={onClick}
size="sm"
title={title}
type="button"
variant="outline"
>
{children}
</Button>
);
}
export function ProjectRepositoryActionsPanel({
canResetWidth,
contributors,
files,
identityPubkey,
onOpenTerminal,
onRepositoryChange,
onResetWidth,
onResizeStart,
profiles,
pullRequests,
project,
projects,
repository,
snapshot,
sourceControls,
terminalTitle,
widthPx,
}: ProjectRepositoryActionsPanelProps) {
const people = [
...new Set(
[repository.owner, ...repository.contributors]
.filter(Boolean)
.map(normalizePubkey),
),
];
const languages = topLanguages(files);
const latestCommit = snapshot?.latestCommit ?? null;
const cloneAction = sourceControls.localDisabled
? sourceControls.onCloneLocal
: undefined;
const fetchAction =
sourceControls.source === "local" ||
sourceControls.remoteKind !== "external"
? sourceControls.onFetch
: undefined;
const pullAction = sourceControls.canPull ? sourceControls.onPull : undefined;
const pushAction = sourceControls.canPush ? sourceControls.onPush : undefined;
return (
<RightAuxiliaryPane
canResetWidth={canResetWidth}
onResetWidth={onResetWidth}
onResizeStart={onResizeStart}
testId="project-repository-actions-panel"
widthPx={widthPx}
>
<div className="min-h-0 flex-1 space-y-6 overflow-y-auto p-5">
<RepositoryPanelSection title="Working copy">
<div className="grid gap-2 [&_button]:w-full [&_button]:justify-between [&_button]:text-sm">
<RepoSourceDropdown controls={sourceControls} />
<RepositoryBranchDropdown
branch={sourceControls.branch}
branchOptions={sourceControls.branchOptions}
createBranchDisabled={sourceControls.createBranchDisabled}
createBranchTitle={sourceControls.createBranchTitle}
deleteBranchDisabled={sourceControls.deleteBranchDisabled}
deleteBranchTitle={sourceControls.deleteBranchTitle}
onBranchChange={sourceControls.onBranchChange}
onCreateBranch={sourceControls.onCreateBranch}
onDeleteBranch={sourceControls.onDeleteBranch}
onTagChange={sourceControls.onTagChange}
selectedTag={sourceControls.selectedTag}
tagOptions={sourceControls.tagOptions}
/>
</div>
</RepositoryPanelSection>
<RepositoryPanelSection title="Actions">
<div className="grid gap-2">
{cloneAction ? (
<RepositoryActionButton
disabled={sourceControls.clonePending}
onClick={cloneAction}
>
{sourceControls.clonePending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<DownloadCloud className="h-3.5 w-3.5" />
)}
{sourceControls.clonePending ? "Cloning…" : "Clone"}
</RepositoryActionButton>
) : null}
{sourceControls.remoteUnavailableReason === "access" &&
sourceControls.onAskForAccess ? (
<RepositoryActionButton
onClick={sourceControls.onAskForAccess}
title="Open project chat to ask for repository access"
>
<MessageCircle className="h-3.5 w-3.5" />
Ask for access
</RepositoryActionButton>
) : null}
{fetchAction ? (
<RepositoryActionButton
disabled={sourceControls.fetchPending}
onClick={fetchAction}
title={sourceControls.fetchTitle}
>
{sourceControls.fetchPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
Fetch
</RepositoryActionButton>
) : null}
{pullAction ? (
<RepositoryActionButton
disabled={sourceControls.pullDisabled}
onClick={pullAction}
title={sourceControls.pullTitle}
>
{sourceControls.pullPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<DownloadCloud className="h-3.5 w-3.5" />
)}
Pull
{sourceControls.behindCount
? ` ${sourceControls.behindCount}`
: ""}
</RepositoryActionButton>
) : null}
{pushAction ? (
<RepositoryActionButton
disabled={sourceControls.pushDisabled}
onClick={pushAction}
title={sourceControls.pushTitle}
>
{sourceControls.pushPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<UploadCloud className="h-3.5 w-3.5" />
)}
Push
{sourceControls.aheadCount
? ` ${sourceControls.aheadCount}`
: ""}
</RepositoryActionButton>
) : null}
<RepositoryActionButton
onClick={onOpenTerminal}
title={terminalTitle ?? "Open terminal"}
>
<SquareTerminal className="h-3.5 w-3.5" />
Terminal
</RepositoryActionButton>
{sourceControls.externalUrl ? (
<Button
asChild
className="h-8 justify-start gap-2 text-sm"
size="sm"
variant="outline"
>
<a
href={sourceControls.externalUrl}
rel="noreferrer"
target="_blank"
>
<ExternalLink className="h-3.5 w-3.5" />
Open
</a>
</Button>
) : null}
</div>
<div className="grid gap-2 [&_button]:h-8 [&_button]:w-full [&_button]:justify-start [&_button]:text-sm">
<ProjectRepositoryManagement
identityPubkey={identityPubkey}
onChange={onRepositoryChange}
project={project}
projects={projects}
repository={repository}
/>
</div>
</RepositoryPanelSection>
<RepositoryPanelSection title="People">
<div className="flex flex-wrap gap-1.5">
{people.slice(0, 18).map((person) => {
const profile = profiles?.[person];
const label =
profile?.displayName?.trim() ||
profile?.nip05Handle?.trim() ||
person;
return (
<UserAvatar
accent={profile?.isAgent === true}
avatarUrl={profile?.avatarUrl ?? null}
displayName={label}
key={person}
size="sm"
/>
);
})}
</div>
</RepositoryPanelSection>
<RepositoryPanelSection title="Top languages">
{languages.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{languages.map(([language], index) => (
<span
className="inline-flex h-5 items-center gap-1.5 rounded-full border border-border/60 bg-muted/40 px-2 text-xs text-muted-foreground"
key={language}
>
<span
className={`h-2 w-2 rounded-full ${
LANGUAGE_DOT_CLASSES[index % LANGUAGE_DOT_CLASSES.length]
}`}
/>
{language}
</span>
))}
</div>
) : (
<p className="text-xs text-muted-foreground">
No language data available.
</p>
)}
</RepositoryPanelSection>
<RepositoryPanelSection title="Repository details">
<dl className="space-y-2 text-sm">
<div className="flex items-center justify-between gap-3">
<dt className="flex items-center gap-1.5 text-muted-foreground">
<GitPullRequest className="h-3.5 w-3.5" />
Reviews
</dt>
<dd className="font-medium text-foreground">
{pullRequests.length}
</dd>
</div>
<div className="flex items-center justify-between gap-3">
<dt className="flex items-center gap-1.5 text-muted-foreground">
<GitCommitHorizontal className="h-3.5 w-3.5" />
Latest
</dt>
<dd className="font-mono text-xs text-foreground">
{latestCommit ? latestCommit.hash.slice(0, 7) : "—"}
</dd>
</div>
<div className="flex items-center justify-between gap-3">
<dt className="flex items-center gap-1.5 text-muted-foreground">
<FileCode2 className="h-3.5 w-3.5" />
Files
</dt>
<dd
className="font-medium text-foreground"
data-testid="project-repository-file-count"
>
{snapshot ? files.length : "—"}
</dd>
</div>
<div className="flex items-center justify-between gap-3">
<dt className="flex items-center gap-1.5 text-muted-foreground">
<Users className="h-3.5 w-3.5" />
Contributors
</dt>
<dd className="font-medium text-foreground">
{snapshot ? contributors.length : "—"}
</dd>
</div>
</dl>
</RepositoryPanelSection>
</div>
</RightAuxiliaryPane>
);
}

View File

@@ -1,5 +1,5 @@
import * as React from "react";
import { Check, ShieldCheck } from "lucide-react";
import { Check, FolderPlus, Link, Plus, ShieldCheck } from "lucide-react";
import { toast } from "sonner";
import { useIsManagedAgent } from "@/features/agent-memory/hooks";
@@ -20,7 +20,6 @@ import {
} from "@/shared/ui/dropdown-menu";
import { AddProjectRepositoryDialog } from "./AddProjectRepositoryDialog";
import { AttachProjectRepositoryDialog } from "./AttachProjectRepositoryDialog";
import { ProjectRepositoryPicker } from "./ProjectRepositoryPicker";
export function ProjectRepositoryManagement({
identityPubkey,
@@ -131,13 +130,39 @@ export function ProjectRepositoryManagement({
project={project}
repositories={attachCandidates}
/>
<ProjectRepositoryPicker
onAttach={canEdit ? () => setAttachOpen(true) : undefined}
onChange={onChange}
onCreate={canEdit ? () => setCreateOpen(true) : undefined}
project={project}
repository={repository}
/>
{canEdit ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label="Add repository"
className="h-7 shrink-0 gap-1.5 rounded-md"
data-testid="add-project-repository"
size="sm"
type="button"
variant="outline"
>
<Plus className="h-3.5 w-3.5" />
Add
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem
data-testid="create-project-repository"
onSelect={() => setCreateOpen(true)}
>
<FolderPlus className="h-4 w-4" />
Create new repository
</DropdownMenuItem>
<DropdownMenuItem
data-testid="attach-project-repository"
onSelect={() => setAttachOpen(true)}
>
<Link className="h-4 w-4" />
Select existing repository
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
{canManageAccess ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>

View File

@@ -555,7 +555,7 @@ function FileContentPanel({
return (
<div className={PROJECT_DETAIL_PANEL_CLASS} data-project-detail-panel>
<div className="flex min-h-14 items-center gap-1 border-border/50 border-b bg-muted/20 px-3 py-3">
<div className="flex min-h-14 items-center gap-1 border-border/50 border-b bg-muted/20 px-4 py-3">
<BreadcrumbButton onClick={() => onOpenPath("")}>
Files
</BreadcrumbButton>
@@ -587,15 +587,15 @@ function FileContentPanel({
Last changed {formatLastChangedAt(file.lastChangedAt)}
</div>
{file.previewContent ? (
<pre className="max-h-[36rem] overflow-auto bg-background/60 p-4">
<pre className="overflow-x-auto bg-background/60 p-4">
{language ? (
<SyntaxHighlightedCode
className="text-xs leading-relaxed"
className="whitespace-pre-wrap break-words text-xs leading-relaxed"
code={file.previewContent}
language={language}
/>
) : (
<code className="block min-w-full whitespace-pre font-mono text-xs leading-relaxed text-foreground">
<code className="block min-w-full whitespace-pre-wrap break-words font-mono text-xs leading-relaxed text-foreground">
{file.previewContent}
</code>
)}
@@ -617,6 +617,7 @@ export function RepositoryFilesPanel({
error,
profiles,
fallbackAuthorPubkey,
onContextChange,
sourceControls,
unavailableMessage,
}: {
@@ -626,6 +627,10 @@ export function RepositoryFilesPanel({
error: unknown;
profiles?: UserProfileLookup;
fallbackAuthorPubkey?: string;
onContextChange?: (context: {
kind: "file" | "folder";
path: string;
}) => void;
/** Branch picker + remote/local toggle rendered in the panel header. */
sourceControls?: RepoSourceHeaderControls;
unavailableMessage?: string;
@@ -633,6 +638,12 @@ export function RepositoryFilesPanel({
const [currentPath, setCurrentPath] = React.useState("");
const [selectedFile, setSelectedFile] =
React.useState<ProjectRepoFile | null>(null);
React.useEffect(() => {
onContextChange?.({
kind: selectedFile ? "file" : "folder",
path: selectedFile?.path ?? currentPath,
});
}, [currentPath, onContextChange, selectedFile]);
const entries = React.useMemo(
() => repositoryEntries(files, currentPath),
[currentPath, files],
@@ -705,7 +716,7 @@ export function RepositoryFilesPanel({
}
return (
<div className={PROJECT_DETAIL_PANEL_CLASS} data-project-detail-panel>
<div className="flex min-h-14 min-w-0 items-center gap-1 border-border/50 border-b px-3 py-3">
<div className="flex min-h-14 min-w-0 items-center gap-1 border-border/50 border-b px-4 py-3">
<RepoSourceDropdown controls={sourceControls} />
<RepositoryBranchDropdown
branch={sourceControls.branch}
@@ -745,7 +756,7 @@ export function RepositoryFilesPanel({
return (
<div className={PROJECT_DETAIL_PANEL_CLASS} data-project-detail-panel>
{sourceControls || pathSegments.length > 0 ? (
<div className="flex min-h-14 min-w-0 items-center gap-1 border-border/50 border-b px-3 py-3">
<div className="flex min-h-14 min-w-0 items-center gap-1 border-border/50 border-b px-4 py-3">
{sourceControls ? (
<>
<RepoSourceDropdown controls={sourceControls} />
@@ -871,7 +882,7 @@ export function RepositoryFilesPanel({
}
tabIndex={0}
>
<td className="min-w-52 p-3 align-middle">
<td className="min-w-52 px-4 py-3 align-middle">
<div className="flex min-w-0 items-center gap-2">
<RepositoryEntryIcon entry={entry} />
<span className="truncate font-medium text-foreground">

View File

@@ -0,0 +1,30 @@
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { DrawerPanelIcon } from "@/shared/ui/DrawerPanelIcon";
export function ProjectRepositoryPanelToggle({
className,
expanded = false,
onClick,
}: {
className?: string;
expanded?: boolean;
onClick: () => void;
}) {
const action = expanded ? "Collapse" : "Expand";
return (
<Button
aria-label={`${action} repository panel`}
className={cn("h-7 w-7", className)}
data-testid={`project-repository-actions-${expanded ? "collapse" : "expand"}`}
onClick={onClick}
size="icon"
title={`${action} repository panel`}
type="button"
variant="ghost"
>
<DrawerPanelIcon side={expanded ? "right" : "left"} />
</Button>
);
}

View File

@@ -1,120 +0,0 @@
import {
Check,
ChevronDown,
FolderGit2,
FolderPlus,
Link,
Plus,
} from "lucide-react";
import type { Project, Repository } from "@/features/projects/hooks";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import { PROJECT_PICKER_TRIGGER_CLASS } from "./projectPanelStyles";
export function ProjectRepositoryPicker({
onAttach,
onChange,
onCreate,
project,
repository,
}: {
onAttach?: () => void;
onChange: (repositoryId: string) => void;
onCreate?: () => void;
project: Project;
repository: Repository;
}) {
const repositoryLabel = (
<>
<FolderGit2 className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate">{repository.name}</span>
</>
);
const unavailableRepositories = project.unavailableRepositoryAddresses ?? [];
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label="Select repository"
className={cn("max-w-64", PROJECT_PICKER_TRIGGER_CLASS)}
data-testid="project-repository-picker"
size="sm"
variant="outline"
>
{repositoryLabel}
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-56">
<DropdownMenuLabel>Repositories</DropdownMenuLabel>
{project.repositories.map((candidate) => (
<DropdownMenuItem
className="justify-between gap-4"
data-testid={`project-repository-${candidate.dtag}`}
key={candidate.id}
onSelect={() => onChange(candidate.id)}
>
<span className="min-w-0 truncate">{candidate.name}</span>
{candidate.id === repository.id ? (
<Check className="h-4 w-4 shrink-0" />
) : null}
</DropdownMenuItem>
))}
{unavailableRepositories.map((address) => (
<DropdownMenuItem
className="justify-between gap-4"
disabled
key={address}
>
<span className="min-w-0 truncate">
{address.slice(address.indexOf(":", 6) + 1)}
</span>
<span className="ml-auto text-xs text-muted-foreground">
Unavailable
</span>
</DropdownMenuItem>
))}
{onCreate && onAttach ? (
<>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger data-testid="add-project-repository">
<Plus className="h-4 w-4" />
Add repository
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem
data-testid="create-project-repository"
onSelect={onCreate}
>
<FolderPlus className="h-4 w-4" />
Create new repository
</DropdownMenuItem>
<DropdownMenuItem
data-testid="attach-project-repository"
onSelect={onAttach}
>
<Link className="h-4 w-4" />
Select existing repository
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -15,6 +15,7 @@ import {
} from "lucide-react";
import { Button } from "@/shared/ui/button";
import type { ProjectRepoUnavailableReason } from "@/features/projects/lib/projectRepoAvailability";
import {
DropdownMenu,
DropdownMenuContent,
@@ -65,9 +66,7 @@ export function RepositoryBranchDropdown({
const RefIcon = selectedTag ? Tag : GitBranch;
if (!branch) {
return (
<span className="truncate font-mono text-sm font-semibold text-foreground">
—
</span>
<span className="truncate text-sm font-semibold text-foreground">—</span>
);
}
return (
@@ -80,7 +79,7 @@ export function RepositoryBranchDropdown({
variant="outline"
>
<RefIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate font-mono">{selectedTag ?? branch}</span>
<span className="truncate">{selectedTag ?? branch}</span>
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
@@ -99,7 +98,7 @@ export function RepositoryBranchDropdown({
{selectableBranches.map((option) => (
<DropdownMenuRadioItem key={option} value={`branch:${option}`}>
<GitBranch className="mr-1.5 h-3.5 w-3.5 text-muted-foreground" />
<span className="truncate font-mono">{option}</span>
<span className="truncate">{option}</span>
</DropdownMenuRadioItem>
))}
{tagOptions.length > 0 ? (
@@ -112,7 +111,7 @@ export function RepositoryBranchDropdown({
value={`tag:${option.name}`}
>
<Tag className="mr-1.5 h-3.5 w-3.5 text-muted-foreground" />
<span className="truncate font-mono">{option.name}</span>
<span className="truncate">{option.name}</span>
<span className="ml-auto font-mono text-xs text-muted-foreground">
{option.commit.slice(0, 7)}
</span>
@@ -181,7 +180,10 @@ export type RepoSourceHeaderControls = {
localLabel: string;
remoteLabel: string;
remoteKind?: "buzz" | "external";
remoteUnavailableReason?: ProjectRepoUnavailableReason;
externalUrl?: string | null;
/** Opens repository-scoped assistance when remote access is restricted. */
onAskForAccess?: () => void;
/** Clones the repository when no local checkout is available. */
onCloneLocal?: () => void;
clonePending?: boolean;

View File

@@ -0,0 +1,190 @@
import {
CircleAlert,
CloudOff,
GitBranch,
Loader2,
LockKeyhole,
MessageCircle,
RefreshCw,
} from "lucide-react";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { useChannelsQuery } from "@/features/channels/hooks";
import {
type ProjectRepoUnavailableReason,
projectRepoUnavailablePresentation,
} from "@/features/projects/lib/projectRepoAvailability";
import { Button } from "@/shared/ui/button";
import { UserAvatar } from "@/shared/ui/UserAvatar";
const UNAVAILABLE_ICONS = {
authentication: LockKeyhole,
missing: CircleAlert,
access: LockKeyhole,
unbound: LockKeyhole,
network: CloudOff,
ref: GitBranch,
unknown: CircleAlert,
} satisfies Record<ProjectRepoUnavailableReason, typeof CircleAlert>;
function RepositoryOwnerReference({
ownerAvatarUrl,
ownerIsAgent,
ownerName,
}: {
ownerAvatarUrl?: string | null;
ownerIsAgent?: boolean;
ownerName?: string;
}) {
if (!ownerName) {
return <>the repository owner</>;
}
return (
<>
<span className="inline-flex items-center gap-1 align-middle">
<span aria-hidden="true">
<UserAvatar
accent={ownerIsAgent}
avatarUrl={ownerAvatarUrl ?? null}
displayName={ownerName}
fallbackDelayMs={0}
size="xs"
testId="repository-owner-avatar"
/>
</span>
<span
className="font-medium text-foreground"
data-testid="repository-owner-name"
>
{ownerName}
</span>
</span>
, the repository owner,
</>
);
}
function AccessRestrictedDescription({
accessChannelId,
ownerAvatarUrl,
ownerIsAgent,
ownerName,
}: {
accessChannelId?: string | null;
ownerAvatarUrl?: string | null;
ownerIsAgent?: boolean;
ownerName?: string;
}) {
const { goChannel } = useAppNavigation();
const channelsQuery = useChannelsQuery();
const channel = channelsQuery.data?.find(
(candidate) => candidate.id === accessChannelId,
);
if (!channel) {
return (
<>
Repository access is granted through a channel you can’t see. Ask{" "}
<RepositoryOwnerReference
ownerAvatarUrl={ownerAvatarUrl}
ownerIsAgent={ownerIsAgent}
ownerName={ownerName}
/>{" "}
for an invite.
</>
);
}
return (
<>
Repository access is granted through{" "}
<button
aria-label={`Open repository access channel #${channel.name}`}
className="font-medium text-foreground underline-offset-2 hover:underline"
onClick={() => void goChannel(channel.id)}
type="button"
>
#{channel.name}
</button>
, and you’re not a member. Join the channel or ask{" "}
<RepositoryOwnerReference
ownerAvatarUrl={ownerAvatarUrl}
ownerIsAgent={ownerIsAgent}
ownerName={ownerName}
/>{" "}
for an invite.
</>
);
}
export function ProjectRepositoryUnavailableState({
accessChannelId,
onAskForAccess,
onRetry,
ownerAvatarUrl,
ownerIsAgent,
ownerName,
reason = "unknown",
retryPending = false,
}: {
accessChannelId?: string | null;
onAskForAccess?: () => void;
onRetry?: () => void;
ownerAvatarUrl?: string | null;
ownerIsAgent?: boolean;
ownerName?: string;
reason?: ProjectRepoUnavailableReason;
retryPending?: boolean;
}) {
const unavailable = projectRepoUnavailablePresentation(reason);
const UnavailableIcon = UNAVAILABLE_ICONS[reason];
return (
<div
className="flex min-h-[calc(100dvh-7rem)] flex-col items-center justify-center p-8 text-center"
data-testid="project-repository-unavailable"
>
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-xl border border-border/60 bg-muted/40 text-muted-foreground">
<UnavailableIcon className="h-6 w-6" />
</div>
<h3 className="text-base font-semibold text-foreground">
{unavailable.title}
</h3>
<p className="mt-1 max-w-lg text-sm text-muted-foreground">
{reason === "access" ? (
<AccessRestrictedDescription
accessChannelId={accessChannelId}
ownerAvatarUrl={ownerAvatarUrl}
ownerIsAgent={ownerIsAgent}
ownerName={ownerName}
/>
) : (
unavailable.description
)}
</p>
<div className="mt-4 flex flex-wrap items-center justify-center gap-2">
{onRetry ? (
<Button
disabled={retryPending}
onClick={onRetry}
size="sm"
variant="outline"
>
{retryPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
{retryPending ? "Retrying…" : "Retry"}
</Button>
) : null}
{reason === "access" && onAskForAccess ? (
<Button onClick={onAskForAccess} size="sm">
<MessageCircle className="h-4 w-4" />
Ask for access
</Button>
) : null}
</div>
</div>
);
}

View File

@@ -0,0 +1,110 @@
import { Info, MessageCircle } from "lucide-react";
import {
toggleTerminalPanel,
useTerminalPanel,
} from "@/features/terminal/terminalPanelStore";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import terminalIcon from "@/shared/ui/assets/terminal.svg";
import { ProjectRepositoryPanelToggle } from "./ProjectRepositoryPanelToggle";
export type ProjectRightPanelMode = "chat" | "repository";
export function ProjectRightPanelControls({
collapsed,
mode,
onCollapse,
onExpand,
onModeChange,
terminalAvailable,
}: {
collapsed: boolean;
mode: ProjectRightPanelMode;
onCollapse: () => void;
onExpand: () => void;
onModeChange: (mode: ProjectRightPanelMode) => void;
terminalAvailable: boolean;
}) {
const terminalPanel = useTerminalPanel();
const terminalOpen = terminalPanel.mode !== "closed";
return (
<div className="flex items-center gap-0.5">
<Button
aria-label="Show repository information"
aria-pressed={!collapsed && mode === "repository"}
className={cn(
"h-7 w-7 text-sidebar-foreground/65 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
!collapsed &&
mode === "repository" &&
"bg-sidebar-accent text-sidebar-accent-foreground",
)}
data-testid="project-right-panel-repository-tab"
onClick={() => {
onModeChange("repository");
onExpand();
}}
size="icon"
title="Repository information"
type="button"
variant="ghost"
>
<Info className="h-4 w-4" />
</Button>
<Button
aria-label="Show project chat"
aria-pressed={!collapsed && mode === "chat"}
className={cn(
"h-7 w-7 text-sidebar-foreground/65 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
!collapsed &&
mode === "chat" &&
"bg-sidebar-accent text-sidebar-accent-foreground",
)}
data-testid="project-right-panel-chat-tab"
onClick={() => {
onModeChange("chat");
onExpand();
}}
size="icon"
title="Project chat"
type="button"
variant="ghost"
>
<MessageCircle className="h-4 w-4" />
</Button>
<Button
aria-label={terminalOpen ? "Hide Buzz Term" : "Open Buzz Term"}
aria-pressed={terminalOpen}
className={cn(
"h-7 w-7 text-sidebar-foreground/65 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
terminalOpen && "bg-sidebar-accent text-sidebar-accent-foreground",
)}
data-testid="project-terminal-toggle"
disabled={!terminalAvailable}
onClick={toggleTerminalPanel}
size="icon"
title="Buzz Term (⌘J)"
type="button"
variant="ghost"
>
<span
aria-hidden="true"
className="h-4 w-[1.1rem] bg-current [mask-position:center] [mask-repeat:no-repeat]"
data-testid="project-terminal-icon"
style={{
maskImage: `url("${terminalIcon}")`,
maskSize: "calc(100% - 2px) calc(100% - 2px)",
WebkitMaskImage: `url("${terminalIcon}")`,
WebkitMaskSize: "calc(100% - 2px) calc(100% - 2px)",
}}
/>
</Button>
<ProjectRepositoryPanelToggle
className="text-sidebar-foreground/65 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
expanded={!collapsed}
onClick={collapsed ? onExpand : onCollapse}
/>
</div>
);
}

View File

@@ -18,7 +18,7 @@ export function ProjectSectionHeader({
}) {
return (
<header
className="flex min-h-12 items-center gap-3 border-border/50 border-b px-4 py-2"
className="flex min-h-12 items-center gap-3 px-4 py-2"
data-testid="project-section-header"
>
<Icon

View File

@@ -0,0 +1,81 @@
import type * as React from "react";
import { cn } from "@/shared/lib/cn";
export type ProjectStatusProgressState =
| "queued"
| "started"
| "review"
| "completed"
| "canceled";
/** Linear-style circular indicator showing a work item's workflow progress. */
export function ProjectStatusProgressIcon({
"aria-label": ariaLabel,
className,
state,
...props
}: React.SVGProps<SVGSVGElement> & {
state: ProjectStatusProgressState;
}) {
return (
<svg
aria-label={ariaLabel ?? `${state} status`}
className={cn("shrink-0", className)}
data-project-status-progress={state}
fill="none"
role="img"
viewBox="0 0 16 16"
{...props}
>
{state === "queued" ? (
<circle
cx="8"
cy="8"
r="6"
stroke="currentColor"
strokeDasharray="1.5 1.8"
strokeLinecap="round"
strokeWidth="1.5"
/>
) : null}
{state === "started" || state === "review" ? (
<>
<circle
cx="8"
cy="8"
opacity="0.45"
r="6"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d={
state === "started"
? "M8 8V2A6 6 0 0 1 8 14Z"
: "M8 8V2A6 6 0 1 1 2 8Z"
}
fill="currentColor"
opacity="0.8"
/>
</>
) : null}
{state === "completed" || state === "canceled" ? (
<>
<circle className="fill-current" cx="8" cy="8" r="6.5" />
<path
className="stroke-background"
d={
state === "completed"
? "m4.75 8.1 2 2 4.5-4.5"
: "m5.5 5.5 5 5m0-5-5 5"
}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
/>
</>
) : null}
</svg>
);
}

View File

@@ -0,0 +1,34 @@
import type * as React from "react";
/** Groups related project work-item rows under a subtle status summary. */
export function ProjectWorkItemGroup({
children,
count,
icon,
label,
}: {
children: React.ReactNode;
count: number;
icon: React.ReactNode;
label: string;
}) {
return (
<section
className="pt-2 first:pt-0"
data-project-work-item-status={label}
data-testid="project-work-item-group"
>
<div
className="mx-2 flex h-9 items-center gap-1.5 rounded-md bg-muted/40 px-2 text-xs text-muted-foreground"
data-testid="project-work-item-group-header"
>
<span className="flex h-4 w-4 items-center justify-center opacity-80">
{icon}
</span>
<span className="font-medium text-foreground/80">{label}</span>
<span className="tabular-nums text-muted-foreground/65">{count}</span>
</div>
<div>{children}</div>
</section>
);
}

View File

@@ -0,0 +1,86 @@
import type * as React from "react";
import { cn } from "@/shared/lib/cn";
export function ProjectWorkItemRow({
eventId,
identifier,
identifierClassName,
identifierTitle,
metadata,
onOpen,
statusIcon,
testId,
title,
trailing,
}: {
eventId: string;
identifier: string;
identifierClassName?: string;
identifierTitle?: string;
metadata?: React.ReactNode;
onOpen?: () => void;
statusIcon: React.ReactNode;
testId: string;
title: string;
trailing?: React.ReactNode;
}) {
return (
<article
className="group/work-item flex min-h-10 min-w-0 items-center gap-2 px-4 py-2 transition-colors hover:bg-muted/30"
data-project-event-id={eventId}
data-testid={testId}
>
<span className="flex h-4 w-4 shrink-0 items-center justify-center opacity-80">
{statusIcon}
</span>
{onOpen ? (
<button
className={cn(
"w-[4.75rem] shrink-0 truncate rounded-sm text-left text-xs font-medium tabular-nums text-muted-foreground/70 transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring",
identifierClassName,
)}
onClick={onOpen}
title={identifierTitle}
type="button"
>
{identifier}
</button>
) : (
<span
className={cn(
"w-[4.75rem] shrink-0 truncate text-xs font-medium tabular-nums text-muted-foreground/70",
identifierClassName,
)}
>
{identifier}
</span>
)}
{onOpen ? (
<button
className="min-w-0 truncate rounded-sm text-left text-sm font-normal text-foreground transition-colors hover:text-primary focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
onClick={onOpen}
title={title}
type="button"
>
{title}
</button>
) : (
<span className="min-w-0 truncate text-sm font-normal text-foreground">
{title}
</span>
)}
{metadata ? (
<div className="hidden min-w-0 shrink items-center gap-1.5 overflow-hidden text-xs text-muted-foreground lg:flex">
<span className="text-muted-foreground/45">›</span>
{metadata}
</div>
) : null}
{trailing ? (
<div className="ml-auto flex shrink-0 items-center gap-2 pl-2">
{trailing}
</div>
) : null}
</article>
);
}

View File

@@ -1,40 +1,29 @@
import { BookOpen } from "lucide-react";
import { Glasses } from "lucide-react";
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-sm 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";
"h-7 shrink-0 rounded-full bg-muted/30 px-3 text-xs font-medium leading-5 tracking-tight text-muted-foreground shadow-none transition-colors hover:bg-muted/55 hover:text-foreground data-[state=active]:bg-muted data-[state=active]:text-foreground data-[state=active]:shadow-none";
const PROJECT_TAB_SELECTED_CLASS =
"font-semibold text-foreground after:opacity-100";
const PROJECT_TAB_SELECTED_CLASS = "bg-muted text-foreground";
const PROJECT_OVERVIEW_TAB_CLASS =
"h-8 w-8 shrink-0 rounded-md p-2 text-muted-foreground shadow-none hover:bg-muted/50 hover:text-foreground data-[state=active]:bg-muted/50 data-[state=active]:text-foreground data-[state=active]:shadow-none";
"h-7 w-7 shrink-0 rounded-full bg-muted/30 p-1.5 text-muted-foreground shadow-none transition-colors hover:bg-muted/55 hover:text-foreground data-[state=active]:bg-muted data-[state=active]:text-foreground data-[state=active]:shadow-none";
function ProjectTabLabel({ children }: { children: string }) {
return (
<span className="grid">
<span
aria-hidden="true"
className="invisible col-start-1 row-start-1 font-semibold"
>
{children}
</span>
<span className="col-start-1 row-start-1">{children}</span>
</span>
);
return <span>{children}</span>;
}
export function ProjectTabsList({ prsActive }: { prsActive?: boolean }) {
return (
<TabsList className="h-full min-w-0 max-w-full flex-none justify-start gap-1 overflow-x-auto bg-transparent p-0 scrollbar-none">
<TabsList className="h-full min-w-0 max-w-full flex-none justify-start gap-1.5 overflow-x-auto bg-transparent p-0 scrollbar-none">
<TabsTrigger
aria-label="Overview"
className={PROJECT_OVERVIEW_TAB_CLASS}
title="README"
value="overview"
>
<BookOpen className="h-full w-full" strokeWidth={2} />
<Glasses className="h-full w-full" strokeWidth={2} />
</TabsTrigger>
<TabsTrigger className={PROJECT_TAB_TRIGGER_CLASS} value="files">
<ProjectTabLabel>Files</ProjectTabLabel>
@@ -58,9 +47,6 @@ export function ProjectTabsList({ prsActive }: { prsActive?: boolean }) {
<TabsTrigger className={PROJECT_TAB_TRIGGER_CLASS} value="channels">
<ProjectTabLabel>Channels</ProjectTabLabel>
</TabsTrigger>
<TabsTrigger className={PROJECT_TAB_TRIGGER_CLASS} value="channels">
<ProjectTabLabel>Channels</ProjectTabLabel>
</TabsTrigger>
<TabsTrigger className={PROJECT_TAB_TRIGGER_CLASS} value="contributors">
<ProjectTabLabel>Contributors</ProjectTabLabel>
</TabsTrigger>

View File

@@ -6,7 +6,6 @@ import {
GitPullRequest,
Hash,
RefreshCw,
SquareTerminal,
Users,
} from "lucide-react";
import * as React from "react";
@@ -23,27 +22,20 @@ import type {
} from "@/features/projects/hooks";
import {
commitAuthorPubkeysFromPullRequests,
gitContributorPubkeysFromCommits,
type ProjectContributorActivityCounts,
type ViewerGitIdentity,
} from "@/features/projects/lib/projectContributorMatching";
import { repositoryDiscussionQuery } from "@/features/projects/lib/discussionChannels";
import type { ProjectRepoHost } from "@/features/projects/lib/projectRepoHost";
import {
projectRepoUnavailableReason,
refineRepoUnavailableReason,
} from "@/features/projects/lib/projectRepoAvailability";
import { useMemberChannelIds } from "@/features/projects/useRepositoryAccess";
import { projectRepoUnavailableReason } from "@/features/projects/lib/projectRepoAvailability";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { cn } from "@/shared/lib/cn";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { Button } from "@/shared/ui/button";
import { Tabs, TabsContent } from "@/shared/ui/tabs";
import { findReadmeFile } from "./ProjectReadmePanel";
import { RepositoryFilesPanel } from "./ProjectRepositoryPanel";
import {
type RepoSourceHeaderControls,
RepoSourceDropdown,
RepoSyncActionButton,
RepositoryBranchDropdown,
} from "./ProjectRepositorySource";
import type { RepoSourceHeaderControls } from "./ProjectRepositorySource";
import { DiscussionChannelsPanel } from "./DiscussionChannels";
import { ProjectCommitDetailPanel } from "./ProjectCommitDetailPanel";
import { ActivityPanel, ContributorsPanel } from "./ProjectDetailFeedPanels";
@@ -56,7 +48,9 @@ import {
import { PullRequestsPanel } from "./ProjectPullRequestsPanel";
import { ProjectTabsList } from "./ProjectWorkspaceTabList";
import { ProjectPullRequestFilesChangedPanel } from "./ProjectPullRequestFilesChangedPanel";
import { ProjectRepositoryUnavailableState } from "./ProjectRepositoryUnavailableState";
import {
PROJECT_COLUMN_HEADER_BACKDROP_CLASS,
PROJECT_DETAIL_PANEL_CLASS,
PROJECT_DETAIL_PANEL_MESSAGE_CLASS,
} from "./projectPanelStyles";
@@ -91,6 +85,8 @@ export function WorkspaceTabs({
commitDiff,
commitDiffError,
commitDiffLoading,
contributorActivityCounts,
contributorPubkeys,
createIssueAction,
createPullRequestAction,
updatePullRequestAction,
@@ -107,16 +103,17 @@ export function WorkspaceTabs({
selectedCommitHash,
selectedIssueId,
selectedPullRequestId,
sharedHeaderBackdrop,
pullRequests,
pullRequestsError,
pullRequestsLoading,
onSelectedCommitHashChange,
onFilesContextChange,
onSelectedIssueIdChange,
onSelectedPullRequestIdChange,
onSelectedTabChange,
onBranchChange,
onOpenMergeRecoveryTerminal,
onOpenTerminal,
snapshot,
snapshotError,
snapshotLoading,
@@ -124,14 +121,14 @@ export function WorkspaceTabs({
repoContributors,
repoSource,
repoHost,
repositoryControls,
sourceControls,
terminalTitle,
viewerGitIdentity,
}: {
commitDiff: ProjectRepoDiff | null | undefined;
commitDiffError: unknown;
commitDiffLoading: boolean;
contributorActivityCounts: Record<string, ProjectContributorActivityCounts>;
contributorPubkeys: string[];
createIssueAction: CreateIssueAction;
createPullRequestAction?: CreatePullRequestAction;
updatePullRequestAction?: UpdatePullRequestAction;
@@ -150,17 +147,21 @@ export function WorkspaceTabs({
selectedCommitHash: string | null;
selectedIssueId: string | null;
selectedPullRequestId: string | null;
sharedHeaderBackdrop?: boolean;
pullRequests: ProjectPullRequest[];
pullRequestsError: unknown;
pullRequestsLoading: boolean;
onSelectedCommitHashChange: (hash: string | null) => void;
onFilesContextChange?: (context: {
kind: "file" | "folder";
path: string;
}) => void;
onSelectedIssueIdChange: (id: string | null) => void;
onSelectedPullRequestIdChange: (id: string | null) => void;
/** Reports the active tab so the screen breadcrumb can mirror it. */
onSelectedTabChange?: (tab: string) => void;
onBranchChange: (branch: string | null) => void;
onOpenMergeRecoveryTerminal?: OpenMergeRecoveryTerminal;
onOpenTerminal?: () => void;
snapshot: ProjectRepoSnapshot | null | undefined;
snapshotError: unknown;
snapshotLoading: boolean;
@@ -168,11 +169,8 @@ export function WorkspaceTabs({
repoContributors: ProjectRepoContributor[];
repoSource: "remote" | "local";
repoHost: ProjectRepoHost;
/** Repository picker (and related actions) for the selection header row. */
repositoryControls?: React.ReactNode;
/** Branch picker + remote/local toggle for the selection header row. */
/** Branch and source state used by repository content and recovery actions. */
sourceControls?: RepoSourceHeaderControls;
terminalTitle?: string;
viewerGitIdentity?: ViewerGitIdentity | null;
}) {
const localCheckoutSnapshot = localSnapshot?.snapshot ?? null;
@@ -184,8 +182,21 @@ export function WorkspaceTabs({
repoSource === "local" ? localSnapshotLoading : snapshotLoading;
const displayedContributors =
displayedSnapshot?.contributors ?? repoContributors;
const contributorPubkeysByGitIdentity = React.useMemo(
() =>
gitContributorPubkeysFromCommits(
displayedSnapshot?.commits ?? [],
pullRequests,
),
[displayedSnapshot?.commits, pullRequests],
);
const files = displayedSnapshot?.files ?? [];
const readmeFile = React.useMemo(() => findReadmeFile(files), [files]);
const ownerProfile = profiles?.[normalizePubkey(project.owner)];
const ownerName =
ownerProfile?.displayName?.trim() ||
ownerProfile?.nip05Handle?.trim() ||
undefined;
const externalHost =
repoSource === "remote" && repoHost.kind === "external"
? repoHost.host
@@ -197,20 +208,28 @@ export function WorkspaceTabs({
: files.length === 0
? "empty"
: "available";
// The relay masks channel-ACL denials as 404 (anti-enumeration), so a
// "missing" git result is re-classified with the repository's channel
// binding and the viewer's memberships before it reaches the UI copy.
const memberChannelIds = useMemberChannelIds();
const unavailableReason =
gitDataState === "unavailable" && !externalHost
? refineRepoUnavailableReason({
reason: projectRepoUnavailableReason(displayedSnapshotError),
repositoryChannelId: project.channelId,
memberChannelIds,
})
? repoSource === "remote"
? (sourceControls?.remoteUnavailableReason ??
projectRepoUnavailableReason(displayedSnapshotError))
: displayedSnapshotError
? projectRepoUnavailableReason(displayedSnapshotError)
: undefined
: undefined;
const repositoryLoaded =
gitDataState === "available" || gitDataState === "empty";
const repositoryUnavailableState =
unavailableReason && !externalHost ? (
<ProjectRepositoryUnavailableState
accessChannelId={project.channelId}
onAskForAccess={sourceControls?.onAskForAccess}
onRetry={sourceControls?.onFetch}
ownerAvatarUrl={ownerProfile?.avatarUrl}
ownerIsAgent={ownerProfile?.isAgent}
ownerName={ownerName}
reason={unavailableReason}
retryPending={sourceControls?.fetchPending}
/>
) : null;
const commitAuthorPubkeys = React.useMemo(
() => commitAuthorPubkeysFromPullRequests(pullRequests),
[pullRequests],
@@ -304,12 +323,10 @@ export function WorkspaceTabs({
},
[selectedPullRequestId],
);
const showRepositorySelection =
selectedTab === "overview" || selectedTab === "files";
const sectionHeader =
selectedTab === "overview" ? (
selectedTab === "overview" && readmeFile?.previewContent ? (
<ProjectSectionHeader icon={BookOpen} title="README" />
) : selectedTab === "files" ? (
) : selectedTab === "files" && files.length > 0 ? (
<ProjectSectionHeader icon={FilesIcon} title="Files" />
) : selectedTab === "activity" && !selectedCommitHash ? (
<ProjectSectionHeader icon={GitCommitHorizontal} title="Commits" />
@@ -348,24 +365,14 @@ export function WorkspaceTabs({
onValueChange={handleTabChange}
value={selectedTab}
>
{repositoryLoaded && !isDetailSelected ? (
{!isDetailSelected ? (
<div
className="flex h-10 min-w-0 items-center gap-1"
className={`sticky top-0 z-30 -mx-4 flex h-13 min-w-0 items-center gap-1 px-4 ${
sharedHeaderBackdrop ? "" : PROJECT_COLUMN_HEADER_BACKDROP_CLASS
}`}
data-testid="project-workspace-tab-menu"
>
<ProjectTabsList prsActive={isPullRequestSelected} />
{onOpenTerminal ? (
<Button
aria-label="Open terminal"
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
onClick={() => onOpenTerminal()}
size="icon"
title={terminalTitle ?? "Open terminal"}
variant="ghost"
>
<SquareTerminal className="h-[1.125rem] w-[1.125rem]" />
</Button>
) : null}
{updatePullRequestAction ? (
<Button
className="h-8 shrink-0 gap-1.5"
@@ -381,102 +388,65 @@ export function WorkspaceTabs({
) : null}
</div>
) : null}
{/* The bordered workspace starts with repository / source / branch
controls only for README and Files, then the active panel. Inner
panels retain standalone chrome, neutralized here. */}
{/* Project content follows the same borderless flow as work-item details.
Inner panels retain standalone chrome, neutralized here. */}
<div
className={cn(
PROJECT_DETAIL_PANEL_CLASS,
"[&_[data-project-detail-panel]]:rounded-none [&_[data-project-detail-panel]]:border-0",
)}
data-project-detail-panel
className="-mx-4 [&_[data-project-detail-panel]]:rounded-none [&_[data-project-detail-panel]]:border-0"
data-testid="project-workspace-panel"
>
{showRepositorySelection && (repositoryControls || sourceControls) ? (
<div
className="flex min-h-12 min-w-0 flex-wrap items-center gap-1.5 border-border/50 border-b px-3 py-2"
data-testid="project-repository-selection-row"
>
{repositoryControls}
{sourceControls ? (
<>
<RepoSourceDropdown controls={sourceControls} />
<RepositoryBranchDropdown
branch={sourceControls.branch}
branchOptions={sourceControls.branchOptions}
createBranchDisabled={sourceControls.createBranchDisabled}
createBranchTitle={sourceControls.createBranchTitle}
deleteBranchDisabled={sourceControls.deleteBranchDisabled}
deleteBranchTitle={sourceControls.deleteBranchTitle}
onBranchChange={sourceControls.onBranchChange}
onCreateBranch={sourceControls.onCreateBranch}
onDeleteBranch={sourceControls.onDeleteBranch}
onTagChange={sourceControls.onTagChange}
selectedTag={sourceControls.selectedTag}
tagOptions={sourceControls.tagOptions}
/>
<div className="ml-auto flex shrink-0 items-center">
<RepoSyncActionButton controls={sourceControls} />
</div>
</>
) : null}
</div>
) : null}
{sectionHeader}
<TabsContent className="m-0" value="overview">
<ProjectOverviewPanel
accessChannelId={project.channelId}
contributors={displayedContributors}
externalHost={externalHost}
externalUrl={externalHost ? sourceControls?.externalUrl : null}
files={files}
gitDataState={gitDataState}
hideReadmeHeader
onViewContributors={() => setSelectedTab("contributors")}
profiles={profiles}
project={project}
pullRequests={pullRequests}
ownerAvatarUrl={ownerProfile?.avatarUrl}
ownerIsAgent={ownerProfile?.isAgent}
ownerName={ownerName}
readmeFile={readmeFile}
snapshot={displayedSnapshot}
sourceControls={sourceControls}
unavailableReason={unavailableReason}
/>
</TabsContent>
<TabsContent className="m-0" value="activity">
{selectedCommitHash ? (
<ProjectCommitDetailPanel
commit={
displayedSnapshot?.commits.find(
(commit) => commit.hash === selectedCommitHash,
) ?? null
}
commitAuthorPubkeys={commitAuthorPubkeys}
commitHash={selectedCommitHash}
viewerGitIdentity={viewerGitIdentity}
diff={commitDiff}
diffError={commitDiffError}
diffLoading={commitDiffLoading}
originAgentName={selectedCommitPullRequest?.originAgentName}
originChannelId={selectedCommitPullRequest?.channelId}
profiles={profiles}
/>
) : (
<ActivityPanel
branch={sourceControls?.branch}
error={displayedSnapshotError}
isLoading={displayedSnapshotLoading}
onSelectCommit={(commit) =>
onSelectedCommitHashChange(commit.hash)
}
profiles={profiles}
pullRequests={pullRequests}
repoContributors={displayedContributors}
snapshot={displayedSnapshot}
viewerGitIdentity={viewerGitIdentity}
/>
)}
{repositoryUnavailableState ??
(selectedCommitHash ? (
<ProjectCommitDetailPanel
commit={
displayedSnapshot?.commits.find(
(commit) => commit.hash === selectedCommitHash,
) ?? null
}
commitAuthorPubkeys={commitAuthorPubkeys}
commitHash={selectedCommitHash}
viewerGitIdentity={viewerGitIdentity}
diff={commitDiff}
diffError={commitDiffError}
diffLoading={commitDiffLoading}
originAgentName={selectedCommitPullRequest?.originAgentName}
originChannelId={selectedCommitPullRequest?.channelId}
profiles={profiles}
project={project}
/>
) : (
<ActivityPanel
branch={sourceControls?.branch}
error={displayedSnapshotError}
isLoading={displayedSnapshotLoading}
onSelectCommit={(commit) =>
onSelectedCommitHashChange(commit.hash)
}
profiles={profiles}
pullRequests={pullRequests}
repoContributors={displayedContributors}
snapshot={displayedSnapshot}
viewerGitIdentity={viewerGitIdentity}
/>
))}
</TabsContent>
<TabsContent
@@ -495,7 +465,8 @@ export function WorkspaceTabs({
}
error={pullRequestsError}
filesChanged={
selectedPullRequest ? (
repositoryUnavailableState ??
(selectedPullRequest ? (
<ProjectPullRequestFilesChangedPanel
diff={repoDiff}
error={repoDiffError}
@@ -510,7 +481,7 @@ export function WorkspaceTabs({
project={project}
pullRequest={selectedPullRequest}
/>
) : undefined
) : undefined)
}
filesCount={repoDiff?.files.length}
forceOpenFiles={
@@ -542,37 +513,48 @@ export function WorkspaceTabs({
</TabsContent>
<TabsContent className="m-0" value="files">
{repoSource === "local" && !localSnapshot && !localSnapshotLoading ? (
<div className="mb-3">
<div
className={PROJECT_DETAIL_PANEL_MESSAGE_CLASS}
data-project-detail-panel
>
No local checkout found.
{repositoryUnavailableState ??
(repoSource === "local" &&
!localSnapshot &&
!localSnapshotLoading ? (
<div className="mb-3">
<div
className={PROJECT_DETAIL_PANEL_MESSAGE_CLASS}
data-project-detail-panel
>
No local checkout found.
</div>
</div>
</div>
) : null}
<RepositoryFilesPanel
error={displayedSnapshotError}
fallbackAuthorPubkey={project.owner}
files={files}
isLoading={displayedSnapshotLoading}
profiles={profiles}
snapshot={displayedSnapshot}
unavailableMessage={
externalHost
? `Not mirrored on Buzz. Repository files are hosted on ${externalHost}.`
: undefined
}
/>
) : (
<RepositoryFilesPanel
error={displayedSnapshotError}
fallbackAuthorPubkey={project.owner}
files={files}
isLoading={displayedSnapshotLoading}
onContextChange={onFilesContextChange}
profiles={profiles}
snapshot={displayedSnapshot}
unavailableMessage={
externalHost
? `Not mirrored on Buzz. Repository files are hosted on ${externalHost}.`
: undefined
}
/>
))}
</TabsContent>
<TabsContent className="m-0" value="channels">
<DiscussionChannelsPanel query={repositoryDiscussionQuery(project)} />
<DiscussionChannelsPanel
query={repositoryDiscussionQuery(project)}
repositoryName={project.name}
/>
</TabsContent>
<TabsContent className="m-0" value="contributors">
<ContributorsPanel
activityCounts={contributorActivityCounts}
contributorPubkeys={contributorPubkeys}
contributorPubkeysByGitIdentity={contributorPubkeysByGitIdentity}
profiles={profiles}
repoContributors={displayedContributors}
/>

View File

@@ -24,18 +24,24 @@ import { useChannelsQuery, useOpenDmMutation } from "@/features/channels/hooks";
import {
useChannelMessagesQuery,
useChannelSubscription,
useToggleReactionMutation,
} from "@/features/messages/hooks";
import { formatTimelineMessages } from "@/features/messages/lib/formatTimelineMessages";
import { getThreadReference } from "@/features/messages/lib/threading";
import { useLinkEditor } from "@/features/messages/lib/useLinkEditor";
import {
type LinkSelectionInfo,
useRichTextEditor,
} from "@/features/messages/lib/useRichTextEditor";
import { FormattingToolbar } from "@/features/messages/ui/FormattingToolbar";
import { TimelineMessageList } from "@/features/messages/ui/TimelineMessageList";
import type { TimelineMessage } from "@/features/messages/types";
import { useThreadRepliesForRoots } from "@/features/messages/useThreadReplies";
import { useProfileQuery, useUsersBatchQuery } from "@/features/profile/hooks";
import type { Project } from "@/features/projects/hooks";
import {
mergeProjectAgentConversationEvents,
restoreProjectsAgentConversation,
visibleConversationMessages,
} from "@/features/projects/lib/projectAgentConversation";
import {
clearStoredProjectsAgentConversation,
@@ -46,6 +52,10 @@ import {
import { useIdentityQuery } from "@/shared/api/hooks";
import { sendChannelMessage } from "@/shared/api/tauri";
import type { Channel } from "@/shared/api/types";
import {
KIND_STREAM_MESSAGE,
KIND_STREAM_MESSAGE_V2,
} from "@/shared/constants/kinds";
import { cn } from "@/shared/lib/cn";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { Button } from "@/shared/ui/button";
@@ -56,10 +66,9 @@ import {
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import { Markdown } from "@/shared/ui/markdown";
import { UserAvatar } from "@/shared/ui/UserAvatar";
type AgentCandidate = {
export type AgentCandidate = {
pubkey: string;
name: string;
/** Managed agents can be auto-started before the prompt is sent. */
@@ -101,7 +110,7 @@ function repoContextBlock(projects: readonly Project[]) {
/** Hides the machine-readable repo footer when rendering the user's own
* prompt back in the inline conversation. */
function stripRepoContext(content: string) {
export function stripRepoContext(content: string) {
const markerIndex = content.indexOf(`---\n${REPO_CONTEXT_MARKER}`);
if (markerIndex === -1) return content;
return content.slice(0, markerIndex).replace(/\n+$/, "");
@@ -134,7 +143,7 @@ function buildSuggestions(projects: readonly Project[]) {
}
/** Sorts runnable agents first so the default pick can answer immediately. */
function useAgentCandidates() {
export function useAgentCandidates() {
const identityQuery = useIdentityQuery();
const managedAgentsQuery = useManagedAgentsQuery();
const relayAgentsQuery = useRelayAgentsQuery();
@@ -186,12 +195,13 @@ function useAgentCandidates() {
/** Live message feed for the conversation's backing DM channel, reduced to
* plain chat rows (kind 9 / 40002 only). */
function ConversationThread({
export function ConversationThread({
channel,
agent,
agentAvatarUrl,
currentPubkey,
selfAvatarUrl,
stripSelfContent = stripRepoContext,
visibleAfter,
}: {
channel: Channel;
@@ -199,55 +209,132 @@ function ConversationThread({
agentAvatarUrl: string | null;
currentPubkey: string | null;
selfAvatarUrl: string | null;
stripSelfContent?: (content: string) => string;
visibleAfter: number;
}) {
useChannelSubscription(channel);
const messagesQuery = useChannelMessagesQuery(channel);
const agentWorking = useAgentWorking(agent.pubkey, channel.id);
const bottomRef = React.useRef<HTMLDivElement>(null);
const messages = React.useMemo(
() => visibleConversationMessages(messagesQuery.data ?? [], visibleAfter),
const threadRootIds = React.useMemo(
() =>
(messagesQuery.data ?? [])
.filter(
(event) =>
(event.kind === KIND_STREAM_MESSAGE ||
event.kind === KIND_STREAM_MESSAGE_V2) &&
event.created_at >= visibleAfter &&
getThreadReference(event.tags).parentId === null,
)
.map((event) => event.id),
[messagesQuery.data, visibleAfter],
);
const threadReplies = useThreadRepliesForRoots(channel, threadRootIds);
const toggleReactionMutation = useToggleReactionMutation();
const agentWorking = useAgentWorking(agent.pubkey, channel.id);
const bottomRef = React.useRef<HTMLDivElement>(null);
const normalizedCurrent = currentPubkey
? normalizePubkey(currentPubkey)
: null;
const profiles = React.useMemo(
() => ({
[normalizePubkey(agent.pubkey)]: {
avatarUrl: agentAvatarUrl,
displayName: agent.name,
isAgent: true,
name: agent.name,
nip05Handle: null,
ownerPubkey: null,
},
...(normalizedCurrent
? {
[normalizedCurrent]: {
avatarUrl: selfAvatarUrl,
displayName: "You",
isAgent: false,
name: null,
nip05Handle: null,
ownerPubkey: null,
},
}
: {}),
}),
[
agent.name,
agent.pubkey,
agentAvatarUrl,
normalizedCurrent,
selfAvatarUrl,
],
);
const messages = React.useMemo(() => {
const events = mergeProjectAgentConversationEvents(
messagesQuery.data ?? [],
threadReplies.events,
);
return formatTimelineMessages(
events,
channel,
currentPubkey ?? undefined,
selfAvatarUrl,
profiles,
)
.filter(
(message) =>
(message.kind === KIND_STREAM_MESSAGE ||
message.kind === KIND_STREAM_MESSAGE_V2) &&
message.createdAt >= visibleAfter,
)
.map((message) =>
normalizedCurrent &&
message.pubkey &&
normalizePubkey(message.pubkey) === normalizedCurrent
? { ...message, body: stripSelfContent(message.body) }
: message,
);
}, [
channel,
currentPubkey,
messagesQuery.data,
normalizedCurrent,
profiles,
selfAvatarUrl,
stripSelfContent,
threadReplies.events,
visibleAfter,
]);
const conversationEntries = React.useMemo(
() => messages.map((message) => ({ message, summary: null })),
[messages],
);
const lastMessageId = messages[messages.length - 1]?.id ?? null;
const handleToggleReaction = React.useCallback(
async (message: TimelineMessage, emoji: string, remove: boolean) => {
await toggleReactionMutation.mutateAsync({
emoji,
eventId: message.id,
remove,
});
},
[toggleReactionMutation.mutateAsync],
);
React.useEffect(() => {
if (!lastMessageId && !agentWorking.working) return;
bottomRef.current?.scrollIntoView({ block: "end" });
}, [lastMessageId, agentWorking.working]);
const normalizedCurrent = currentPubkey
? normalizePubkey(currentPubkey)
: null;
return (
<div className="space-y-5">
{messages.map((event) => {
const isSelf = normalizePubkey(event.pubkey) === normalizedCurrent;
return (
<div className="flex gap-3" key={event.localKey ?? event.id}>
<UserAvatar
accent={!isSelf}
avatarUrl={isSelf ? selfAvatarUrl : agentAvatarUrl}
className="mt-0.5 shrink-0"
displayName={isSelf ? "You" : agent.name}
size="sm"
/>
<div className="min-w-0 flex-1 space-y-0.5">
<span className="text-xs font-semibold text-muted-foreground">
{isSelf ? "You" : agent.name}
</span>
<Markdown
className="text-base text-foreground"
content={
isSelf ? stripRepoContext(event.content) : event.content
}
/>
</div>
</div>
);
})}
<div data-project-agent-channel-id={channel.id}>
<TimelineMessageList
channelId={channel.id}
channelName={agent.name}
channelType="dm"
currentPubkey={currentPubkey ?? undefined}
hideDayDividers
mainEntries={conversationEntries}
messages={messages}
onToggleReaction={handleToggleReaction}
profiles={profiles}
useVirtualizer={false}
/>
{agentWorking.working ? (
<div className="flex items-center gap-2 pl-11 text-sm text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />

View File

@@ -55,16 +55,12 @@ export function PullRequestMetaHeader({
return (
<ProjectDetailMetaList>
<ProjectDetailMetaRow icon={GitBranch} label="Branch">
<p className="flex min-w-0 flex-wrap items-center gap-1.5 text-xs">
<code className="truncate font-mono text-foreground">
{sourceBranch}
</code>
<p className="flex min-w-0 flex-wrap items-center gap-1.5">
<span className="truncate text-foreground">{sourceBranch}</span>
<span aria-hidden className="text-muted-foreground">
→
</span>
<code className="truncate font-mono text-foreground">
{targetBranch}
</code>
<span className="truncate text-foreground">{targetBranch}</span>
{diffStats ? (
<>
<span className="text-green-500">+{diffStats.additions}</span>
@@ -73,7 +69,10 @@ export function PullRequestMetaHeader({
) : null}
{pullRequest.commit ? (
<span className="inline-flex min-w-0 items-center gap-0.5 text-muted-foreground">
<code className="truncate font-mono" title={pullRequest.commit}>
<code
className="truncate font-mono text-sm"
title={pullRequest.commit}
>
{pullRequest.commit.slice(0, 7)}
</code>
<CopyCommitHashButton

View File

@@ -202,7 +202,7 @@ export function PullRequestReviewersRow({
<Dialog onOpenChange={setPickerOpen} open={pickerOpen}>
<DialogTrigger asChild>
<Button
className="h-5 px-0 text-xs text-muted-foreground hover:bg-transparent hover:text-foreground"
className="h-5 px-0 text-sm text-muted-foreground hover:bg-transparent hover:text-foreground"
disabled={requestReviewMutation.isPending}
size="xs"
type="button"
@@ -301,7 +301,7 @@ export function PullRequestReviewersRow({
<ProjectDetailMetaRow
key={pubkey}
label={label}
labelClassName="text-foreground"
labelClassName="font-medium text-foreground"
leading={
<UserAvatar
accent={profile?.isAgent === true}
@@ -313,7 +313,7 @@ export function PullRequestReviewersRow({
>
<span
className={cn(
"inline-flex items-center gap-0.5 text-xs",
"inline-flex items-center gap-0.5 text-sm",
hasApproved && "text-green-600 dark:text-green-400",
hasRequestedChanges && "text-amber-600 dark:text-amber-400",
!hasApproved && !hasRequestedChanges && "text-muted-foreground",

View File

@@ -1,11 +1,17 @@
import { toast } from "sonner";
import { projectCloneErrorPresentation } from "@/features/projects/lib/projectGitError";
import type { ProjectRepoUnavailableReason } from "@/features/projects/lib/projectRepoAvailability";
export function showProjectCloneErrorToast(
error: unknown,
cloneUrl?: string | null,
unavailableReason?: ProjectRepoUnavailableReason,
) {
const presentation = projectCloneErrorPresentation(error, cloneUrl);
const presentation = projectCloneErrorPresentation(
error,
cloneUrl,
unavailableReason,
);
toast.error(presentation.title, { description: presentation.description });
}

View File

@@ -13,18 +13,10 @@ export const PROJECT_PICKER_TRIGGER_CLASS =
export const PROJECT_DETAIL_PANEL_CLASS =
"overflow-hidden rounded-xl border border-border/60 bg-transparent";
/**
* Detail + meta rail: two columns when the project pane itself is wide enough.
* Uses a container query so opening the conversation panel reflows the rail
* instead of clipping it against a viewport `xl` breakpoint.
*/
export const PROJECT_DETAIL_META_GRID_WIDE_CLASS =
"[@container(min-width:48rem)]:grid-cols-[minmax(0,1fr)_18rem]";
/** Left border for the meta rail when it sits beside the detail column. */
export const PROJECT_DETAIL_META_RAIL_WIDE_BORDER_CLASS =
"[@container(min-width:48rem)]:border-l [@container(min-width:48rem)]:border-t-0";
/** Empty or loading state using the same transparent project panel shell. */
export const PROJECT_DETAIL_PANEL_MESSAGE_CLASS =
"rounded-xl border border-border/60 bg-transparent p-4 text-sm text-muted-foreground";
/** Shared translucent chrome for the paired project content/panel headers. */
export const PROJECT_COLUMN_HEADER_BACKDROP_CLASS =
"bg-background/80 backdrop-blur-md supports-backdrop-filter:bg-background/70 dark:bg-background/70 dark:backdrop-blur-xl dark:supports-backdrop-filter:bg-background/55";

View File

@@ -0,0 +1,58 @@
import type * as React from "react";
import type { EntityLinkTab } from "@/shared/lib/entityLink";
import type { ProjectDetailWorkItemCrumb } from "./ProjectDetailChrome";
import { PROJECT_TAB_CRUMB_LABELS } from "./projectDetailHelpers";
export function buildProjectDetailCrumbs({
activeTab,
commit,
issue,
pullRequest,
setRequestedTab,
setSelectedCommitHash,
setSelectedIssueId,
setSelectedPullRequestId,
setTabsResetKey,
}: {
activeTab: string;
commit: { hash: string; subject?: string | null } | null;
issue: { id: string; title: string } | null;
pullRequest: { id: string; title: string } | null;
setRequestedTab: (tab: EntityLinkTab | undefined) => void;
setSelectedCommitHash: (hash: string | null) => void;
setSelectedIssueId: (id: string | null) => void;
setSelectedPullRequestId: (id: string | null) => void;
setTabsResetKey: React.Dispatch<React.SetStateAction<number>>;
}) {
const activeWorkItemCrumb: ProjectDetailWorkItemCrumb | null = pullRequest
? {
category: "Review",
clear: () => setSelectedPullRequestId(null),
title: pullRequest.title,
}
: issue
? {
category: "Tasks",
clear: () => setSelectedIssueId(null),
title: issue.title,
}
: commit
? {
category: "Commits",
clear: () => setSelectedCommitHash(null),
title: commit.subject || commit.hash.slice(0, 7),
}
: null;
const activeTabCrumb = activeWorkItemCrumb
? null
: (PROJECT_TAB_CRUMB_LABELS[activeTab] ?? null);
const handleGoToProjectHome = () => {
setSelectedPullRequestId(null);
setSelectedIssueId(null);
setSelectedCommitHash(null);
setRequestedTab(undefined);
setTabsResetKey((key) => key + 1);
};
return { activeTabCrumb, activeWorkItemCrumb, handleGoToProjectHome };
}

View File

@@ -0,0 +1,101 @@
import * as React from "react";
import type {
ProjectIssue,
ProjectPullRequest,
Repository,
} from "@/features/projects/hooks";
import {
projectContributorActivityCounts,
signedProjectContributorPubkeys,
type ViewerGitIdentity,
} from "@/features/projects/lib/projectContributorMatching";
import { useGitIdentityQuery } from "@/features/projects/useGitIdentity";
import { useProfileQuery, useUsersBatchQuery } from "@/features/profile/hooks";
import { mergeCurrentProfileIntoLookup } from "@/features/profile/lib/identity";
import { useIdentityQuery } from "@/shared/api/hooks";
import { projectPeople } from "./projectDetailHelpers";
/** Resolves profiles and contributor identity data used across project panels. */
export function useProjectDetailPeople({
issues,
pullRequests,
repository,
}: {
issues: ProjectIssue[];
pullRequests: ProjectPullRequest[];
repository: Repository | null;
}) {
const peoplePubkeys = React.useMemo(() => {
if (!repository) return [];
const pullRequestPubkeys = pullRequests.flatMap((pullRequest) => [
pullRequest.author,
...pullRequest.updates.map((update) => update.author),
...pullRequest.comments.map((comment) => comment.author),
...pullRequest.reviewers,
...pullRequest.approvals.map((approval) => approval.author),
]);
const issuePubkeys = issues.flatMap((issue) => [
issue.author,
...issue.recipients,
...issue.assignees,
...issue.comments.map((comment) => comment.author),
]);
return [
...new Set([
...projectPeople(repository),
...pullRequestPubkeys,
...issuePubkeys,
]),
];
}, [issues, pullRequests, repository]);
const profilesQuery = useUsersBatchQuery(peoplePubkeys, {
enabled: peoplePubkeys.length > 0,
});
const currentProfileQuery = useProfileQuery();
const profiles = React.useMemo(
() =>
mergeCurrentProfileIntoLookup(
profilesQuery.data?.profiles,
currentProfileQuery.data,
),
[currentProfileQuery.data, profilesQuery.data?.profiles],
);
const contributorPubkeys = React.useMemo(() => {
if (!repository) return [];
return signedProjectContributorPubkeys({
contributors: repository.contributors,
issues,
owner: repository.owner,
pullRequests,
});
}, [issues, pullRequests, repository]);
const contributorActivityCounts = React.useMemo(() => {
if (!repository) return {};
return projectContributorActivityCounts({
contributors: repository.contributors,
issues,
owner: repository.owner,
pullRequests,
});
}, [issues, pullRequests, repository]);
const identityQuery = useIdentityQuery();
const gitIdentityQuery = useGitIdentityQuery();
const viewerGitIdentity = React.useMemo<ViewerGitIdentity | null>(() => {
const pubkey = identityQuery.data?.pubkey ?? null;
if (!pubkey || !gitIdentityQuery.data) return null;
return {
pubkey,
name: gitIdentityQuery.data.name,
email: gitIdentityQuery.data.email,
};
}, [gitIdentityQuery.data, identityQuery.data?.pubkey]);
return {
contributorActivityCounts,
contributorPubkeys,
identityPubkey: identityQuery.data?.pubkey,
profiles,
viewerGitIdentity,
};
}

View File

@@ -0,0 +1,60 @@
import * as React from "react";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { useOpenDmMutation } from "@/features/channels/hooks";
import type {
ProfilePanelTab,
ProfilePanelView,
} from "@/features/profile/ui/UserProfilePanel";
import {
profilePanelTabFromSearch,
profilePanelViewFromSearch,
} from "@/features/profile/ui/UserProfilePanelUtils";
import { useHistorySearchState } from "@/shared/hooks/useHistorySearchState";
const PROJECT_DETAIL_PANEL_SEARCH_KEYS = [
"profile",
"profileTab",
"profileView",
] as const;
/** Owns URL-backed profile panel state for the project workspace. */
export function useProjectProfilePanel() {
const { goChannel } = useAppNavigation();
const { applyPatch, values } = useHistorySearchState(
PROJECT_DETAIL_PANEL_SEARCH_KEYS,
);
const openDmMutation = useOpenDmMutation();
return {
profilePanelPubkey: values.profile,
profilePanelTab: profilePanelTabFromSearch(values.profileTab),
profilePanelView: profilePanelViewFromSearch(values.profileView),
handleOpenProfilePanel: React.useCallback(
(pubkey: string) =>
applyPatch({ profile: pubkey, profileTab: null, profileView: null }),
[applyPatch],
),
handleCloseProfilePanel: React.useCallback(
() => applyPatch({ profile: null, profileTab: null, profileView: null }),
[applyPatch],
),
handleProfilePanelViewChange: React.useCallback(
(view: ProfilePanelView, options?: { replace?: boolean }) =>
applyPatch({ profileView: view === "summary" ? null : view }, options),
[applyPatch],
),
handleProfilePanelTabChange: React.useCallback(
(tab: ProfilePanelTab, options?: { replace?: boolean }) =>
applyPatch({ profileTab: tab === "info" ? null : tab }, options),
[applyPatch],
),
handleOpenDm: React.useCallback(
async (pubkeys: string[]) => {
const dm = await openDmMutation.mutateAsync({ pubkeys });
await goChannel(dm.id);
},
[goChannel, openDmMutation],
),
};
}

View File

@@ -0,0 +1,65 @@
import * as React from "react";
import type { ProjectRightPanelMode } from "./ProjectRightPanelControls";
const REPOSITORY_PANEL_COLLAPSED_KEY =
"buzz.desktop.project-repository-panel-collapsed";
const PROJECT_RIGHT_PANEL_MODE_KEY = "buzz.desktop.project-right-panel-mode";
function initialCollapsed() {
try {
return (
globalThis.sessionStorage?.getItem(REPOSITORY_PANEL_COLLAPSED_KEY) ===
"true"
);
} catch {
return false;
}
}
function initialMode(): ProjectRightPanelMode {
try {
return globalThis.sessionStorage?.getItem(PROJECT_RIGHT_PANEL_MODE_KEY) ===
"chat"
? "chat"
: "repository";
} catch {
return "repository";
}
}
export function useProjectRepositoryPanel() {
const [collapsed, setCollapsedState] = React.useState(initialCollapsed);
const [mode, setModeState] =
React.useState<ProjectRightPanelMode>(initialMode);
const setCollapsed = React.useCallback((nextCollapsed: boolean) => {
setCollapsedState(nextCollapsed);
try {
globalThis.sessionStorage?.setItem(
REPOSITORY_PANEL_COLLAPSED_KEY,
String(nextCollapsed),
);
} catch {
// Persistence is best-effort; the in-memory drawer state still works.
}
}, []);
const setMode = React.useCallback((nextMode: ProjectRightPanelMode) => {
setModeState(nextMode);
try {
globalThis.sessionStorage?.setItem(
PROJECT_RIGHT_PANEL_MODE_KEY,
nextMode,
);
} catch {
// Persistence is best-effort; the in-memory panel mode still works.
}
}, []);
return {
collapse: React.useCallback(() => setCollapsed(true), [setCollapsed]),
collapsed,
expand: React.useCallback(() => setCollapsed(false), [setCollapsed]),
mode,
setMode,
};
}

View File

@@ -10,6 +10,7 @@ import {
buildInitialProjectEventTemplates,
isUnsupportedProjectKindError,
} from "@/features/projects/projectCreation";
import { addProjectToSidebar } from "@/features/projects/lib/projectSidebarMembership";
import { buildProjectReadModels } from "@/features/projects/projectModels";
import { relayClient } from "@/shared/api/relayClient";
import { getCachedRelayOrigin } from "@/shared/lib/mediaUrl";
@@ -130,6 +131,11 @@ export function useCreateProjectMutation() {
mutationFn: (input: CreateProjectInput) =>
createProject(input, resumableProjectIdsRef.current),
onSuccess: ({ project }) => {
addProjectToSidebar(
project.projectAddress,
getCachedRelayOrigin(),
project.owner,
);
queryClient.setQueryData<Project[]>(projectsQueryKey, (current = []) => [
project,
...current.filter(

View File

@@ -30,12 +30,7 @@ export function useProjectRepoPresentation(
controls: {
externalUrl: host.kind === "external" ? webUrl : null,
remoteKind: host.kind === "unresolved" ? undefined : host.kind,
remoteLabel:
host.kind === "external"
? host.host
: host.kind === "buzz"
? "Buzz"
: "Remote",
remoteLabel: host.kind === "external" ? host.host : "Remote",
},
};
}

View File

@@ -1,4 +1,4 @@
import { Activity, Bot, FolderGit2, Inbox, Zap } from "lucide-react";
import { Activity, Bot, Folders, Inbox, Zap } from "lucide-react";
import { TopbarSearch } from "@/features/search/ui/TopbarSearch";
import { SidebarProjectsSection } from "@/features/sidebar/ui/SidebarProjectsSection";
@@ -155,7 +155,7 @@ export function AppSidebarPrimaryMenu({
tooltip="Projects"
type="button"
>
<FolderGit2 className="h-4 w-4" />
<Folders className="h-4 w-4" />
<SidebarMenuLabel>Projects</SidebarMenuLabel>
</SidebarMenuButton>
</SidebarMenuItem>

View File

@@ -41,7 +41,7 @@ test("selectedProjectRouteId reads the project id from the detail route", () =>
);
});
test("listSidebarProjects keeps owned and contributed projects for Mine", () => {
test("listSidebarProjects only includes explicitly added projects", () => {
const owned = makeProject({
dtag: "owned",
id: `30621:${VIEWER}:owned`,
@@ -72,14 +72,18 @@ test("listSidebarProjects keeps owned and contributed projects for Mine", () =>
const other = makeProject({ name: "Other" });
const listed = listSidebarProjects({
addedProjectAddresses: new Set([
contributed.projectAddress,
other.projectAddress,
]),
currentPubkey: VIEWER,
filter: "mine",
filter: "added",
projects: [other, contributed, owned],
sort: "name",
});
assert.deepEqual(
listed.map((project) => project.name),
["Contrib", "Owned"],
["Contrib", "Other"],
);
});
@@ -113,6 +117,10 @@ test("listSidebarProjects owned filter hides contributed projects", () => {
});
const listed = listSidebarProjects({
addedProjectAddresses: new Set([
contributed.projectAddress,
owned.projectAddress,
]),
currentPubkey: VIEWER,
filter: "owned",
projects: [contributed, owned],
@@ -143,8 +151,12 @@ test("listSidebarProjects newest sort orders by createdAt then name", () => {
});
const listed = listSidebarProjects({
addedProjectAddresses: new Set([
older.projectAddress,
newer.projectAddress,
]),
currentPubkey: VIEWER,
filter: "mine",
filter: "added",
projects: [older, newer],
sort: "created",
});

View File

@@ -2,11 +2,14 @@ import { useLocation } from "@tanstack/react-router";
import {
ArrowUpDown,
ChevronDown,
EllipsisVertical,
Folder,
FolderGit2,
FolderOpen,
Folders,
Link2,
ListMinus,
Plus,
Settings2,
Trash2,
} from "lucide-react";
import * as React from "react";
@@ -20,13 +23,21 @@ import {
} from "@/features/projects/hooks";
import { isProjectOwnedByCurrentUser } from "@/features/projects/lib/projectsViewHelpers";
import { projectShareLink } from "@/features/projects/lib/projectShareLinks";
import {
addProjectToSidebar,
PROJECT_SIDEBAR_MEMBERSHIP_EVENT,
readProjectSidebarMembership,
removeProjectFromSidebar,
} from "@/features/projects/lib/projectSidebarMembership";
import { selectProjectRepository } from "@/features/projects/projectModels";
import { projectMatchesRouteId } from "@/features/projects/projectRoutes";
import { CreateProjectDialog } from "@/features/projects/ui/CreateProjectDialog";
import { ProjectBrowserDialog } from "@/features/projects/ui/ProjectBrowserDialog";
import { useCreateProjectMutation } from "@/features/projects/useCreateProject";
import { useIdentityQuery } from "@/shared/api/hooks";
import { FeatureGate } from "@/shared/features";
import { copyTextToClipboard } from "@/shared/lib/clipboard";
import { cn } from "@/shared/lib/cn";
import { getCachedRelayOrigin } from "@/shared/lib/mediaUrl";
import {
AlertDialog,
AlertDialogAction,
@@ -113,10 +124,17 @@ function SidebarProjectsSectionContent() {
const currentPubkey = identityQuery.data?.pubkey;
const { goProject, goProjects } = useAppNavigation();
const pathname = useLocation({ select: (location) => location.pathname });
const routeRepositoryId = useLocation({
select: (location) => {
const value = (location.search as Record<string, unknown>).repositoryId;
return typeof value === "string" ? value : undefined;
},
});
const routeProjectId = selectedProjectRouteId(pathname);
const relayOrigin = getCachedRelayOrigin();
const [collapsed, setCollapsed] = React.useState(false);
const [actionsOpen, setActionsOpen] = React.useState(false);
const [createOpen, setCreateOpen] = React.useState(false);
const [browserOpen, setBrowserOpen] = React.useState(false);
const [projectToDelete, setProjectToDelete] = React.useState<Project | null>(
null,
);
@@ -126,18 +144,36 @@ function SidebarProjectsSectionContent() {
const [sort, setSort] = React.useState<SidebarProjectsSort>(
readSidebarProjectsSort,
);
const [addedProjectAddresses, setAddedProjectAddresses] = React.useState<
string[]
>(() => readProjectSidebarMembership(relayOrigin, currentPubkey));
const createProjectMutation = useCreateProjectMutation();
const deleteProjectMutation = useDeleteProjectMutation();
const isPending = projectsQuery.isPending || identityQuery.isPending;
React.useEffect(() => {
const refresh = () =>
setAddedProjectAddresses(
readProjectSidebarMembership(relayOrigin, currentPubkey),
);
refresh();
globalThis.addEventListener(PROJECT_SIDEBAR_MEMBERSHIP_EVENT, refresh);
return () =>
globalThis.removeEventListener(PROJECT_SIDEBAR_MEMBERSHIP_EVENT, refresh);
}, [currentPubkey, relayOrigin]);
const addedProjectAddressSet = React.useMemo(
() => new Set(addedProjectAddresses),
[addedProjectAddresses],
);
const projects = React.useMemo(
() =>
listSidebarProjects({
addedProjectAddresses: addedProjectAddressSet,
currentPubkey,
filter,
projects: projectsQuery.data ?? [],
sort,
}),
[currentPubkey, filter, projectsQuery.data, sort],
[addedProjectAddressSet, currentPubkey, filter, projectsQuery.data, sort],
);
const handleFilterChange = (next: SidebarProjectsFilter) => {
@@ -148,11 +184,32 @@ function SidebarProjectsSectionContent() {
setSort(next);
writeSidebarProjectsSort(next);
};
const handleAdd = (project: Project) => {
addProjectToSidebar(project.projectAddress, relayOrigin, currentPubkey);
};
const handleRemove = (project: Project) => {
removeProjectFromSidebar(
project.projectAddress,
relayOrigin,
currentPubkey,
);
if (
routeProjectId != null &&
projectMatchesRouteId(project, routeProjectId)
) {
void goProjects();
}
};
const handleDelete = React.useCallback(
async (project: Project) => {
try {
await deleteProjectMutation.mutateAsync(project);
removeProjectFromSidebar(
project.projectAddress,
relayOrigin,
currentPubkey,
);
toast.success("Project deleted");
if (
routeProjectId != null &&
@@ -168,7 +225,13 @@ function SidebarProjectsSectionContent() {
setProjectToDelete(null);
}
},
[deleteProjectMutation, goProjects, routeProjectId],
[
currentPubkey,
deleteProjectMutation,
goProjects,
relayOrigin,
routeProjectId,
],
);
return (
@@ -201,7 +264,7 @@ function SidebarProjectsSectionContent() {
<SidebarProjectsHeaderActions
filter={filter}
onBrowseAll={() => void goProjects()}
onCreate={() => setCreateOpen(true)}
onCreate={() => setBrowserOpen(true)}
onFilterChange={handleFilterChange}
onOpenChange={setActionsOpen}
onSortChange={handleSortChange}
@@ -212,23 +275,56 @@ function SidebarProjectsSectionContent() {
<SidebarGroupContent id="sidebar-projects">
{projects.length > 0 ? (
<SidebarMenu data-testid="sidebar-projects">
{projects.map((project) => (
<SidebarProjectRow
canDelete={isProjectOwnedByCurrentUser(
project,
currentPubkey,
)}
deleteDisabled={deleteProjectMutation.isPending}
isActive={
routeProjectId != null &&
projectMatchesRouteId(project, routeProjectId)
}
key={project.id}
onDelete={() => setProjectToDelete(project)}
onOpen={() => goProject(project.id)}
project={project}
/>
))}
{projects.map((project) => {
const isActive =
routeProjectId != null &&
projectMatchesRouteId(project, routeProjectId);
const selectedRepository = isActive
? selectProjectRepository(project, routeRepositoryId)
: null;
return (
<React.Fragment key={project.id}>
<SidebarProjectRow
canDelete={isProjectOwnedByCurrentUser(
project,
currentPubkey,
)}
deleteDisabled={deleteProjectMutation.isPending}
isActive={isActive}
onDelete={() => setProjectToDelete(project)}
onOpen={() => goProject(project.id)}
onRemove={() => handleRemove(project)}
project={project}
/>
{isActive
? project.repositories.map((repository) => (
<SidebarMenuItem key={repository.id}>
<SidebarMenuButton
className="h-7 pl-7 text-sidebar-foreground/70 data-[active=true]:!bg-transparent data-[active=true]:font-semibold data-[active=true]:text-sidebar-foreground data-[active=true]:shadow-none data-[active=true]:hover:!bg-transparent data-[active=true]:hover:text-sidebar-foreground data-[active=true]:active:!bg-transparent"
data-testid={`sidebar-project-repository-${repository.dtag}`}
isActive={
repository.id === selectedRepository?.id
}
onClick={() =>
goProject(project.id, {
repositoryId: repository.id,
})
}
tooltip={repository.name}
type="button"
>
<FolderGit2 className="h-3.5 w-3.5" />
<SidebarMenuLabel>
{repository.name}
</SidebarMenuLabel>
</SidebarMenuButton>
</SidebarMenuItem>
))
: null}
</React.Fragment>
);
})}
</SidebarMenu>
) : isPending ? null : (
<p className="px-2 py-1 text-xs text-sidebar-foreground/50">
@@ -237,7 +333,7 @@ function SidebarProjectsSectionContent() {
)}
</SidebarGroupContent>
) : null}
<CreateProjectDialog
<ProjectBrowserDialog
isCreating={createProjectMutation.isPending}
onCreate={async (input) => {
const result = await createProjectMutation.mutateAsync(input);
@@ -250,8 +346,14 @@ function SidebarProjectsSectionContent() {
}
await goProject(result.project.id);
}}
onOpenChange={setCreateOpen}
open={createOpen}
onOpenChange={setBrowserOpen}
onSelectProject={(project) => {
handleAdd(project);
void goProject(project.id);
}}
open={browserOpen}
projects={projectsQuery.data ?? []}
selectedProjectAddresses={addedProjectAddressSet}
/>
<AlertDialog
onOpenChange={(open) => {
@@ -327,12 +429,12 @@ function SidebarProjectsHeaderActions({
onSortChange: (sort: SidebarProjectsSort) => void;
sort: SidebarProjectsSort;
}) {
const settingsTriggerRef = React.useRef<HTMLButtonElement>(null);
const actionsTriggerRef = React.useRef<HTMLButtonElement>(null);
return (
<div className="absolute right-1 top-1/2 z-10 flex -translate-y-1/2 items-center gap-0.5">
<button
aria-label="Create project"
aria-label="Add project"
className={cn(
SECTION_ICON_BUTTON_CLASS,
SECTION_ACTION_VISIBILITY_CLASS,
@@ -343,7 +445,7 @@ function SidebarProjectsHeaderActions({
onCreate();
}}
onPointerDown={(event) => event.stopPropagation()}
title="Create project"
title="Add project"
type="button"
>
<Plus className="h-4 w-4" />
@@ -351,7 +453,7 @@ function SidebarProjectsHeaderActions({
<DropdownMenu onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild>
<button
aria-label="Project section settings"
aria-label="More actions for Projects"
className={cn(
SECTION_ICON_BUTTON_CLASS,
SECTION_ACTION_VISIBILITY_CLASS,
@@ -359,17 +461,17 @@ function SidebarProjectsHeaderActions({
data-testid="sidebar-projects-settings"
onClick={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
ref={settingsTriggerRef}
ref={actionsTriggerRef}
type="button"
>
<Settings2 className="h-4 w-4" />
<EllipsisVertical className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
onCloseAutoFocus={(event) => {
event.preventDefault();
settingsTriggerRef.current?.blur();
actionsTriggerRef.current?.blur();
}}
>
<DropdownMenuSub>
@@ -384,7 +486,9 @@ function SidebarProjectsHeaderActions({
}
value={filter}
>
<DropdownMenuRadioItem value="mine">Mine</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="added">
Added
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="owned">
Owned by me
</DropdownMenuRadioItem>
@@ -412,7 +516,7 @@ function SidebarProjectsHeaderActions({
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => deferMenuAction(onBrowseAll)}>
<FolderGit2 className="h-4 w-4" />
<Folder className="h-4 w-4" />
<span>Browse all projects</span>
</DropdownMenuItem>
</DropdownMenuContent>
@@ -427,6 +531,7 @@ function SidebarProjectRow({
isActive,
onDelete,
onOpen,
onRemove,
project,
}: {
canDelete: boolean;
@@ -434,22 +539,25 @@ function SidebarProjectRow({
isActive: boolean;
onDelete: () => void;
onOpen: () => void;
onRemove: () => void;
project: Project;
}) {
const shareLink = projectShareLink(project);
const ProjectIcon = isActive ? FolderOpen : Folders;
return (
<ContextMenu>
<ContextMenuTrigger asChild>
<SidebarMenuItem>
<SidebarMenuButton
className="data-[active=true]:!bg-transparent data-[active=true]:font-normal data-[active=true]:text-sidebar-foreground data-[active=true]:shadow-none data-[active=true]:hover:!bg-transparent data-[active=true]:hover:text-sidebar-foreground data-[active=true]:active:!bg-transparent"
data-testid={`sidebar-project-${project.dtag}`}
isActive={isActive}
onClick={onOpen}
tooltip={project.name}
type="button"
>
<FolderGit2 className="h-4 w-4" />
<ProjectIcon className="h-4 w-4" />
<SidebarMenuLabel>{project.name}</SidebarMenuLabel>
</SidebarMenuButton>
{canDelete ? (
@@ -470,23 +578,32 @@ function SidebarProjectRow({
</SidebarMenuItem>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onSelect={() => deferMenuAction(onRemove)}>
<ContextMenuIconSlot>
<ListMinus className="h-4 w-4" />
</ContextMenuIconSlot>
<span>Remove from sidebar</span>
</ContextMenuItem>
{shareLink ? (
<ContextMenuItem
onSelect={() =>
deferMenuAction(() =>
copyTextToClipboard(shareLink, "Link copied to clipboard"),
)
}
>
<ContextMenuIconSlot>
<Link2 className="h-4 w-4" />
</ContextMenuIconSlot>
<span>Copy link</span>
</ContextMenuItem>
<>
<ContextMenuSeparator />
<ContextMenuItem
onSelect={() =>
deferMenuAction(() =>
copyTextToClipboard(shareLink, "Link copied to clipboard"),
)
}
>
<ContextMenuIconSlot>
<Link2 className="h-4 w-4" />
</ContextMenuIconSlot>
<span>Copy link</span>
</ContextMenuItem>
</>
) : null}
{canDelete ? (
<>
{shareLink ? <ContextMenuSeparator /> : null}
<ContextMenuSeparator />
<ContextMenuItem
className="text-destructive focus:text-destructive"
data-testid={`sidebar-project-delete-menu-${project.dtag}`}

View File

@@ -1,13 +1,10 @@
import type { Project } from "@/features/projects/projectModels";
import {
isProjectMine,
isProjectOwnedByCurrentUser,
} from "@/features/projects/lib/projectsViewHelpers";
import { isProjectOwnedByCurrentUser } from "@/features/projects/lib/projectsViewHelpers";
const SIDEBAR_PROJECTS_FILTER_KEY = "buzz.sidebar.projects.filter";
const SIDEBAR_PROJECTS_SORT_KEY = "buzz.sidebar.projects.sort";
export type SidebarProjectsFilter = "mine" | "owned";
export type SidebarProjectsFilter = "added" | "owned";
export type SidebarProjectsSort = "name" | "created";
export function selectedProjectRouteId(pathname: string): string | undefined {
@@ -24,9 +21,9 @@ export function selectedProjectRouteId(pathname: string): string | undefined {
export function readSidebarProjectsFilter(): SidebarProjectsFilter {
try {
const value = globalThis.localStorage?.getItem(SIDEBAR_PROJECTS_FILTER_KEY);
return value === "owned" ? "owned" : "mine";
return value === "owned" ? "owned" : "added";
} catch {
return "mine";
return "added";
}
}
@@ -56,21 +53,24 @@ export function writeSidebarProjectsSort(sort: SidebarProjectsSort) {
}
export function listSidebarProjects({
addedProjectAddresses,
currentPubkey,
filter,
projects,
sort,
}: {
addedProjectAddresses: ReadonlySet<string>;
currentPubkey: string | undefined;
filter: SidebarProjectsFilter;
projects: readonly Project[];
sort: SidebarProjectsSort;
}): Project[] {
return [...projects]
.filter((project) =>
filter === "owned"
? isProjectOwnedByCurrentUser(project, currentPubkey)
: isProjectMine(project, currentPubkey),
.filter(
(project) =>
addedProjectAddresses.has(project.projectAddress) &&
(filter !== "owned" ||
isProjectOwnedByCurrentUser(project, currentPubkey)),
)
.sort((left, right) => {
if (sort === "created") {

View File

@@ -3,6 +3,7 @@ import { readFileSync } from "node:fs";
import test from "node:test";
import {
buildCommitLink,
buildIssueLink,
buildProjectLink,
buildPullRequestLink,
@@ -41,6 +42,14 @@ test("builders emit the canonical cross-language link format", () => {
buildProjectLink({ owner: OWNER, dtag: GOLDEN.dtag }),
GOLDEN.links.project,
);
assert.equal(
buildCommitLink({
commitHash: EVENT_ID,
owner: OWNER,
dtag: GOLDEN.dtag,
}),
GOLDEN.links.commit,
);
assert.deepEqual(ENTITY_LINK_TABS, GOLDEN.tabs);
for (const dtag of GOLDEN.validDtags) {
assert.equal(isLinkableCoordinate(OWNER, dtag), true);
@@ -85,6 +94,34 @@ test("parseEntityLink round-trips built links", () => {
});
});
test("commit links select an exact repository commit", () => {
const link = buildCommitLink({
commitHash: EVENT_ID,
owner: OWNER,
dtag: "buzz-world",
});
assert.equal(
link,
`buzz://repo?owner=${OWNER}&d=buzz-world&tab=commits&commit=${EVENT_ID}`,
);
assert.deepEqual(parseEntityLink(link), {
ok: true,
value: {
type: "repo",
owner: OWNER,
dtag: "buzz-world",
tab: "commits",
commitHash: EVENT_ID,
},
});
assert.deepEqual(
parseEntityLink(
`buzz://repo?owner=${OWNER}&d=buzz-world&tab=files&commit=${EVENT_ID}`,
),
{ ok: false, reason: "invalid-commit" },
);
});
test("parseEntityLink lowercase-normalizes hex identifiers", () => {
const parsed = parseEntityLink(
`buzz://issue?id=${EVENT_ID.toUpperCase()}&owner=${OWNER.toUpperCase()}&d=buzz-world`,

View File

@@ -3,7 +3,7 @@
* `features/messages/lib/messageLink.ts` for `buzz://message`.
*
* Formats:
* buzz://repo?owner=<owner-pubkey>&d=<repo-dtag>[&tab=<tab>]
* buzz://repo?owner=<owner-pubkey>&d=<repo-dtag>[&tab=<tab>][&commit=<git-hash>]
* buzz://project?owner=<owner-pubkey>&d=<project-dtag>[&tab=<tab>]
* buzz://pr?id=<event-id>&owner=<owner-pubkey>&d=<repo-dtag>
* buzz://issue?id=<event-id>&owner=<owner-pubkey>&d=<repo-dtag>
@@ -45,7 +45,13 @@ export function isEntityLinkTab(value: unknown): value is EntityLinkTab {
export type ParsedEntityLink =
| { type: "pr"; id: string; owner: string; dtag: string }
| { type: "issue"; id: string; owner: string; dtag: string }
| { type: "repo"; owner: string; dtag: string; tab?: EntityLinkTab }
| {
type: "repo";
owner: string;
dtag: string;
tab?: EntityLinkTab;
commitHash?: string;
}
| { type: "project"; owner: string; dtag: string; tab?: EntityLinkTab };
export type EntityLinkParseResult =
@@ -53,6 +59,7 @@ export type EntityLinkParseResult =
| { ok: false; reason: string };
const HEX64_RE = /^[a-fA-F0-9]{64}$/;
const GIT_OBJECT_ID_RE = /^(?:[a-fA-F0-9]{40}|[a-fA-F0-9]{64})$/;
const DTAG_RE = /^[a-zA-Z0-9._-]{1,64}$/;
function isValidDtag(dtag: string): boolean {
@@ -102,6 +109,19 @@ export function buildRepoLink(input: {
return `buzz://repo?owner=${input.owner.toLowerCase()}&d=${input.dtag}${tabSuffix(input.tab)}`;
}
/** Build a link to a specific commit in a repository. */
export function buildCommitLink(input: {
commitHash: string;
owner: string;
dtag: string;
}): string {
checkCoordinate(input.owner, input.dtag);
if (!GIT_OBJECT_ID_RE.test(input.commitHash)) {
throw new Error("entityLink: commit must be a 40- or 64-char hex hash");
}
return `buzz://repo?owner=${input.owner.toLowerCase()}&d=${input.dtag}&tab=commits&commit=${input.commitHash.toLowerCase()}`;
}
/** Build a `buzz://project` link for a project announcement (kind 30621). */
export function buildProjectLink(input: {
owner: string;
@@ -198,10 +218,13 @@ export function parseEntityLink(url: string): EntityLinkParseResult {
}
// Validate known params and reject unknown ones, and enforce single-instance.
const KNOWN_COORDINATE_PARAMS = new Set(["owner", "d", "tab"]);
const KNOWN_REPO_PARAMS = new Set(["owner", "d", "tab", "commit"]);
const KNOWN_PROJECT_PARAMS = new Set(["owner", "d", "tab"]);
const KNOWN_EVENT_PARAMS = new Set(["id", "owner", "d"]);
const knownParams = isCoordinateHost
? KNOWN_COORDINATE_PARAMS
? host === "repo"
? KNOWN_REPO_PARAMS
: KNOWN_PROJECT_PARAMS
: KNOWN_EVENT_PARAMS;
for (const key of parsed.searchParams.keys()) {
@@ -230,6 +253,14 @@ export function parseEntityLink(url: string): EntityLinkParseResult {
if (tab !== null && !isEntityLinkTab(tab)) {
return { ok: false, reason: "invalid-tab" };
}
const commitHash =
host === "repo" ? parsed.searchParams.get("commit") : null;
if (
commitHash !== null &&
(tab !== "commits" || !GIT_OBJECT_ID_RE.test(commitHash))
) {
return { ok: false, reason: "invalid-commit" };
}
return {
ok: true,
value: {
@@ -237,6 +268,9 @@ export function parseEntityLink(url: string): EntityLinkParseResult {
owner: owner.toLowerCase(),
dtag,
...(tab !== null && isEntityLinkTab(tab) ? { tab } : {}),
...(commitHash !== null
? { commitHash: commitHash.toLowerCase() }
: {}),
},
};
}

View File

@@ -0,0 +1,43 @@
import { cn } from "@/shared/lib/cn";
export function DrawerPanelIcon({
className,
side,
}: {
className?: string;
side: "left" | "right";
}) {
return (
<svg
aria-hidden="true"
className={cn("h-4 w-auto shrink-0", className)}
fill="none"
height="22"
viewBox="0 0 24 22"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<rect
height="20"
rx="5"
stroke="currentColor"
strokeWidth="2"
width="22"
x="1"
y="1"
/>
<rect
className="transition-transform duration-200 ease-out motion-reduce:transition-none"
fill="currentColor"
height="14"
rx="3"
style={{
transform: side === "right" ? "translateX(10px)" : "translateX(0)",
}}
width="6"
x="4"
y="4"
/>
</svg>
);
}

View File

@@ -0,0 +1,4 @@
<svg width="24" height="22" viewBox="0 0 24 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="1" width="22" height="20" rx="5" stroke="#F5EFF7" stroke-width="2"/>
<rect x="4" y="14" width="16" height="4" rx="2" fill="#F5EFF7"/>
</svg>

After

Width:  |  Height:  |  Size: 252 B

View File

@@ -12,6 +12,7 @@ import {
import { useTheme } from "@/shared/theme/ThemeProvider";
import { resolveShikiThemeName } from "@/shared/theme/theme-loader";
import { copyCodeBlockToClipboard } from "@/shared/lib/codeBlockClipboard";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { useSmoothCorners } from "@/shared/ui/smoothCorners";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
@@ -131,6 +132,7 @@ export function MarkdownCodeBlock({
}
export function SyntaxHighlightedCode({
className,
code,
language,
...props
@@ -212,7 +214,7 @@ export function SyntaxHighlightedCode({
}
}, [code, language, shikiTheme, loadedKey]);
const codeClassName = CODE_BLOCK_CLASS;
const codeClassName = cn(CODE_BLOCK_CLASS, className);
if (!tokens) {
const lines = code.split("\n");

View File

@@ -18,9 +18,13 @@ function entityLinkPresentation(link: ParsedEntityLink) {
switch (link.type) {
case "repo":
return {
ariaLabel: `Open repository ${link.dtag}`,
ariaLabel: link.commitHash
? `Open commit ${link.commitHash.slice(0, 8)} in repository ${link.dtag}`
: `Open repository ${link.dtag}`,
icon: "repo" as const,
label: link.dtag,
label: link.commitHash
? `${link.dtag} · ${link.commitHash.slice(0, 8)}`
: link.dtag,
};
case "pr":
return {
@@ -65,6 +69,9 @@ export function useOpenEntityLink(): (link: ParsedEntityLink) => void {
: {}),
...(link.type === "pr" ? { pullRequestId: link.id } : {}),
...(link.type === "issue" ? { issueId: link.id } : {}),
...(link.type === "repo" && link.commitHash
? { commitHash: link.commitHash }
: {}),
});
},
[goProject],

View File

@@ -1,13 +1,13 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { PanelLeftClose, PanelLeftOpen } from "lucide-react";
import { cn } from "@/shared/lib/cn";
import { performSidebarDefaultHaptic } from "@/shared/lib/haptics";
import { hasPrimaryShortcutModifier } from "@/shared/lib/platform";
import { useIsMobile } from "@/shared/hooks/use-mobile";
import { Button } from "@/shared/ui/button";
import { DrawerPanelIcon } from "@/shared/ui/DrawerPanelIcon";
import { Input } from "@/shared/ui/input";
import { Separator } from "@/shared/ui/separator";
import {
@@ -394,7 +394,7 @@ const SidebarTrigger = React.forwardRef<
React.ElementRef<typeof Button>,
React.ComponentProps<typeof Button>
>(({ className, onClick, ...props }, ref) => {
const { toggleSidebar, open } = useSidebar();
const { open, toggleSidebar } = useSidebar();
return (
<Button
@@ -409,7 +409,7 @@ const SidebarTrigger = React.forwardRef<
}}
{...props}
>
{open ? <PanelLeftClose /> : <PanelLeftOpen />}
<DrawerPanelIcon side={open ? "left" : "right"} />
<span className="sr-only">Toggle Sidebar</span>
</Button>
);

View File

@@ -188,6 +188,10 @@ type E2eConfig = {
pocketVoiceImportResult?: "success" | "cancel" | "invalid";
/** Advertised HEAD for the first mock project without adding that branch. */
projectHeadBranch?: string;
/** Override the repository access channel for project authorization states. */
projectAccessChannelId?: string;
/** Make remote project snapshots fail with this git-facing message. */
projectRepoSnapshotError?: string;
/** Builderlab account returned by hosted-community onboarding. Null/omitted = signed out. */
builderlabAuth?: {
email?: string;
@@ -5475,7 +5479,11 @@ function buildMockProjectEvents(): RelayEvent[] {
["d", seed.dtag],
["name", seed.name],
["description", seed.description],
["buzz-channel", "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"],
[
"buzz-channel",
getConfig()?.mock?.projectAccessChannelId ??
"9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50",
],
["clone", seed.cloneUrl],
...seed.contributors.map((pubkey) => ["p", pubkey]),
],
@@ -11535,6 +11543,9 @@ export function maybeInstallE2eTauriMocks() {
// viewer-identity avatar attribution is exercised in e2e.
return { name: "Thomas P", email: "thomasp@example.com" };
case "get_project_repo_snapshot":
if (activeConfig?.mock?.projectRepoSnapshotError) {
throw new Error(activeConfig.mock.projectRepoSnapshotError);
}
return {
latest_commit: {
hash: "0123456789abcdef0123456789abcdef01234567",

View File

@@ -18,6 +18,17 @@ async function enableProjectsFeature(page: import("@playwright/test").Page) {
});
}
async function addProjectToSidebar(
page: import("@playwright/test").Page,
dtag: string,
) {
await page.getByTestId("sidebar-projects-section-label").hover();
await page.getByTestId("sidebar-projects-create").click();
const browser = page.getByTestId("project-browser-dialog");
await browser.getByRole("searchbox", { name: "Search projects" }).fill(dtag);
await browser.getByTestId(`project-browser-result-${dtag}`).click();
}
async function waitForMockLiveSubscription(
page: import("@playwright/test").Page,
channelName: string,
@@ -320,9 +331,10 @@ test("unsupported relays keep the initial repository accessible", async ({
await projectEntry
.getByRole("button", { name: "View legacy-fallback" })
.click();
const repositoryPicker = page.getByTestId("project-repository-picker");
await expect(repositoryPicker).toBeVisible();
await expect(repositoryPicker).toContainText("legacy-fallback");
const repositoryRow = page.getByTestId(
"sidebar-project-repository-legacy-fallback",
);
await expect(repositoryRow).toBeVisible();
await waitForAnimations(page);
await page.screenshot({
path: `${SHOTS}/06-single-repository-add.png`,
@@ -419,41 +431,34 @@ test("multi-repository projects switch the active repository", async ({
await enableProjectsFeature(page);
await installMockBridge(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByTestId("open-projects-view").click();
await page.getByTestId("projects-section-projects").click();
await page
.locator(
'[data-testid="project-card-buzz"], [data-testid="project-row-buzz"]',
)
.first()
.click();
await addProjectToSidebar(page, "buzz");
const picker = page.getByTestId("project-repository-picker");
await expect(picker).toContainText("buzz");
await picker.click();
await expect(
page.getByTestId("project-repository-relay-tools"),
).toBeVisible();
const primaryRepository = page.getByTestId("sidebar-project-repository-buzz");
const relayToolsRepository = page.getByTestId(
"sidebar-project-repository-relay-tools",
);
await expect(primaryRepository).toHaveAttribute("data-active", "true");
await expect(relayToolsRepository).toBeVisible();
await waitForAnimations(page);
await page.screenshot({
path: `${SHOTS}/04-multi-repository-picker.png`,
});
await page.getByTestId("project-repository-relay-tools").click();
await expect(picker).toContainText("relay-tools");
await relayToolsRepository.click();
await expect(relayToolsRepository).toHaveAttribute("data-active", "true");
await expect(page).toHaveURL(
new RegExp(`repositoryId=${TEST_IDENTITIES.alice.pubkey}%3Arelay-tools`),
);
await picker.click();
await page.getByTestId("add-project-repository").click();
await expect(page.getByTestId("create-project-repository")).toBeVisible();
await expect(page.getByTestId("attach-project-repository")).toBeVisible();
await page.getByTestId("create-project-repository").click();
await page.getByTestId("add-project-repository-name").fill("mobile-app");
await page.getByTestId("add-project-repository-submit").click();
await expect(page.getByTestId("add-project-repository-dialog")).toBeHidden();
await expect(picker).toContainText("mobile-app");
await expect(
page.getByTestId("sidebar-project-repository-mobile-app"),
).toBeVisible();
const addedEvents = await page.evaluate(
() =>
window.__BUZZ_E2E_ACCEPTED_PROJECT_EVENTS__?.filter(
@@ -472,7 +477,6 @@ test("multi-repository projects switch the active repository", async ({
addedEvents.find((event) => event.kind === 30617)?.tags,
).toContainEqual(["buzz-channel", "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"]);
await picker.click();
await page.getByTestId("add-project-repository").click();
await page.getByTestId("attach-project-repository").click();
await expect(
@@ -482,7 +486,9 @@ test("multi-repository projects switch the active repository", async ({
await expect(
page.getByTestId("attach-project-repository-dialog"),
).toBeHidden();
await expect(picker).toContainText("design-system");
await expect(
page.getByTestId("sidebar-project-repository-design-system"),
).toBeVisible();
await expect
.poll(() =>
page.evaluate(
@@ -526,10 +532,22 @@ test("commit detail opens from the commits feed with a diff", async ({
const commitRows = page.getByTestId("project-activity-feed-item");
await expect(commitRows.first()).toBeVisible({ timeout: 10_000 });
// Commits share the rounded list structure used by issues and pull requests.
// Commits use the same compact, aligned row structure as work items.
await expect(
page.getByRole("heading", { name: "Commits", exact: true }),
).toBeVisible();
const firstCommitRow = commitRows.first();
expect((await firstCommitRow.boundingBox())?.height).toBeLessThanOrEqual(40);
await expect(firstCommitRow.getByTitle(/^View commit /)).toBeVisible();
await expect(
firstCommitRow.getByTestId("project-commit-author"),
).toHaveAttribute("title", /^Committed by .+ · \d+ commits?$/);
await expect(
firstCommitRow.getByRole("button", { name: "Copy commit hash" }),
).toBeVisible();
await expect(
firstCommitRow.getByTestId("project-commit-row-date"),
).toHaveClass(/text-muted-foreground\/70/);
await waitForAnimations(page);
await page.screenshot({
fullPage: false,
@@ -542,14 +560,17 @@ test("commit detail opens from the commits feed with a diff", async ({
.getByRole("button", { name: /Add Trello board workflow details/ })
.click();
// Detail header: author line, subject, and hash.
await expect(page.getByText("Commit from")).toBeVisible();
// Detail header: resolved author, subject, and hash.
await expect(page.getByText("Brain", { exact: true })).toBeVisible();
await expect(
page.getByRole("heading", { name: "Add Trello board workflow details" }),
).toBeVisible();
await expect(
page.getByRole("button", { name: "Copy commit hash" }),
).toBeVisible();
await expect(
page.getByRole("button", { name: "Copy commit link" }),
).toBeVisible();
await expect(page.getByTestId("project-workspace-tab-menu")).toHaveCount(0);
await expect(
page.getByRole("link", { name: "project guide", exact: true }),
@@ -594,10 +615,10 @@ test("commit detail opens from the commits feed with a diff", async ({
.first()
.getByRole("button", { name: /Add Trello board workflow details/ })
.click();
await expect(page.getByText("Commit from")).toBeVisible();
await expect(page.getByText("Brain", { exact: true })).toBeVisible();
await page
.getByRole("navigation", { name: "Project breadcrumb" })
.getByRole("button", { name: "buzz", exact: true })
.getByTestId("project-breadcrumb-repository")
.click();
await expect(page.getByRole("tab", { name: "Overview" })).toHaveAttribute(
"aria-selected",
@@ -674,7 +695,7 @@ test("project discussion row opens its channel thread in context", async ({
await expect(panel).toBeHidden();
});
test("pull request and issue feeds share the commit row structure", async ({
test("pull request and issue feeds use compact work item rows", async ({
page,
}) => {
await enableProjectsFeature(page);
@@ -694,17 +715,30 @@ test("pull request and issue feeds share the commit row structure", async ({
await expect(projectEntry).toBeVisible({ timeout: 10_000 });
await projectEntry.click();
// PR rows use the shared feed row: title button + #id cluster cell.
// Reviews use the compact single-line work-item row.
await page.getByRole("tab", { name: "Review" }).click();
const prRows = page.getByTestId("project-pull-request-row");
await expect(prRows.first()).toBeVisible({ timeout: 10_000 });
await expect(
prRows.first().getByRole("button", { name: /^#/ }),
).toBeVisible();
expect((await prRows.first().boundingBox())?.height).toBeLessThanOrEqual(40);
await expect(
prRows.first().getByTestId("project-pull-request-comments"),
).toHaveText("0");
await expect(
prRows.first().getByTestId("project-pull-request-row-date"),
).toHaveClass(/text-muted-foreground\/70/);
await expect(
page.getByTestId("project-work-item-group-header").first(),
).toBeVisible();
await expect(
prRows.first().locator("button").filter({ hasText: /.+/ }).nth(1),
).toHaveCSS("font-weight", "400");
await waitForAnimations(page);
await page.screenshot({ fullPage: false, path: `${SHOTS}/03-prs-feed.png` });
// The #id cell opens the PR detail, same as clicking the title.
// The inline #id opens the review detail, same as clicking the title.
await prRows.first().getByRole("button", { name: /^#/ }).click();
await expect(
page.getByRole("navigation", { name: "Project breadcrumb" }),
@@ -717,13 +751,47 @@ test("pull request and issue feeds share the commit row structure", async ({
.click();
await expect(prRows.first()).toBeVisible();
// Issue rows share the same structure.
// Tasks share the same compact structure.
await page.getByRole("tab", { name: "Tasks" }).click();
const issueRows = page.getByTestId("project-issue-row");
await expect(issueRows.first()).toBeVisible({ timeout: 10_000 });
await expect(
issueRows.first().getByRole("button", { name: /^#/ }),
).toBeVisible();
expect((await issueRows.first().boundingBox())?.height).toBeLessThanOrEqual(
40,
);
const taskCategoryCells = issueRows.getByTestId("project-issue-row-category");
await expect(taskCategoryCells.first()).toHaveText(
/^(Issue|Change request|Improvement)$/,
);
const taskCreator = issueRows.first().getByTestId("project-issue-creator");
const emptyAssignee = issueRows
.first()
.getByTestId("project-issue-assignee-placeholder");
await expect(taskCreator).toHaveAttribute("title", /^Created by /);
await expect(emptyAssignee).toHaveAttribute("title", "Unassigned");
const [taskCreatorBox, emptyAssigneeBox] = await Promise.all([
taskCreator.boundingBox(),
emptyAssignee.boundingBox(),
]);
expect(
(emptyAssigneeBox?.x ?? 0) -
((taskCreatorBox?.x ?? 0) + (taskCreatorBox?.width ?? 0)),
).toBeLessThanOrEqual(4);
await expect(
issueRows.first().getByTestId("project-issue-comments"),
).toHaveText("0");
await expect(
issueRows.first().getByTestId("project-issue-row-date"),
).toHaveClass(/text-muted-foreground\/70/);
const taskCategoryBoxes = await taskCategoryCells.evaluateAll((cells) =>
cells.slice(0, 5).map((cell) => {
const box = cell.getBoundingClientRect();
return box.x + box.width;
}),
);
expect(new Set(taskCategoryBoxes).size).toBe(1);
await waitForAnimations(page);
await page.screenshot({
fullPage: false,
@@ -743,17 +811,8 @@ test("adding a repository retries and reports an error when the 30617 publicatio
});
await installMockBridge(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByTestId("open-projects-view").click();
await page.getByTestId("projects-section-projects").click();
await page
.locator(
'[data-testid="project-card-buzz"], [data-testid="project-row-buzz"]',
)
.first()
.click();
await addProjectToSidebar(page, "buzz");
const picker = page.getByTestId("project-repository-picker");
await picker.click();
await page.getByTestId("add-project-repository").click();
await page.getByTestId("create-project-repository").click();
await page.getByTestId("add-project-repository-name").fill("rejected-repo");
@@ -810,18 +869,8 @@ test("adding a repository treats a lost 30617 acknowledgement as success", async
});
await installMockBridge(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByTestId("open-projects-view").click();
await page.getByTestId("projects-section-projects").click();
await page
.locator(
'[data-testid="project-card-buzz"], [data-testid="project-row-buzz"]',
)
.first()
.click();
await addProjectToSidebar(page, "buzz");
const picker = page.getByTestId("project-repository-picker");
await picker.click();
await page.getByTestId("add-project-repository").click();
await page.getByTestId("create-project-repository").click();
await page.getByTestId("add-project-repository-name").fill("lost-ack-repo");
@@ -829,8 +878,10 @@ test("adding a repository treats a lost 30617 acknowledgement as success", async
// The dialog should close — the operation recovered from the lost ACK.
await expect(page.getByTestId("add-project-repository-dialog")).toBeHidden();
// The repository picker must reflect the newly added repository.
await expect(picker).toContainText("lost-ack-repo");
// The sidebar must reflect the newly added repository.
await expect(
page.getByTestId("sidebar-project-repository-lost-ack-repo"),
).toBeVisible();
// Both events must have been accepted: the 30621 (project update) and the
// 30617 (repository — accepted by relay even though ACK was lost).
@@ -887,8 +938,6 @@ test("adding a repository blocks when a standalone 30617 already exists at that
.first()
.click();
const picker = page.getByTestId("project-repository-picker");
await picker.click();
await page.getByTestId("add-project-repository").click();
await page.getByTestId("create-project-repository").click();
// Use the same name — the dtag will match the seeded standalone 30617.
@@ -975,10 +1024,10 @@ test("navigating via a 30617 entity-link route opens the correct non-primary rep
{ waitUntil: "domcontentloaded" },
);
// Repository controls intentionally render only on README and Files. The
// seeded PR and branch prove that this detail route resolved relay-tools
// rather than falling back to the project's primary repository.
await expect(page.getByTestId("project-repository-picker")).toHaveCount(0);
// Direct navigation must not implicitly add the project to the sidebar.
await expect(page.getByTestId("sidebar-project-buzz")).toHaveCount(0);
// The seeded PR proves that this detail route resolved relay-tools rather
// than falling back to the project's primary repository.
// Use `first()` to avoid Playwright strict-mode violations: the text appears
// in both the breadcrumb and the PR title heading once the detail panel opens.
await expect(

View File

@@ -1119,7 +1119,7 @@ test("project detail content areas do not paint background fills", async ({
await expectVisiblePanelsToBeTransparent();
});
test("project without a checkout offers fetch feedback and dropdown cloning", async ({
test("project without a checkout offers fetch feedback and cloning", async ({
page,
}) => {
await enableProjectsFeature(page);
@@ -1127,21 +1127,21 @@ test("project without a checkout offers fetch feedback and dropdown cloning", as
await openBuzzProject(page);
await expect(
page.getByRole("button", { name: "Buzz", exact: true }),
page.getByRole("button", { name: "Remote", exact: true }),
).toBeVisible();
await expect(
page.getByRole("button", { name: "Buzz", exact: true }),
page.getByRole("button", { name: "Remote", exact: true }),
).toHaveClass(/\bborder-input\/40\b/);
await expect(page.getByRole("button", { name: /main/ })).toHaveClass(
/\bborder-input\/40\b/,
);
await expect(
page.getByRole("button", { name: "Clone", exact: true }),
).toHaveCount(0);
).toBeVisible();
await page.getByRole("button", { name: "Fetch", exact: true }).click();
await expect(page.getByText("Remote state refreshed.")).toBeVisible();
await page.getByRole("button", { name: "Buzz", exact: true }).click();
await page.getByRole("button", { name: "Remote", exact: true }).click();
const cloneItem = page.getByRole("menuitem", {
name: "Local missing Clone",
});
@@ -1223,7 +1223,7 @@ test("repository tags can be browsed as immutable remote snapshots", async ({
await expect(page.getByRole("button", { name: /v1\.0\.0/ })).toBeVisible();
await expect(
page.getByRole("button", { name: "Buzz", exact: true }),
page.getByRole("button", { name: "Remote", exact: true }),
).toBeVisible();
await expect
.poll(() =>

View File

@@ -1,7 +1,7 @@
import { expect, test } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge } from "../helpers/bridge";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
const SHOTS = "test-results/projects-v3-screenshots";
@@ -18,30 +18,325 @@ async function openBuzzProject(page: import("@playwright/test").Page) {
await projectEntry.click();
}
test("sidebar project add flow browses before creating", async ({ page }) => {
await installMockBridge(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("sidebar-project-buzz")).toHaveCount(0);
await page.getByTestId("sidebar-projects-section-label").hover();
await page.getByTestId("sidebar-projects-create").click();
const browser = page.getByTestId("project-browser-dialog");
await expect(browser).toBeVisible();
const search = browser.getByRole("searchbox", { name: "Search projects" });
await search.fill("new workspace");
await browser.getByTestId("project-browser-create").click();
await expect(page.getByTestId("create-project-dialog")).toBeVisible();
await expect(page.getByTestId("create-project-name")).toHaveValue(
"new workspace",
);
await page.getByRole("button", { name: "Back to projects" }).click();
await expect(browser).toBeVisible();
await search.fill("buzz");
await browser.getByTestId("project-browser-result-buzz").click();
await expect(browser).toBeHidden();
await expect(
page.getByRole("navigation", { name: "Project breadcrumb" }),
).toContainText("buzz");
const addedProject = page.getByTestId("sidebar-project-buzz");
await expect(addedProject).toBeVisible();
await addedProject.click({ button: "right" });
await page.getByRole("menuitem", { name: "Remove from sidebar" }).click();
await expect(addedProject).toHaveCount(0);
});
test("restricted repositories keep event work visible and offer access help", async ({
page,
}) => {
await page.addInitScript((owner) => {
window.__BUZZ_E2E_PROJECT_OWNER_OVERRIDE__ = owner;
}, TEST_IDENTITIES.alice.pubkey);
await installMockBridge(page, {
projectAccessChannelId: "11111111-1111-4111-8111-111111111111",
projectRepoSnapshotError: "remote: repository not found",
});
await openBuzzProject(page);
const unavailableState = page
.getByTestId("project-repository-unavailable")
.first();
await expect(unavailableState).toContainText("Repository access restricted");
await expect(
unavailableState.getByTestId("repository-owner-name"),
).toHaveText("alice");
await expect(unavailableState).toContainText(/the repository owner/i);
await expect(
unavailableState
.locator(
'[data-testid="repository-owner-avatar-image"], [data-testid="repository-owner-avatar-fallback"]',
)
.first(),
).toBeVisible();
await expect(page.getByTestId("project-section-header")).toHaveCount(0);
await expect(
page.getByRole("button", { name: "Ask for access" }),
).toHaveCount(2);
await expect(
page.getByRole("button", { name: "Clone", exact: true }),
).toHaveCount(0);
await expect(page.getByTestId("project-repository-file-count")).toHaveText(
"—",
);
await page.getByRole("tab", { name: "Tasks", exact: true }).click();
await expect(page.getByTestId("project-issue-row").first()).toBeVisible();
await page.getByRole("tab", { name: "Review", exact: true }).click();
await expect(
page.getByTestId("project-pull-request-row").first(),
).toBeVisible();
await page.getByRole("tab", { name: "Files", exact: true }).click();
await expect(page.getByText("Repository access restricted")).toBeVisible();
await expect(page.getByTestId("project-section-header")).toHaveCount(0);
await page.getByRole("tab", { name: "Commits", exact: true }).click();
await expect(page.getByText("Repository access restricted")).toBeVisible();
await page.getByRole("button", { name: "Ask for access" }).first().click();
const chatPanel = page.getByTestId("project-agent-chat-panel");
await expect(chatPanel).toBeVisible();
await expect(chatPanel.getByTestId("project-agent-context")).toContainText(
"Commits",
);
await expect(chatPanel.getByTestId("message-composer")).toBeVisible();
});
// Walks the Projects v3 workspace through its headline states so PR
// screenshots capture distinct pixels per feature (overview box, tab-strip
// plus, issue detail with inline copy link + avatar timeline, PR detail).
test("projects v3 workspace screenshot states", async ({ page }) => {
await installMockBridge(page);
await openBuzzProject(page);
const initialProjectBreadcrumb = page.getByRole("navigation", {
name: "Project breadcrumb",
});
await expect(
initialProjectBreadcrumb.getByTestId("project-breadcrumb-project"),
).toBeVisible();
await expect(
initialProjectBreadcrumb.getByTestId("project-breadcrumb-repository"),
).toBeVisible();
await expect(
page.getByRole("button", { name: "Open Discussion" }),
).toHaveCount(0);
// Single-box workspace: tabs sit above the pickers, which only appear for
// the README overview and Files.
// Borderless workspace: repository controls live in the persistent,
// resizable auxiliary panel instead of a header row.
const overviewTab = page.getByRole("tab", { name: "Overview" });
const filesTab = page.getByRole("tab", { name: "Files" });
const channelsTab = page.getByRole("tab", { name: "Channels" });
const contributorsTab = page.getByRole("tab", { name: "Contributors" });
const tabMenu = page.getByTestId("project-workspace-tab-menu");
const workspacePanel = page.getByTestId("project-workspace-panel");
const selectionRow = page.getByTestId("project-repository-selection-row");
const projectDetailScroll = page.getByTestId("project-detail-scroll");
const repositoryActionsPanel = page.getByTestId(
"project-repository-actions-panel",
);
const expectFullWidthSection = async () => {
const sectionHeader = workspacePanel.getByTestId("project-section-header");
const [workspaceBox, headerBox, menuBox] = await Promise.all([
workspacePanel.boundingBox(),
sectionHeader.boundingBox(),
tabMenu.boundingBox(),
]);
expect(workspaceBox).not.toBeNull();
expect(headerBox).not.toBeNull();
expect(menuBox).not.toBeNull();
expect(headerBox?.x).toBe(workspaceBox?.x);
expect(headerBox?.width).toBe(workspaceBox?.width);
expect(workspaceBox?.x).toBe(menuBox?.x);
expect((workspaceBox?.x ?? 0) + (workspaceBox?.width ?? 0)).toBe(
(menuBox?.x ?? 0) + (menuBox?.width ?? 0),
);
};
await expect(filesTab).toBeVisible();
await expect(selectionRow).toBeVisible();
await expect(projectDetailScroll).toHaveCSS("overscroll-behavior-y", "none");
await expect(overviewTab).toHaveAttribute("data-state", "active");
const [projectDetailScrollBounds, initialTabMenuBounds] = await Promise.all([
projectDetailScroll.boundingBox(),
tabMenu.boundingBox(),
]);
expect(projectDetailScrollBounds).not.toBeNull();
expect(initialTabMenuBounds?.y).toBe(projectDetailScrollBounds?.y);
expect(
await filesTab.evaluate((element) =>
Number.parseFloat(getComputedStyle(element).borderRadius),
),
).toBeGreaterThan(13);
expect((await filesTab.boundingBox())?.height).toBe(28);
await expect(repositoryActionsPanel).toBeVisible();
const repositoryPanelTab = page.getByTestId(
"project-right-panel-repository-tab",
);
const chatPanelTab = page.getByTestId("project-right-panel-chat-tab");
const terminalButton = page.getByTestId("project-terminal-toggle");
const terminalIcon = page.getByTestId("project-terminal-icon");
await expect(repositoryPanelTab).toHaveAttribute("aria-pressed", "true");
await expect(terminalIcon).toBeVisible();
expect(
await terminalIcon.evaluate((element) => {
const style = getComputedStyle(element);
return {
backgroundColor: style.backgroundColor,
maskImage: style.maskImage || style.webkitMaskImage,
parentColor: getComputedStyle(element.parentElement ?? element).color,
};
}),
).toMatchObject({
backgroundColor: await terminalButton.evaluate(
(element) => getComputedStyle(element).color,
),
maskImage: expect.not.stringMatching(/^none$/),
});
const [repositoryTabBounds, chatTabBounds] = await Promise.all([
repositoryPanelTab.boundingBox(),
chatPanelTab.boundingBox(),
]);
expect(repositoryTabBounds?.width).toBe(chatTabBounds?.width);
await chatPanelTab.click();
const agentChatPanel = page.getByTestId("project-agent-chat-panel");
await expect(agentChatPanel).toBeVisible();
await expect(agentChatPanel.getByTestId("message-composer")).toBeVisible();
const agentContext = agentChatPanel.getByTestId("project-agent-context");
await expect(agentContext).toBeVisible();
await expect(agentContext).toContainText("Overview");
await expect(agentContext).not.toContainText("Buzz /");
const [tabMenuHeaderBounds, agentContextBounds] = await Promise.all([
tabMenu.boundingBox(),
agentContext.boundingBox(),
]);
expect(tabMenuHeaderBounds).not.toBeNull();
expect(agentContextBounds).not.toBeNull();
expect(tabMenuHeaderBounds?.height).toBe(52);
expect(
Math.abs((tabMenuHeaderBounds?.y ?? 0) - (agentContextBounds?.y ?? 0)),
).toBeLessThanOrEqual(1);
expect(
Math.abs(
(tabMenuHeaderBounds?.height ?? 0) - (agentContextBounds?.height ?? 0),
),
).toBeLessThanOrEqual(1);
const sharedHeaderBackdrop = page.getByTestId(
"project-shared-header-backdrop",
);
await expect(sharedHeaderBackdrop).toBeVisible();
await expect
.poll(() =>
sharedHeaderBackdrop.evaluate(
(element) => getComputedStyle(element).backdropFilter,
),
)
.not.toBe("none");
const sharedHeaderBackdropBounds = await sharedHeaderBackdrop.boundingBox();
expect(sharedHeaderBackdropBounds).not.toBeNull();
expect(sharedHeaderBackdropBounds?.x).toBeLessThanOrEqual(
tabMenuHeaderBounds?.x ?? 0,
);
expect(
(sharedHeaderBackdropBounds?.x ?? 0) +
(sharedHeaderBackdropBounds?.width ?? 0),
).toBeGreaterThanOrEqual(
(agentContextBounds?.x ?? 0) + (agentContextBounds?.width ?? 0),
);
await expect
.poll(() =>
agentContext.evaluate((element) => getComputedStyle(element).position),
)
.toBe("absolute");
const agentConversationScroll = agentChatPanel.getByTestId(
"project-agent-conversation-scroll",
);
await expect
.poll(() =>
agentConversationScroll.evaluate((element) =>
Number.parseFloat(getComputedStyle(element).paddingTop),
),
)
.toBeGreaterThanOrEqual(52);
await agentChatPanel
.getByTestId("message-input")
.fill("Summarize this repository.");
await agentChatPanel.getByTestId("send-message").click();
const projectAgentMessage = agentChatPanel
.locator("[data-message-id]")
.filter({ hasText: "Summarize this repository." });
await expect(projectAgentMessage).toBeVisible();
const projectAgentMessageId =
await projectAgentMessage.getAttribute("data-message-id");
expect(projectAgentMessageId).not.toBeNull();
const projectAgentChannelId = await agentChatPanel
.locator("[data-project-agent-channel-id]")
.getAttribute("data-project-agent-channel-id");
expect(projectAgentChannelId).not.toBeNull();
await page.evaluate(
async ({ channelId, parentEventId }) => {
if (!channelId)
throw new Error("Project agent DM channel was not recorded.");
await window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__?.("send_channel_message", {
channelId,
content: "A persisted threaded agent response.",
parentEventId: parentEventId ?? undefined,
});
},
{ channelId: projectAgentChannelId, parentEventId: projectAgentMessageId },
);
await expect(
workspacePanel.getByTestId("project-repository-selection-row"),
agentChatPanel.getByText("A persisted threaded agent response."),
).toBeVisible();
await expect(repositoryActionsPanel).toHaveCount(0);
await page.getByTestId("project-right-panel-repository-tab").click();
await expect(repositoryActionsPanel).toBeVisible();
await expect(
page.getByTestId("project-repository-selection-row"),
).toHaveCount(0);
await page.getByTestId("project-repository-actions-collapse").click();
await expect(repositoryActionsPanel).toHaveCount(0);
const collapsedMainPaneBounds = await workspacePanel.boundingBox();
const viewportSize = page.viewportSize();
expect(collapsedMainPaneBounds).not.toBeNull();
expect(viewportSize).not.toBeNull();
expect(
Math.abs(
(collapsedMainPaneBounds?.x ?? 0) +
(collapsedMainPaneBounds?.width ?? 0) -
(viewportSize?.width ?? 0),
),
).toBeLessThanOrEqual(8);
const expandRepositoryPanel = page.getByTestId(
"project-repository-actions-expand",
);
const projectBreadcrumb = page.getByRole("navigation", {
name: "Project breadcrumb",
});
const expandBounds = await expandRepositoryPanel.boundingBox();
const breadcrumbBounds = await projectBreadcrumb.boundingBox();
expect(expandBounds).not.toBeNull();
expect(breadcrumbBounds).not.toBeNull();
expect(
Math.abs(
(expandBounds?.y ?? 0) +
(expandBounds?.height ?? 0) / 2 -
((breadcrumbBounds?.y ?? 0) + (breadcrumbBounds?.height ?? 0) / 2),
),
).toBeLessThanOrEqual(1);
await expandRepositoryPanel.click();
await expect(repositoryActionsPanel).toBeVisible();
await expect(page.getByTestId("project-detail-copy-link")).toBeVisible();
await expect(
repositoryActionsPanel.getByTestId("project-detail-copy-link"),
).toHaveCount(0);
await expect(
workspacePanel.getByTestId("project-workspace-tab-menu"),
).toHaveCount(0);
@@ -50,36 +345,45 @@ test("projects v3 workspace screenshot states", async ({ page }) => {
const contributorsBounds = await contributorsTab.boundingBox();
const tabMenuBounds = await tabMenu.boundingBox();
const workspaceBounds = await workspacePanel.boundingBox();
const selectionBounds = await selectionRow.boundingBox();
const repositoryActionsBounds = await repositoryActionsPanel.boundingBox();
expect(overviewBounds).not.toBeNull();
expect(channelsBounds).not.toBeNull();
expect(contributorsBounds).not.toBeNull();
expect(tabMenuBounds).not.toBeNull();
expect(workspaceBounds).not.toBeNull();
expect(selectionBounds).not.toBeNull();
expect(repositoryActionsBounds).not.toBeNull();
expect(channelsBounds?.x).toBeLessThan(contributorsBounds?.x ?? 0);
expect(overviewBounds?.x).toBe(workspaceBounds?.x);
expect(overviewBounds?.x).toBeGreaterThan(workspaceBounds?.x ?? 0);
expect(tabMenuBounds?.y).toBeLessThan(workspaceBounds?.y ?? 0);
expect(overviewBounds?.y).toBeLessThan(selectionBounds?.y ?? 0);
expect(repositoryActionsBounds?.x).toBeGreaterThan(workspaceBounds?.x ?? 0);
await expect(
workspacePanel.getByRole("heading", { name: "README", exact: true }),
).toBeVisible();
).toHaveCount(0);
await expect(
workspacePanel.getByTestId("project-section-header-icon"),
).toBeVisible();
).toHaveCount(0);
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/01-workspace-overview.png` });
await filesTab.click();
await expect(selectionRow).toBeVisible();
await expect(repositoryActionsPanel).toBeVisible();
await expect(
workspacePanel.getByRole("heading", { name: "Files", exact: true }),
).toBeVisible();
await chatPanelTab.click();
await expect(agentContext).toContainText("Files");
await expect(
agentChatPanel.getByText("A persisted threaded agent response."),
).toBeVisible();
await repositoryPanelTab.click();
await expect(repositoryActionsPanel).toBeVisible();
await expectFullWidthSection();
await page.getByRole("tab", { name: "Commits" }).click();
await expect(selectionRow).toHaveCount(0);
await expect(repositoryActionsPanel).toBeVisible();
await expect(
workspacePanel.getByRole("heading", { name: "Commits", exact: true }),
).toBeVisible();
await expectFullWidthSection();
const commitRow = page.getByTestId("project-activity-feed-item").first();
const commitDate = commitRow.getByTestId("project-commit-row-date");
const commitHash = commitRow.getByTitle(/^View commit /);
@@ -90,16 +394,52 @@ test("projects v3 workspace screenshot states", async ({ page }) => {
expect(commitHashBounds).not.toBeNull();
expect(commitDateBounds?.x).toBeGreaterThan(commitHashBounds?.x ?? 0);
await contributorsTab.click();
await expectFullWidthSection();
const contributorRow = page.getByTestId("project-contributor-row").first();
await expect(
page.getByTestId("project-contributor-row-date").first(),
contributorRow.getByTestId("project-contributor-commit-count"),
).toBeVisible();
await expect(
contributorRow.getByTestId("project-contributor-review-count"),
).toBeVisible();
await expect(
contributorRow.getByTestId("project-contributor-task-count"),
).toBeVisible();
const contributorBounds = await contributorRow.boundingBox();
const contributorIdentityBounds = await contributorRow
.getByTestId("project-contributor-identity")
.boundingBox();
const contributorCommitCountBounds = await contributorRow
.getByTestId("project-contributor-commit-count")
.boundingBox();
expect(contributorBounds).not.toBeNull();
expect(contributorBounds?.height).toBeLessThanOrEqual(40);
expect(contributorIdentityBounds).not.toBeNull();
expect(contributorCommitCountBounds?.x).toBeGreaterThan(
contributorIdentityBounds?.x ?? 0,
);
const contributorCommitCounts = await page
.getByTestId("project-contributor-commit-count")
.evaluateAll((nodes) =>
nodes.map((node) => {
const value = Number.parseInt(node.textContent?.trim() ?? "", 10);
return Number.isNaN(value) ? -1 : value;
}),
);
expect(contributorCommitCounts).toEqual(
[...contributorCommitCounts].sort((left, right) => right - left),
);
await channelsTab.click();
await expect(
workspacePanel.getByRole("heading", { name: "Channels", exact: true }),
).toBeVisible();
await expectFullWidthSection();
// Issues tab: the create action lives in the section header.
await page.getByRole("tab", { name: "Tasks", exact: true }).click();
await expect(selectionRow).toHaveCount(0);
await expect(repositoryActionsPanel).toBeVisible();
await expectFullWidthSection();
const newIssueButton = workspacePanel.getByRole("button", {
name: "Create task",
});
@@ -127,7 +467,7 @@ test("projects v3 workspace screenshot states", async ({ page }) => {
expect(issueDateBounds).not.toBeNull();
expect(issueIdBounds).not.toBeNull();
expect(issueRowBounds).not.toBeNull();
expect(contributorBounds?.height).toBe(issueRowBounds?.height);
expect(issueRowBounds?.height).toBeLessThanOrEqual(40);
expect(issueDateBounds?.x).toBeGreaterThan(issueIdBounds?.x ?? 0);
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/02-issues-tab.png` });
@@ -152,6 +492,16 @@ test("projects v3 workspace screenshot states", async ({ page }) => {
await expect(
page.getByTestId("project-issue-comment-timeline-row").first(),
).toBeVisible();
const issueActivity = workspacePanel.getByRole("button", {
name: "Activity",
exact: true,
});
await issueActivity.click();
await expect(composer).toBeVisible();
await expect(
page.getByTestId("project-issue-comment-timeline-row"),
).toHaveCount(0);
await issueActivity.click();
// Let the "Comment posted." toast dismiss so it doesn't photobomb.
await expect(page.getByText("Comment posted.")).toHaveCount(0, {
timeout: 10_000,
@@ -175,6 +525,7 @@ test("projects v3 workspace screenshot states", async ({ page }) => {
await expect(
workspacePanel.getByRole("button", { name: "Create review" }),
).toBeVisible();
await expectFullWidthSection();
await expect(
tabMenu.getByRole("button", { name: "Create review" }),
).toHaveCount(0);
@@ -192,6 +543,29 @@ test("projects v3 workspace screenshot states", async ({ page }) => {
await expect(
page.getByTestId("project-pull-request-copy-link"),
).toBeVisible();
const reviewCommits = workspacePanel.getByRole("button", {
name: "Commits",
exact: true,
});
await expect(reviewCommits).toHaveAttribute("aria-expanded", "false");
await expect(reviewCommits).toHaveCSS("font-size", "16px");
await expect(reviewCommits).toHaveCSS("font-weight", "400");
await reviewCommits.click();
const openedReviewCommits = workspacePanel.getByRole("button", {
name: /^Commits \d+$/,
});
await expect(openedReviewCommits).toHaveAttribute("aria-expanded", "true");
await openedReviewCommits.click();
const reviewComposer = page.getByTestId(
"project-pull-request-comment-composer",
);
await expect(reviewComposer).toBeVisible();
const reviewActivity = workspacePanel.getByRole("button", {
name: "Activity",
exact: true,
});
await reviewActivity.click();
await expect(reviewComposer).toBeVisible();
await expect(tabMenu).toHaveCount(0);
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/04-pr-detail.png` });

View File

@@ -169,6 +169,33 @@ async function reveal(page: Page) {
.toBeGreaterThanOrEqual(180);
}
test("project terminal button opens Buzz Term for the repository", async ({
page,
}) => {
await page.setViewportSize({ width: 1280, height: 800 });
await installTerminalBackend(page);
await installMockBridge(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByTestId("open-projects-view").click();
await page.getByTestId("projects-section-projects").click();
const projectEntry = page
.locator(
'[data-testid="project-card-buzz"], [data-testid="project-row-buzz"]',
)
.first();
await expect(projectEntry).toBeVisible({ timeout: 10_000 });
await projectEntry.click();
const terminalButton = page.getByTestId("project-terminal-toggle");
await expect(terminalButton).toBeEnabled();
await terminalButton.click();
await expect(page.locator(TERM)).toHaveAttribute(
"data-terminal-mode",
"docked",
);
await expect(page.locator(TERM)).toBeVisible();
});
test("scrollback: wheel over Buzz Term reaches terminal_scroll", async ({
page,
}) => {

View File

@@ -13,6 +13,7 @@
"links": {
"pullRequest": "buzz://pr?id=c3b589fa5713ba25bad6dc095e2de00a4ac8f50050fdea00fc6444e603be1dd1&owner=71d67180ba17e749ee825fc8819c9c6ee7003617e1c126504f9b658070ab9224&d=buzz-world",
"issue": "buzz://issue?id=c3b589fa5713ba25bad6dc095e2de00a4ac8f50050fdea00fc6444e603be1dd1&owner=71d67180ba17e749ee825fc8819c9c6ee7003617e1c126504f9b658070ab9224&d=buzz-world",
"commit": "buzz://repo?owner=71d67180ba17e749ee825fc8819c9c6ee7003617e1c126504f9b658070ab9224&d=buzz-world&tab=commits&commit=c3b589fa5713ba25bad6dc095e2de00a4ac8f50050fdea00fc6444e603be1dd1",
"repository": "buzz://repo?owner=71d67180ba17e749ee825fc8819c9c6ee7003617e1c126504f9b658070ab9224&d=buzz-world",
"project": "buzz://project?owner=71d67180ba17e749ee825fc8819c9c6ee7003617e1c126504f9b658070ab9224&d=buzz-world"
},