feat(desktop): refine repository-aware project workspaces

Make Projects repository-aware across navigation, source management, work items, discussions, and contextual actions while consolidating detail views into consistent single-column workspaces.

Signed-off-by: Thomas Petersen <thomasp@squareup.com>
This commit is contained in:
Thomas Petersen
2026-08-15 21:07:00 -04:00
parent 78cbffeb64
commit 9ae5e5cd61
150 changed files with 10147 additions and 3503 deletions

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>
);
@@ -105,6 +101,13 @@ export function AppTopChrome({
)}
data-tauri-drag-region
data-testid="app-top-chrome"
style={
{
"--app-top-chrome-center-offset": hasCommunityRail
? "-1.75rem"
: "0rem",
} as React.CSSProperties
}
>
<div className={cn("flex items-center gap-0.5", navRowAlignmentClass)}>
<TopChromeSidebarTrigger />
@@ -131,6 +134,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

@@ -3,11 +3,22 @@ import type * as React from "react";
import { THREAD_FOCUS_COLUMN_MAX_WIDTH_PX } from "@/features/channels/lib/threadFocusLayout";
export type ThreadPanelLayoutProps = {
canResetWidth?: boolean;
columnMaxWidthPx?: number;
enterMotion?: boolean;
headerLeading?: React.ReactNode;
/** Replaces the default "Thread" label. Channel threads leave this unset. */
headerTitle?: string;
headerTitleAriaLabel?: string;
isFocusMode: boolean;
isSinglePanelView?: boolean;
layout?: "standalone" | "split";
showBackButton?: boolean;
onHeaderTitleClick?: () => void;
onResetWidth?: () => void;
onResizeStart?: React.PointerEventHandler<HTMLButtonElement>;
splitPaneClamp?: boolean;
testId?: string;
transparentChrome?: boolean;
};

View File

@@ -7,6 +7,7 @@ type RightAuxiliaryPaneProps = {
canResetWidth: boolean;
children: React.ReactNode;
constrainToAvailableSpace?: boolean;
detached?: boolean;
onResetWidth: () => void;
onResizeStart: (event: React.PointerEvent<HTMLButtonElement>) => void;
testId?: string;
@@ -17,6 +18,7 @@ export function RightAuxiliaryPane({
canResetWidth,
children,
constrainToAvailableSpace = true,
detached = false,
onResetWidth,
onResizeStart,
testId,
@@ -25,7 +27,10 @@ export function RightAuxiliaryPane({
return (
<aside
className={cn(
"group/right-pane relative flex h-full shrink-0 flex-col overflow-hidden bg-background before:pointer-events-none before:absolute before:bottom-0 before:left-0 before:top-0 before:z-50 before:w-px before:bg-border/80 before:content-['']",
"group/right-pane relative flex h-full shrink-0 flex-col overflow-hidden bg-background",
detached
? "bg-transparent"
: "before:pointer-events-none before:absolute before:bottom-0 before:left-0 before:top-0 before:z-50 before:w-px before:bg-border/80 before:content-['']",
)}
data-testid={testId}
style={{

View File

@@ -129,8 +129,8 @@ function projectRootItem(item: FeedItem, groupItems: readonly FeedItem[]) {
}
function projectTypeLabel(item: FeedItem) {
if (item.kind === 1618) return "Pull request";
if (item.kind === 1621) return "Issue";
if (item.kind === 1618) return "Review";
if (item.kind === 1621) return "Task";
return "Project update";
}

View File

@@ -169,7 +169,7 @@ test("presents project work with its canonical subject and project filter", () =
assert.equal(item.subject, "Add project work items to Inbox");
assert.deepEqual(getInboxTypeLabel(item), {
text: "Pull request",
text: "Review",
channelLabel: null,
});
assert.equal(matchesInboxFilter(item, "project"), true);

View File

@@ -4,11 +4,7 @@ import * as React from "react";
import { useCommunities } from "@/features/communities/useCommunities";
import type { ProjectInboxWorkItem } from "@/features/home/lib/projectInbox";
import { ProjectIssueDetail } from "@/features/projects/ui/ProjectIssuesPanel";
import {
ProjectPullRequestDetail,
PullRequestDetailHeader,
PullRequestMetaRail,
} from "@/features/projects/ui/ProjectPullRequestsPanel";
import { ProjectPullRequestDetail } from "@/features/projects/ui/ProjectPullRequestsPanel";
import {
resolveUserLabel,
type UserProfileLookup,
@@ -16,7 +12,6 @@ import {
import { openProjectMergeRecoveryTerminal } from "@/shared/api/projectGit";
import { useElementWidth } from "@/shared/hooks/use-mobile";
import { TopChromeInsetHeader } from "@/shared/layout/TopChromeInsetHeader";
import { cn } from "@/shared/lib/cn";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { Button } from "@/shared/ui/button";
import { UserAvatar } from "@/shared/ui/UserAvatar";
@@ -40,7 +35,7 @@ export function ProjectInboxDetailPane({
const { activeCommunity } = useCommunities();
const [detailContentRef, detailContentWidth] =
useElementWidth<HTMLDivElement>();
const showSideRail = detailContentWidth >= 760;
const showWideActions = detailContentWidth >= 760;
const authorPubkey =
workItem.type === "pull-request"
? workItem.pullRequest.author
@@ -49,12 +44,12 @@ export function ProjectInboxDetailPane({
const authorAvatarUrl =
profiles?.[normalizePubkey(authorPubkey)]?.avatarUrl ?? null;
const inboxTitle = `${authorLabel} sent you ${
workItem.type === "pull-request" ? "a pull request" : "an issue"
workItem.type === "pull-request" ? "a review" : "a task"
}`;
// The action deep-links to this specific work item in the project view,
// so the label names the entity, not the project.
const openLabel =
workItem.type === "pull-request" ? "Open pull request" : "Open issue";
workItem.type === "pull-request" ? "Open review" : "Open task";
const handleOpenMergeRecoveryTerminal = React.useCallback(
async (input: {
expectedCommit: string;
@@ -63,7 +58,7 @@ export function ProjectInboxDetailPane({
targetBranch: string;
}) => {
if (workItem.type !== "pull-request") {
throw new Error("Merge recovery is only available for pull requests.");
throw new Error("Merge recovery is only available for reviews.");
}
const targetCloneUrl = workItem.repository.cloneUrls[0];
if (!targetCloneUrl) {
@@ -117,13 +112,13 @@ export function ProjectInboxDetailPane({
aria-label={openLabel}
className="shrink-0"
onClick={onOpenProject}
size={showSideRail ? "sm" : "icon"}
size={showWideActions ? "sm" : "icon"}
title={openLabel}
type="button"
variant="ghost"
>
<ExternalLink className="h-4 w-4" />
{showSideRail ? openLabel : null}
{showWideActions ? openLabel : null}
</Button>
</div>
</div>
@@ -138,42 +133,22 @@ export function ProjectInboxDetailPane({
className="overflow-hidden rounded-xl border border-border/60 bg-card"
data-testid="project-inbox-work-item-card"
>
{workItem.type === "pull-request" ? (
<div
className={cn(
"grid",
showSideRail && "grid-cols-[minmax(0,1fr)_18rem]",
)}
data-testid="project-inbox-work-item-layout"
>
<div className="min-w-0">
<PullRequestDetailHeader
profiles={profiles}
pullRequest={workItem.pullRequest}
/>
<ProjectPullRequestDetail
mode="conversation"
onOpenTerminal={handleOpenMergeRecoveryTerminal}
profiles={profiles}
project={workItem.repository}
pullRequest={workItem.pullRequest}
/>
</div>
<PullRequestMetaRail
<div data-testid="project-inbox-work-item-layout">
{workItem.type === "pull-request" ? (
<ProjectPullRequestDetail
onOpenTerminal={handleOpenMergeRecoveryTerminal}
profiles={profiles}
project={workItem.repository}
pullRequest={workItem.pullRequest}
stacked={!showSideRail}
/>
</div>
) : (
<ProjectIssueDetail
issue={workItem.issue}
profiles={profiles}
project={workItem.repository}
stackMetaRail={!showSideRail}
/>
)}
) : (
<ProjectIssueDetail
issue={workItem.issue}
profiles={profiles}
project={workItem.repository}
/>
)}
</div>
</div>
</div>
</div>

View File

@@ -28,11 +28,6 @@ import { VideoReviewNavigationProvider } from "@/shared/ui/VideoReviewNavigation
import { cn } from "@/shared/lib/cn";
import { AuxiliaryPanel } from "@/shared/layout/AuxiliaryPanel";
import { AuxiliaryPanelBody } from "@/shared/layout/AuxiliaryPanel";
import {
AuxiliaryPanelHeader,
AuxiliaryPanelHeaderGroup,
AuxiliaryPanelTitle,
} from "@/shared/layout/AuxiliaryPanel";
import {
THREAD_PANEL_COLUMN_CLASS,
THREAD_PANEL_COMPOSER_GUTTER_CLASS,
@@ -43,7 +38,10 @@ import { Separator } from "@/shared/ui/separator";
import { ComposerActivityAccessory } from "./ComposerActivityAccessory";
import { ComposerDockBackdrop } from "./ComposerDockBackdrop";
import { MessageComposer } from "./MessageComposer";
import { ThreadMessageSkeleton } from "./MessageThreadPanelSkeleton";
import {
MessageThreadPanelHeader,
ThreadMessageSkeleton,
} from "./MessageThreadPanelSkeleton";
import { MessageRow, type ThreadDepthGuideAction } from "./MessageRow";
import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow";
import { TypingIndicatorRow } from "./TypingIndicatorRow";
@@ -200,7 +198,10 @@ export function MessageThreadPanel({
isHuddleTranscript = false,
layout = "standalone",
editTarget,
enterMotion,
headerLeading,
headerTitle,
headerTitleAriaLabel,
isSending,
isFocusMode,
isSinglePanelView = false,
@@ -209,6 +210,9 @@ export function MessageThreadPanel({
onCancelEdit,
onCancelReply,
onClose,
onHeaderTitleClick,
onResetWidth,
onResizeStart,
onDelete,
onEdit,
onEditLastOwnMessage,
@@ -237,6 +241,10 @@ export function MessageThreadPanel({
threadTypingPubkeys,
activityAccessoryContent,
activityAccessoryVisible,
canResetWidth,
splitPaneClamp,
showBackButton,
testId = "message-thread-panel",
widthPx,
transparentChrome = false,
autoSendDraftKey = null,
@@ -952,39 +960,34 @@ export function MessageThreadPanel({
</>
);
const threadHeaderContent = (
<>
<AuxiliaryPanelHeaderGroup
backButtonAriaLabel="Back to conversation"
backButtonTestId="message-thread-back"
// A focus drawer only sets `isSinglePanelView` to fill its container's
// width — it isn't the narrow single-column view, and it has the scrimmed
// sliver as its way back, so it takes no back control of its own. The
// narrow view still needs one.
leading={headerLeading}
onBack={isSinglePanelView && !isFocusMode ? onClose : undefined}
>
<AuxiliaryPanelTitle>Thread</AuxiliaryPanelTitle>
</AuxiliaryPanelHeaderGroup>
</>
);
return (
<VideoReviewNavigationProvider>
<AuxiliaryPanel
canResetWidth={canResetWidth}
className="relative"
// The focus drawer animates itself; a second slide here would compound.
enterMotion={!isFocusMode}
enterMotion={enterMotion ?? !isFocusMode}
footer={threadFooter}
header={
isHuddleTranscript ? undefined : (
<AuxiliaryPanelHeader>{threadHeaderContent}</AuxiliaryPanelHeader>
<MessageThreadPanelHeader
headerLeading={headerLeading}
headerTitle={headerTitle}
headerTitleAriaLabel={headerTitleAriaLabel}
isFocusMode={isFocusMode}
isSinglePanelView={isSinglePanelView}
onClose={onClose}
onHeaderTitleClick={onHeaderTitleClick}
showBackButton={showBackButton}
/>
)
}
isSinglePanelView={isSinglePanelView}
layout={layout}
onClose={onClose}
testId="message-thread-panel"
onResetWidth={onResetWidth}
onResizeStart={onResizeStart}
splitPaneClamp={splitPaneClamp}
testId={testId}
transparentChrome={transparentChrome}
widthPx={widthPx}
>

View File

@@ -1,3 +1,4 @@
import type * as React from "react";
import type { ThreadPanelLayoutProps } from "@/features/channels/lib/threadPanelLayout";
import {
THREAD_PANEL_COLUMN_CLASS,
@@ -16,6 +17,61 @@ import {
} from "@/shared/layout/AuxiliaryPanel";
import { Skeleton } from "@/shared/ui/skeleton";
/** Shared title row so the skeleton and loaded panel keep the same chrome. */
export function MessageThreadPanelHeader({
headerLeading,
headerTitle = "Thread",
headerTitleAriaLabel,
isFocusMode,
isSinglePanelView,
onClose,
onHeaderTitleClick,
showBackButton,
}: {
headerLeading?: React.ReactNode;
headerTitle?: string;
headerTitleAriaLabel?: string;
isFocusMode: boolean;
isSinglePanelView: boolean;
onClose: () => void;
onHeaderTitleClick?: () => void;
showBackButton?: boolean;
}) {
const title = onHeaderTitleClick ? (
<button
aria-label={headerTitleAriaLabel ?? `Open ${headerTitle}`}
className="min-w-0 max-w-full truncate text-left hover:underline"
data-testid="message-thread-open-channel"
onClick={onHeaderTitleClick}
title={headerTitleAriaLabel ?? `Open ${headerTitle}`}
type="button"
>
{headerTitle}
</button>
) : (
headerTitle
);
return (
<AuxiliaryPanelHeader backdrop>
<AuxiliaryPanelHeaderGroup
backButtonAriaLabel="Back to conversation"
backButtonTestId="message-thread-back"
leading={headerLeading}
// Focus drawers fill width via isSinglePanelView but use the scrim to go
// back, so they omit this control. The narrow single-column view keeps it.
onBack={
(showBackButton ?? (isSinglePanelView && !isFocusMode))
? onClose
: undefined
}
>
<AuxiliaryPanelTitle>{title}</AuxiliaryPanelTitle>
</AuxiliaryPanelHeaderGroup>
</AuxiliaryPanelHeader>
);
}
type MessageThreadPanelSkeletonProps = ThreadPanelLayoutProps & {
onClose: () => void;
widthPx: number;
@@ -95,12 +151,22 @@ function ThreadComposerSkeleton({
/** Loading state for the thread panel, in every layout the real panel supports. */
export function MessageThreadPanelSkeleton({
canResetWidth,
columnMaxWidthPx,
enterMotion,
headerLeading,
headerTitle,
headerTitleAriaLabel,
isFocusMode,
isSinglePanelView = false,
layout = "standalone",
onClose,
onHeaderTitleClick,
onResetWidth,
onResizeStart,
showBackButton,
splitPaneClamp,
testId = "message-thread-panel",
widthPx,
transparentChrome = false,
}: MessageThreadPanelSkeletonProps) {
@@ -108,17 +174,6 @@ export function MessageThreadPanelSkeleton({
const hasConstrainedColumn = columnMaxWidthPx != null;
useEscapeKey(onClose, isOverlay || isSinglePanelView || isFocusMode);
const threadHeaderContent = (
<AuxiliaryPanelHeaderGroup
backButtonAriaLabel="Back to conversation"
// Matches the loaded panel's header so it doesn't shift on resolve.
leading={headerLeading}
onBack={isSinglePanelView && !isFocusMode ? onClose : undefined}
>
<AuxiliaryPanelTitle>Thread</AuxiliaryPanelTitle>
</AuxiliaryPanelHeaderGroup>
);
const threadBody = (
<AuxiliaryPanelBody
className="overflow-y-auto overflow-x-hidden overscroll-contain pb-24"
@@ -156,17 +211,29 @@ export function MessageThreadPanelSkeleton({
return (
<AuxiliaryPanel
canResetWidth={canResetWidth}
className="relative"
// See `MessageThreadPanel`: the focus drawer owns the slide.
enterMotion={!isFocusMode}
enterMotion={enterMotion ?? !isFocusMode}
footer={<ThreadComposerSkeleton columnMaxWidthPx={columnMaxWidthPx} />}
header={
<AuxiliaryPanelHeader>{threadHeaderContent}</AuxiliaryPanelHeader>
<MessageThreadPanelHeader
headerLeading={headerLeading}
headerTitle={headerTitle}
headerTitleAriaLabel={headerTitleAriaLabel}
isFocusMode={isFocusMode}
isSinglePanelView={isSinglePanelView}
onClose={onClose}
onHeaderTitleClick={onHeaderTitleClick}
showBackButton={showBackButton}
/>
}
isSinglePanelView={isSinglePanelView}
layout={layout}
onClose={onClose}
testId="message-thread-panel"
onResetWidth={onResetWidth}
onResizeStart={onResizeStart}
splitPaneClamp={splitPaneClamp}
testId={testId}
transparentChrome={transparentChrome}
widthPx={widthPx}
>

View File

@@ -340,7 +340,7 @@ async function createProjectPullRequestComment({
throw new Error("Comment location is invalid.");
}
if ((normalizedAnchor || decision) && !pullRequest.commit) {
throw new Error("Pull request commit is required for review comments.");
throw new Error("A review commit is required for review comments.");
}
const recipients = new Set([
@@ -387,8 +387,8 @@ async function createProjectPullRequestComment({
await relayClient.publishEvent(
event,
"Timed out posting pull request comment.",
"Failed to post pull request comment.",
"Timed out posting review comment.",
"Failed to post review comment.",
);
}
@@ -437,8 +437,8 @@ async function createProjectIssueComment({
await relayClient.publishEvent(
event,
"Timed out posting issue comment.",
"Failed to post issue comment.",
"Timed out posting task comment.",
"Failed to post task comment.",
);
}

View File

@@ -74,8 +74,8 @@ async function writeProjectIssueAssignment({
);
const isAssignment = operation === "assign";
const content = isAssignment
? `Assigned this issue to ${normalizedLabel}`
: `Unassigned ${normalizedLabel} from this issue`;
? `Assigned this task to ${normalizedLabel}`
: `Unassigned ${normalizedLabel} from this task`;
const label = isAssignment
? ISSUE_ASSIGNMENT_LABEL
: ISSUE_UNASSIGNMENT_LABEL;
@@ -113,8 +113,8 @@ async function writeProjectIssueAssignment({
await relayClient.publishEvent(
event,
`Timed out ${operation}ing issue.`,
`Failed to ${operation} issue.`,
`Timed out ${operation}ing task.`,
`Failed to ${operation} task.`,
);
}

View File

@@ -5,10 +5,12 @@ import { signRelayEvent } from "@/shared/api/tauri";
import { KIND_GIT_ISSUE } from "@/shared/constants/kinds";
import type { Repository as Project } from "./hooks";
import { buildGitIssueTags } from "./projectIssues.mjs";
import type { ProjectTaskCategory } from "./projectTaskCategories";
type CreateProjectIssueInput = {
title: string;
body: string;
category?: ProjectTaskCategory;
};
export async function publishProjectIssue(
@@ -22,12 +24,13 @@ export async function publishProjectIssue(
repoAddress: project.repoAddress,
repoOwner: project.owner,
title: input.title,
labels: [input.category ?? "issue"],
}),
});
await relayClient.publishEvent(
event,
"Timed out creating issue.",
"Failed to create issue.",
"Timed out creating task.",
"Failed to create task.",
);
return event.id;
}

View File

@@ -7,6 +7,9 @@ import {
entityDiscussionQuery,
formatNameList,
groupDiscussionChannels,
mergeOriginDiscussionChannel,
pickOriginConversationEvent,
relayEventToSearchHit,
repositoryDiscussionQuery,
} from "./discussionChannels.ts";
@@ -23,6 +26,82 @@ test("query builders emit the tokens FTS needs, nothing else", () => {
);
});
test("mergeOriginDiscussionChannel prepends the origin when search missed it", () => {
const discussed = groupDiscussionChannels([
{ channelId: "c1", channelName: "general", createdAt: 100, pubkey: ALICE },
]);
assert.deepEqual(
mergeOriginDiscussionChannel(discussed, {
channelId: "origin",
createdAt: 50,
pubkey: BOB,
}),
[
{
id: "origin",
name: null,
messageCount: 1,
lastActivityAt: 50,
participants: [BOB],
},
discussed[0],
],
);
assert.equal(
mergeOriginDiscussionChannel(discussed, {
channelId: "c1",
createdAt: 1,
pubkey: BOB,
}),
discussed,
);
assert.equal(mergeOriginDiscussionChannel(discussed, null), discussed);
});
test("relayEventToSearchHit uses the thread root when present", () => {
const hit = relayEventToSearchHit(
{
id: EVENT_ID,
content: "filed it",
kind: 9,
pubkey: ALICE,
created_at: 50,
tags: [
["h", "origin"],
["e", "root-id", "", "root"],
["e", "parent-id", "", "reply"],
],
},
"origin",
"agents",
);
assert.equal(hit.eventId, EVENT_ID);
assert.equal(hit.channelId, "origin");
assert.equal(hit.channelName, "agents");
assert.equal(hit.threadRootId, "root-id");
});
test("pickOriginConversationEvent prefers the author's newest message", () => {
const origin = { channelId: "c1", createdAt: 100, pubkey: ALICE };
const picked = pickOriginConversationEvent(
[
{ pubkey: BOB, created_at: 90, id: "bob" },
{ pubkey: ALICE, created_at: 40, id: "old" },
{ pubkey: ALICE, created_at: 80, id: "alice" },
],
origin,
);
assert.equal(picked?.id, "alice");
assert.equal(
pickOriginConversationEvent(
[{ pubkey: BOB, created_at: 90, id: "bob" }],
origin,
)?.id,
"bob",
);
assert.equal(pickOriginConversationEvent([], origin), null);
});
test("commit queries match full or short hash citations", () => {
const hash = "0123456789abcdef0123456789abcdef01234567";
assert.equal(
@@ -122,6 +201,13 @@ test("discussionSnippet strips entity links and coordinates", () => {
);
});
test("discussionSnippet keeps markdown markers for the preview renderer", () => {
assert.equal(
discussionSnippet("**blessed** — @Tyler + [PR #5825](https://example.com)"),
"**blessed** — @Tyler + [PR #5825](https://example.com)",
);
});
test("discussionSnippet truncates long content on an ellipsis", () => {
// The cap only bounds DOM size — the row's CSS truncation does the
// visual cut — so it just needs to hold for arbitrarily long content.

View File

@@ -16,6 +16,11 @@
*/
import type { SearchHit } from "@/shared/api/searchTypes";
import type { RelayEvent } from "@/shared/api/types";
import {
getChannelIdFromTags,
getThreadReference,
} from "@/features/messages/lib/threading";
export type DiscussionChannel = {
id: string;
@@ -37,6 +42,72 @@ export function entityDiscussionQuery(eventId: string): string {
return eventId;
}
/** Channel the entity was created from (`h` tag). Search will not find that
* thread: those messages predate the share link. */
export type DiscussionOrigin = {
channelId: string;
createdAt: number;
pubkey: string;
};
/** Prepend the origin channel when search did not already return it. */
export function mergeOriginDiscussionChannel(
channels: DiscussionChannel[],
origin: DiscussionOrigin | null | undefined,
): DiscussionChannel[] {
const channelId = origin?.channelId?.trim();
if (!channelId || !origin) return channels;
if (channels.some((channel) => channel.id === channelId)) {
return channels;
}
return [
{
id: channelId,
name: null,
messageCount: 1,
lastActivityAt: origin.createdAt,
participants: [origin.pubkey.toLowerCase()],
},
...channels,
];
}
/** Newest origin-author message, else the newest message in the channel. */
export function pickOriginConversationEvent<
T extends { pubkey: string; created_at: number },
>(events: readonly T[], origin: DiscussionOrigin): T | null {
if (events.length === 0) return null;
const author = origin.pubkey.toLowerCase();
const byAuthor = events.filter(
(event) => event.pubkey.toLowerCase() === author,
);
const pool = byAuthor.length > 0 ? byAuthor : events;
return [...pool].sort((left, right) => right.created_at - left.created_at)[0];
}
/** Shape the origin channel event into the hit the conversation panel expects. */
export function relayEventToSearchHit(
event: Pick<
RelayEvent,
"id" | "content" | "kind" | "pubkey" | "created_at" | "tags"
>,
channelId: string,
channelName?: string | null,
): SearchHit {
const thread = getThreadReference(event.tags);
return {
eventId: event.id,
content: event.content,
kind: event.kind,
pubkey: event.pubkey,
channelId: getChannelIdFromTags(event.tags) ?? channelId,
channelName: channelName ?? null,
createdAt: event.created_at,
score: 0,
threadRootId: thread.rootId ?? thread.parentId ?? event.id,
};
}
/**
* Search text matching messages that link a repository or any of its PRs
* and issues: all those links carry `owner=<pubkey>&d=<dtag>`, so the owner

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

@@ -113,7 +113,7 @@ export function projectBranchManagementState(input: {
: !activeRemoteBranch
? "Only a published remote branch can be deleted."
: input.hasOpenPullRequest
? "Close the branch's pull request before deleting it."
? "Close the branch's review before deleting it."
: null;
return { activeBranchCommit, activeRemoteBranch, deleteBranchReason };
}

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

@@ -0,0 +1,33 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { projectExternalRefUrl } from "./projectExternalUrl.ts";
test("opens the selected GitHub branch", () => {
assert.equal(
projectExternalRefUrl(
"https://github.com/block/buzz",
"fix/agent-profile-about-preserve",
),
"https://github.com/block/buzz/tree/fix%2Fagent-profile-about-preserve",
);
});
test("normalizes clone URLs before adding the selected ref", () => {
assert.equal(
projectExternalRefUrl("https://github.com/block/buzz.git/", "main"),
"https://github.com/block/buzz/tree/main",
);
});
test("keeps unsupported and unscoped URLs unchanged", () => {
assert.equal(
projectExternalRefUrl("https://gitlab.com/block/buzz", "main"),
"https://gitlab.com/block/buzz",
);
assert.equal(
projectExternalRefUrl("https://github.com/block/buzz", null),
"https://github.com/block/buzz",
);
assert.equal(projectExternalRefUrl("not a URL", "main"), "not a URL");
});

View File

@@ -0,0 +1,26 @@
/** Builds a GitHub repository URL scoped to the selected branch or tag. */
export function projectExternalRefUrl(
externalUrl: string | null | undefined,
ref: string | null | undefined,
): string | null {
if (!externalUrl) return null;
const selectedRef = ref?.trim();
if (!selectedRef) return externalUrl;
try {
const url = new URL(externalUrl);
if (
url.protocol !== "https:" ||
url.hostname.toLowerCase() !== "github.com"
) {
return externalUrl;
}
const segments = url.pathname.split("/").filter(Boolean);
if (segments.length !== 2) return externalUrl;
const repository = segments[1]?.replace(/\.git$/i, "");
if (!segments[0] || !repository) return externalUrl;
return `${url.origin}/${segments[0]}/${repository}/tree/${encodeURIComponent(selectedRef)}`;
} catch {
return externalUrl;
}
}

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

@@ -3,6 +3,7 @@ import { test } from "node:test";
import {
isProjectAccessibleToViewer,
isProjectMine,
isRepositoryAccessibleToViewer,
relativeTime,
} from "./projectsViewHelpers.ts";
@@ -44,6 +45,50 @@ function makeAccessInput(overrides = {}) {
};
}
function makeProject(overrides = {}) {
return {
createdAt: 0,
description: "",
dtag: "sprout",
id: "30621:owner:sprout",
name: "sprout",
owner: REPO_OWNER,
primaryRepositoryAddress: null,
projectAddress: "30621:owner:sprout",
projectChannelId: null,
repositories: [],
repositoryAddresses: [],
status: "open",
...overrides,
};
}
test("isProjectMine is true for projects the viewer owns or contributes to", () => {
assert.equal(isProjectMine(makeProject(), undefined), false);
assert.equal(isProjectMine(makeProject(), VIEWER), false);
assert.equal(isProjectMine(makeProject(), REPO_OWNER), true);
assert.equal(
isProjectMine(
makeProject({
owner: "c".repeat(64),
repositories: [makeRepository({ owner: VIEWER })],
}),
VIEWER,
),
true,
);
assert.equal(
isProjectMine(
makeProject({
owner: "c".repeat(64),
repositories: [makeRepository({ contributors: [VIEWER] })],
}),
VIEWER,
),
true,
);
});
test("a channel-bound repository is accessible only to channel members", () => {
const repository = makeRepository();

View File

@@ -333,8 +333,8 @@ export function getActivityLabel(summary: ProjectActivitySummary | undefined) {
return [
pluralize(summary.commitCount, "commit"),
pluralize(summary.prCount, "PR"),
pluralize(summary.issueCount, "issue"),
pluralize(summary.prCount, "review"),
pluralize(summary.issueCount, "task"),
].join(" · ");
}

View File

@@ -8,6 +8,8 @@ export type ProjectIssueStatus =
| "Done"
| "Closed";
export type ProjectTaskCategory = "issue" | "change-request" | "improvement";
export type ProjectIssueComment = {
id: string;
content: string;
@@ -27,6 +29,7 @@ export type ProjectIssue = {
channelId: string | null;
originAgentName: string | null;
labels: string[];
category: ProjectTaskCategory;
recipients: string[];
assignees: string[];
assigneeOperationHeads: Record<string, string>;

View File

@@ -1,4 +1,5 @@
import { sortEvents } from "../../shared/api/relayClientShared.ts";
import { projectTaskCategoryFromLabels } from "./projectTaskCategories.ts";
// Issue assignment mirrors PR review requests (projectPullRequests.mjs):
// a kind:1 comment labeled with this `t` tag whose `p` tags are the
@@ -190,10 +191,9 @@ export function eventToProjectIssue(
);
const comments = commentsForIssue(issueCommentEvents);
const assignmentState = assignmentStateForIssue(issue, issueCommentEvents);
const labels = getAllTags(issue, "t");
const title =
getTag(issue, "subject") ||
issue.content.split("\n")[0] ||
"Untitled issue";
getTag(issue, "subject") || issue.content.split("\n")[0] || "Untitled task";
return {
id: issue.id,
@@ -205,7 +205,8 @@ export function eventToProjectIssue(
repoAddress: getTag(issue, "a") ?? null,
channelId: getTag(issue, "h") ?? null,
originAgentName: getTag(issue, "buzz-origin-agent") ?? null,
labels: getAllTags(issue, "t"),
labels,
category: projectTaskCategoryFromLabels(labels),
recipients: getAllTags(issue, "p"),
assignees: assignmentState.assignees,
assigneeOperationHeads: assignmentState.heads,
@@ -249,17 +250,17 @@ export function buildGitIssueTags({
labels = [],
}) {
if (!repoAddress.startsWith("30617:")) {
throw new Error("Issue repo address must reference a kind:30617 repo.");
throw new Error("Task repo address must reference a kind:30617 repo.");
}
if (!/^[a-fA-F0-9]{64}$/.test(repoOwner)) {
throw new Error("Repo owner must be 64 hex characters.");
}
const subject = title.trim();
if (!subject) {
throw new Error("Issue title is required.");
throw new Error("Task title is required.");
}
if (subject.length > 256) {
throw new Error("Issue title must be 256 characters or fewer.");
throw new Error("Task title must be 256 characters or fewer.");
}
const tags = [
@@ -278,7 +279,7 @@ export function buildGitIssueTags({
export function buildGitStatusTags({ issueId, repoAddress, repoOwner }) {
if (!/^[a-fA-F0-9]{64}$/.test(issueId)) {
throw new Error("Issue ID must be 64 hex characters.");
throw new Error("Task ID must be 64 hex characters.");
}
const tags = [["e", issueId, "", "root"]];
if (repoAddress) tags.push(["a", repoAddress]);

View File

@@ -125,10 +125,28 @@ test("tag helpers drop malformed value-less tags", () => {
const issue = eventToProjectIssue(event);
assert.deepEqual(issue.labels, ["bug"]);
assert.equal(issue.category, "issue");
assert.equal(issue.status, PROJECT_ISSUE_STATUS.BACKLOG);
assert.equal(issue.title, "Something is broken");
});
test("derives task categories from labels while defaulting legacy tasks to issue", () => {
const changeRequest = eventToProjectIssue(
issueEvent({
tags: [
["a", REPO_ADDRESS],
["subject", "Update the release workflow"],
["t", "change-request"],
["t", "release"],
],
}),
);
assert.equal(changeRequest.category, "change-request");
assert.deepEqual(changeRequest.labels, ["change-request", "release"]);
assert.equal(eventToProjectIssue(issueEvent()).category, "issue");
});
test("preserves root and comment tags for rich content rendering", () => {
const root = issueEvent({
tags: [

View File

@@ -100,7 +100,7 @@ export function projectPullRequestReviewSummary(pullRequest) {
const changeRequestCount = pullRequest.changeRequests.length;
const isDraft = pullRequest.status === "Draft";
const state = isDraft
? "This pull request is still a work in progress."
? "This review is still a work in progress."
: changeRequestCount > 0
? `${changeRequestCount} reviewer${changeRequestCount === 1 ? "" : "s"} requested changes.`
: pullRequest.reviewers.length > 0
@@ -111,7 +111,7 @@ export function projectPullRequestReviewSummary(pullRequest) {
approvalCount,
changeRequestCount,
detail: isDraft
? "Draft pull requests cannot be merged."
? "Draft reviews cannot be merged."
: approvalCount === 0 && changeRequestCount === 0
? "Approvals from reviewers will show up here."
: null,
@@ -347,7 +347,7 @@ export function eventToProjectPullRequest(
const title =
getTag(pullRequest, "subject") ||
pullRequest.content.split("\n")[0] ||
"Untitled pull request";
"Untitled review";
const reviewDecisions = reviewDecisionsForPullRequest(
comments,
trustedActors,

View File

@@ -0,0 +1,39 @@
export const PROJECT_TASK_CATEGORIES = [
{ label: "Issue", value: "issue" },
{ label: "Change request", value: "change-request" },
{ label: "Improvement", value: "improvement" },
] as const;
export type ProjectTaskCategory =
(typeof PROJECT_TASK_CATEGORIES)[number]["value"];
export type ProjectTaskCategoryFilter = "all" | ProjectTaskCategory;
const PROJECT_TASK_CATEGORY_VALUES = new Set<string>(
PROJECT_TASK_CATEGORIES.map(({ value }) => value),
);
export function isProjectTaskCategory(
value: string,
): value is ProjectTaskCategory {
return PROJECT_TASK_CATEGORY_VALUES.has(value.toLowerCase());
}
export function projectTaskCategoryFromLabels(
labels: string[],
): ProjectTaskCategory {
const category = labels
.map((label) => label.toLowerCase())
.find(isProjectTaskCategory);
return category ?? "issue";
}
export function projectTaskCategoryLabel(category: ProjectTaskCategory) {
return (
PROJECT_TASK_CATEGORIES.find((option) => option.value === category)
?.label ?? "Issue"
);
}
export function projectTaskUserLabels(labels: string[]) {
return labels.filter((label) => !isProjectTaskCategory(label));
}

View File

@@ -136,7 +136,7 @@ export async function fetchProjectsWorkItems<TProject extends ProjectReference>(
if (rootResult.status === "rejected") {
throw rootResult.reason instanceof Error
? rootResult.reason
: new Error("Could not load project issues and pull requests.");
: new Error("Could not load project tasks and reviews.");
}
const updateEvents =

View File

@@ -111,9 +111,9 @@ async function publishProjectPullRequest(
input: CreateProjectPullRequestInput,
) {
const title = input.title.trim();
if (!title) throw new Error("Pull request title cannot be empty.");
if (!title) throw new Error("Review title cannot be empty.");
if (title.length > 256) {
throw new Error("Pull request title must be 256 characters or fewer.");
throw new Error("Review title must be 256 characters or fewer.");
}
if (project.cloneUrls.length === 0) {
throw new Error("This project has no clone URL.");
@@ -129,8 +129,8 @@ async function publishProjectPullRequest(
});
await relayClient.publishEvent(
event,
"Timed out creating pull request.",
"Failed to create pull request.",
"Timed out creating review.",
"Failed to create review.",
);
return event.id;
}
@@ -152,7 +152,7 @@ export async function publishProjectPullRequestUpdate({
!canPublishProjectPullRequestUpdate(identity.pubkey, project, pullRequest)
) {
throw new Error(
"Only the pull request author or repository owner can publish its update.",
"Only the review author or repository owner can publish its update.",
);
}
const event = await signRelayEvent({
@@ -166,8 +166,8 @@ export async function publishProjectPullRequestUpdate({
});
await relayClient.publishEvent(
event,
"Timed out updating pull request.",
"The branch was pushed, but the pull request update could not be published.",
"Timed out updating review.",
"The branch was pushed, but the review update could not be published.",
);
return true;
}
@@ -209,8 +209,7 @@ export function useUpdateProjectPullRequestMutation(
mergeBase: string | null;
}) => {
if (!project) throw new Error("No project selected.");
if (!pullRequest)
throw new Error("No open pull request for this branch.");
if (!pullRequest) throw new Error("No open review for this branch.");
return publishProjectPullRequestUpdate({
commit,
mergeBase,
@@ -234,7 +233,7 @@ export function useMergeProjectPullRequestMutation(
}) => {
if (!project?.cloneUrls[0]) throw new Error("No project selected.");
if (!pullRequest.branchName || !pullRequest.commit) {
throw new Error("Pull request branch information is incomplete.");
throw new Error("Review branch information is incomplete.");
}
const result = await mergeProjectPullRequest({
targetCloneUrl: project.cloneUrls[0],

View File

@@ -84,8 +84,8 @@ async function updateProjectPullRequestStatus({
await relayClient.publishEvent(
event,
"Timed out updating pull request status.",
"Failed to update pull request status.",
"Timed out updating review status.",
"Failed to update review status.",
);
}
@@ -177,9 +177,9 @@ const REVIEW_DECISION_DETAILS: Record<
> = {
approve: {
content: "Approved these changes",
errorMessage: "Failed to approve pull request.",
errorMessage: "Failed to approve review.",
label: PR_APPROVAL_LABEL,
timeoutMessage: "Timed out approving pull request.",
timeoutMessage: "Timed out approving review.",
},
"request-changes": {
content: "Requested changes",
@@ -203,7 +203,7 @@ async function submitProjectPullRequestReview({
pullRequest: ProjectPullRequest;
}): Promise<void> {
if (!pullRequest.commit) {
throw new Error("The pull request has no commit to review.");
throw new Error("The review has no commit to inspect.");
}
const details = REVIEW_DECISION_DETAILS[decision];
const recipients = new Set([

View File

@@ -99,7 +99,7 @@ export function usePushProjectLocalRepositoryMutation(
error:
error instanceof Error
? error.message
: "The pull request update could not be published.",
: "The review update could not be published.",
};
}
}

View File

@@ -21,14 +21,14 @@ export function CreateIssueDialog({
return (
<CreateProjectWorkItemDialog
bodyPlaceholder="Add context, expected behavior, or reproduction steps"
description={`Create an issue in ${projectName}`}
description={`Create a task in ${projectName}`}
isCreating={isCreating}
itemName="issue"
onCreate={onCreate}
onOpenChange={onOpenChange}
open={open}
title="Create an issue"
titlePlaceholder="Describe the issue"
title="Create a task"
titlePlaceholder="Describe the task"
/>
);
}

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

@@ -57,7 +57,7 @@ export function CreateProjectIssueDialog({
async function handleCreate(input: CreateProjectWorkItemDialogInput) {
if (!project || !repository) throw new Error("Choose a repository.");
const issueId = await createMutation.mutateAsync(input);
toast.success("Issue created.");
toast.success("Task created.");
await onCreated(project, repository, issueId);
}
@@ -66,8 +66,8 @@ export function CreateProjectIssueDialog({
bodyPlaceholder="Add context, expected behavior, or reproduction steps"
description={
repository
? `Create an issue in ${repository.name}`
: "Choose a repository for this issue."
? `Create a task in ${repository.name}`
: "Choose a repository for this task."
}
isCreating={createMutation.isPending}
itemName="issue"
@@ -75,8 +75,8 @@ export function CreateProjectIssueDialog({
onOpenChange={onOpenChange}
open={open}
submitDisabled={!repository}
title="Create an issue"
titlePlaceholder="Describe the issue"
title="Create a task"
titlePlaceholder="Describe the task"
>
<label className="block space-y-1.5 text-sm font-medium">
<span>Repository</span>

View File

@@ -1,5 +1,9 @@
import * as React from "react";
import {
PROJECT_TASK_CATEGORIES,
type ProjectTaskCategory,
} from "@/features/projects/projectTaskCategories";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content";
@@ -15,6 +19,7 @@ const FIELD_CONTROL_CLASS =
export type CreateProjectWorkItemDialogInput = {
title: string;
body: string;
category?: ProjectTaskCategory;
};
export function CreateProjectWorkItemDialog({
@@ -44,16 +49,18 @@ export function CreateProjectWorkItemDialog({
}) {
const [workItemTitle, setWorkItemTitle] = React.useState("");
const [body, setBody] = React.useState("");
const [category, setCategory] = React.useState<ProjectTaskCategory>("issue");
const [errorMessage, setErrorMessage] = React.useState<string | null>(null);
const titleInputRef = React.useRef<HTMLInputElement>(null);
const submitInFlightRef = React.useRef(false);
const testIdPrefix = `create-${itemName}`;
const itemLabel = itemName === "issue" ? "issue" : "pull request";
const itemLabel = itemName === "issue" ? "task" : "review";
React.useEffect(() => {
if (!open) return;
setWorkItemTitle("");
setBody("");
setCategory("issue");
setErrorMessage(null);
const timerId = globalThis.setTimeout(
() => titleInputRef.current?.focus(),
@@ -70,7 +77,11 @@ export function CreateProjectWorkItemDialog({
submitInFlightRef.current = true;
setErrorMessage(null);
try {
await onCreate({ title: trimmedTitle, body: body.trim() });
await onCreate({
title: trimmedTitle,
body: body.trim(),
...(itemName === "issue" ? { category } : {}),
});
onOpenChange(false);
} catch (error) {
setErrorMessage(
@@ -122,6 +133,37 @@ export function CreateProjectWorkItemDialog({
onSubmit={(event) => void handleSubmit(event)}
>
{children}
{itemName === "issue" ? (
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor={`${testIdPrefix}-category`}
>
Category
</label>
<div className={FIELD_SHELL_CLASS}>
<select
className={cn(
"h-11 w-full px-3 text-sm",
FIELD_CONTROL_CLASS,
)}
data-testid={`${testIdPrefix}-category`}
disabled={isCreating}
id={`${testIdPrefix}-category`}
onChange={(event) =>
setCategory(event.target.value as ProjectTaskCategory)
}
value={category}
>
{PROJECT_TASK_CATEGORIES.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
</div>
) : null}
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"

View File

@@ -135,9 +135,9 @@ export function CreatePullRequestDialog({
: sourceBranch === targetBranch
? "The base and compare branches must be different."
: hasOpenPullRequest
? "An open pull request already compares these branches."
? "An open review already compares these branches."
: !sourceCommit
? "The compare branch must be pushed before opening a pull request."
? "The compare branch must be pushed before opening a review."
: null;
const description =
repository && sourceBranch && targetBranch
@@ -146,9 +146,7 @@ export function CreatePullRequestDialog({
async function handleCreate(input: CreatePullRequestDialogInput) {
if (!project || !repository || !sourceCommit || selectionError) {
throw new Error(
selectionError ?? "Pull request branches are incomplete.",
);
throw new Error(selectionError ?? "Review branches are incomplete.");
}
const pullRequestId = await createMutation.mutateAsync({
...input,
@@ -158,7 +156,7 @@ export function CreatePullRequestDialog({
mergeBase: sourceSyncQuery.data?.mergeBase ?? null,
reviewers: [],
});
toast.success("Pull request created.");
toast.success("Review created.");
await onCreated(project, repository, pullRequestId);
}
@@ -175,7 +173,7 @@ export function CreatePullRequestDialog({
}}
open={open}
submitDisabled={Boolean(selectionError)}
title="Open a pull request"
title="Open a review"
titlePlaceholder="Describe the change"
>
<div className="grid gap-3 rounded-xl border border-border/60 bg-muted/25 p-3 sm:grid-cols-2">

View File

@@ -1,5 +1,6 @@
import { Hash } from "lucide-react";
import { Hash, MessageSquare } from "lucide-react";
import * as React from "react";
import { useQuery } from "@tanstack/react-query";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { useChannelsQuery } from "@/features/channels/hooks";
@@ -11,19 +12,76 @@ import {
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
import {
type DiscussionChannel,
type DiscussionOrigin,
discussionSnippet,
groupDiscussionChannels,
mergeOriginDiscussionChannel,
pickOriginConversationEvent,
relayEventToSearchHit,
} from "@/features/projects/lib/discussionChannels";
import { relativeTime } from "@/features/projects/lib/projectsViewHelpers";
import { useSearchMessagesQuery } from "@/features/search/hooks";
import { relayClient } from "@/shared/api/relayClient";
import type { SearchHit } from "@/shared/api/searchTypes";
import {
KIND_FORUM_COMMENT,
KIND_FORUM_POST,
KIND_STREAM_MESSAGE,
KIND_STREAM_MESSAGE_V2,
} from "@/shared/constants/kinds";
import { cn } from "@/shared/lib/cn";
import {
getMentionTagPubkey,
resolveMentionProps,
} from "@/shared/lib/resolveMentionNames";
import { BuzzLoadingState } from "@/shared/ui/BuzzLoadingState";
import { Markdown } from "@/shared/ui/markdown";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import { useProjectConversationPanel } from "./ProjectConversationPanelContext";
// Relay search caps a page at 500. Use the full page and surface a lower-bound
// marker when it fills rather than silently presenting partial totals as exact.
const DISCUSSION_SEARCH_LIMIT = 500;
const COLLAPSED_MENTION_ROWS = 3;
const ORIGIN_CONVERSATION_KINDS = [
KIND_STREAM_MESSAGE,
KIND_STREAM_MESSAGE_V2,
KIND_FORUM_POST,
KIND_FORUM_COMMENT,
];
function useOriginConversationHit(
origin: DiscussionOrigin | null,
hasSearchHit: boolean,
): { hit: SearchHit | null; tags: string[][] | null } {
const query = useQuery({
queryKey: [
"project-origin-conversation",
origin?.channelId ?? null,
origin?.pubkey ?? null,
origin?.createdAt ?? null,
],
enabled: Boolean(origin) && !hasSearchHit,
queryFn: async () => {
if (!origin) return { hit: null, tags: null };
const events = await relayClient.fetchEvents({
kinds: ORIGIN_CONVERSATION_KINDS,
"#h": [origin.channelId],
until: origin.createdAt,
limit: 20,
});
const event = pickOriginConversationEvent(events, origin);
return event
? {
hit: relayEventToSearchHit(event, origin.channelId),
tags: event.tags,
}
: { hit: null, tags: null };
},
staleTime: 30_000,
});
return query.data ?? { hit: null, tags: null };
}
/**
* Messages (and the channels containing them) that link the entity matched
@@ -69,18 +127,20 @@ function useChannelNameLookup(enabled: boolean) {
}
/**
* "Channels" card for PR, issue, and commit detail views: a bordered,
* softly tinted block with a small header that separates it from the
* surrounding text. Each channel gets a single truncating line —
* "Alice, Bob and Carol discussed this in #channel · 2h ago — snippet…" —
* cut at the card edge regardless of screen width. Clicking the snippet
* jumps to that message (thread-aware), the same way inbox items do.
* Renders nothing until at least one channel references the entity, so
* the detail layout stays unchanged for undiscussed items.
* "Related Conversations" card for PR, issue, and commit detail views: a
* bordered block under the body. Each channel gets a name line plus a
* compact markdown preview of the latest message (same `inbox-preview-markdown`
* treatment as inbox rows). Tasks and reviews also include the origin
* channel (`h` tag), because that thread created the entity and will not
* contain a share link yet. Renders nothing when search and origin are both
* empty.
*/
export function DiscussedInChannels({
className,
entityLabel = "this",
originChannelId,
originCreatedAt,
originPubkey,
query,
testId,
}: {
@@ -88,23 +148,63 @@ export function DiscussedInChannels({
className?: string;
/** How the sentence names the entity, e.g. "this issue". */
entityLabel?: string;
originChannelId?: string | null;
originCreatedAt?: number;
originPubkey?: string;
query: string;
testId?: string;
}) {
const { channels, hits, isTruncated } = useDiscussionChannels(query);
const {
channels: discussed,
hits,
isTruncated,
} = useDiscussionChannels(query);
const origin = React.useMemo(
() =>
originChannelId && originPubkey && originCreatedAt != null
? {
channelId: originChannelId,
createdAt: originCreatedAt,
pubkey: originPubkey,
}
: null,
[originChannelId, originCreatedAt, originPubkey],
);
const channels = React.useMemo(
() => mergeOriginDiscussionChannel(discussed, origin),
[discussed, origin],
);
const originConversation = useOriginConversationHit(
origin,
hits.some((hit) => hit.channelId === origin?.channelId),
);
const originSearchHit = originConversation.hit;
const originTags = originConversation.tags;
const originMentionPubkeys = React.useMemo(() => {
if (!originTags) return [];
return originTags.flatMap((tag) => {
const pubkey = getMentionTagPubkey(tag);
return pubkey ? [pubkey] : [];
});
}, [originTags]);
const { goChannel, openSearchHit } = useAppNavigation();
const projectConversationPanel = useProjectConversationPanel();
const [expanded, setExpanded] = React.useState(false);
const channelName = useChannelNameLookup(channels.length > 0);
const visible = expanded
? channels
: channels.slice(0, COLLAPSED_MENTION_ROWS);
const profilesQuery = useUsersBatchQuery(
visible.flatMap((channel) => channel.participants),
[
...visible.flatMap((channel) => channel.participants),
...originMentionPubkeys,
],
{ enabled: visible.length > 0 },
);
const profiles = profilesQuery.data?.profiles;
// Hits are sorted newest first, so the first hit per channel is the one a
// click should land on (and the one worth quoting).
// click should land on (and the one worth quoting). Origin-channel events
// fill in the spawning thread when nobody has pasted a share link yet.
const latestHitByChannel = React.useMemo(() => {
const byChannel = new Map<string, SearchHit>();
for (const hit of hits) {
@@ -112,8 +212,14 @@ export function DiscussedInChannels({
byChannel.set(hit.channelId, hit);
}
}
if (
originSearchHit?.channelId &&
!byChannel.has(originSearchHit.channelId)
) {
byChannel.set(originSearchHit.channelId, originSearchHit);
}
return byChannel;
}, [hits]);
}, [hits, originSearchHit]);
if (channels.length === 0) return null;
const hiddenCount = channels.length - visible.length;
@@ -127,53 +233,86 @@ export function DiscussedInChannels({
data-testid={testId}
>
<h4 className="border-b border-border/40 px-3 py-1.5 text-2xs font-semibold uppercase tracking-wide text-muted-foreground">
Channels
Related Conversations
</h4>
<div className="divide-y divide-border/40">
{visible.map((channel) => {
const latestHit = latestHitByChannel.get(channel.id);
if (!latestHit) return null;
const name = channelName(channel.id, channel.name);
const isOriginFallback =
Boolean(originSearchHit) &&
latestHit?.eventId === originSearchHit?.eventId;
const opensForum =
latestHit != null &&
(latestHit.kind === KIND_FORUM_POST ||
latestHit.kind === KIND_FORUM_COMMENT);
const openConversation = () => {
if (!latestHit) {
void goChannel(channel.id);
return;
}
if (!projectConversationPanel || opensForum) {
void openSearchHit(latestHit);
return;
}
projectConversationPanel.openConversation(latestHit);
};
const previewMentionNames =
isOriginFallback && originTags
? resolveMentionProps(originTags, profiles).mentionNames
: undefined;
return (
<div
className="flex w-full min-w-0 items-center gap-2.5 px-3 py-2"
className="group relative flex w-full min-w-0 items-start gap-2.5 px-3 py-2 transition-colors hover:bg-muted/30"
data-testid="discussion-mention-row"
key={channel.id}
>
<ParticipantFacepile
interactive
participants={channel.participants}
profiles={profiles}
<button
aria-label={`Open conversation in #${name}`}
className="absolute inset-0"
onClick={openConversation}
title={`Open the latest conversation in #${name}`}
type="button"
/>
<span className="min-w-0 flex-1 truncate text-sm">
<DiscussionNameList
<span className="relative z-10 pt-0.5">
<ParticipantFacepile
interactive
participants={channel.participants}
profiles={profiles}
/>
<span className="text-muted-foreground">
{" "}
discussed {entityLabel} in{" "}
</span>
<button
className="font-medium text-foreground hover:underline"
onClick={() => void goChannel(channel.id)}
title={`Open #${name}`}
type="button"
>
#{name}
</button>
<button
className="text-muted-foreground hover:underline"
onClick={() => void openSearchHit(latestHit)}
title={`Open the latest mention in #${name}`}
type="button"
>
<span className="text-xs">
</span>
<span className="pointer-events-none relative z-10 min-w-0 flex-1 text-sm">
<span className="block truncate">
<DiscussionNameList
participants={channel.participants}
profiles={profiles}
/>
<span className="text-muted-foreground">
{" "}
{latestHit && !isOriginFallback ? "discussed" : "started"}{" "}
{entityLabel} in{" "}
</span>
<button
className="pointer-events-auto font-medium text-foreground hover:underline"
onClick={() => void goChannel(channel.id)}
title={`Open #${name}`}
type="button"
>
#{name}
</button>
<span className="text-xs text-muted-foreground">
{" "}
· {relativeTime(channel.lastActivityAt)}
</span>
<span> — {discussionSnippet(latestHit.content)}</span>
</button>
</span>
{latestHit ? (
<span className="block text-xs text-muted-foreground">
<DiscussionMessagePreview
content={latestHit.content}
mentionNames={previewMentionNames}
/>
</span>
) : null}
</span>
</div>
);
@@ -185,7 +324,8 @@ export function DiscussedInChannels({
onClick={() => setExpanded(true)}
type="button"
>
Show {hiddenCount} more {hiddenCount === 1 ? "channel" : "channels"}
Show {hiddenCount} more{" "}
{hiddenCount === 1 ? "conversation" : "conversations"}
</button>
) : null}
{isTruncated ? (
@@ -199,6 +339,25 @@ export function DiscussedInChannels({
const NAME_LIST_MAX = 3;
/** Compact markdown preview matching inbox list rows: first block only,
* clamped, non-interactive so the overlay click still opens the thread. */
function DiscussionMessagePreview({
content,
mentionNames,
}: {
content: string;
mentionNames?: string[];
}) {
return (
<Markdown
className="inbox-preview-markdown mt-0.5 text-inherit leading-4"
content={discussionSnippet(content)}
interactive={false}
mentionNames={mentionNames}
/>
);
}
/**
* The "Alice, Bob and Carol" (or "Alice, Bob and 2 others") part of the
* sentence, with each name opening that person's profile popover. Mirrors
@@ -229,7 +388,7 @@ function DiscussionNameList({
) : null}
<UserProfilePopover pubkey={pubkey} triggerElement="span">
<button
className="font-medium text-foreground hover:underline"
className="pointer-events-auto font-medium text-foreground hover:underline"
type="button"
>
{resolveUserLabel({ profiles, pubkey })}
@@ -267,16 +426,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 (
@@ -314,7 +475,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);
@@ -325,63 +492,66 @@ export function DiscussionChannelsPanel({ query }: { query: string }) {
const profiles = profilesQuery.data?.profiles;
if (isLoading) {
return (
<p className="px-4 py-6 text-sm text-muted-foreground">
Searching channel discussions…
</p>
);
return <BuzzLoadingState label="Loading channel discussions" />;
}
if (channels.length === 0) {
return (
<p className="px-4 py-6 text-sm text-muted-foreground">
No channels reference this repository yet. Paste its link (or a PR or
issue link) in a channel and it will show up here.
No channels reference this repository yet. Paste its link (or a review
or task link) in a channel and it will show up here.
</p>
);
}
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

@@ -153,10 +153,10 @@ export function IssueAssigneesRow({
});
setPickerOpen(false);
setAssigneeQuery("");
toast.success("Issue assigned.");
toast.success("Task assigned.");
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Failed to assign issue.",
error instanceof Error ? error.message : "Failed to assign task.",
);
} finally {
assignmentOperationInFlightRef.current = false;
@@ -178,10 +178,10 @@ export function IssueAssigneesRow({
signerPubkey: operationSigner,
signAsManagedOwner,
});
toast.success("Issue unassigned.");
toast.success("Task unassigned.");
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Failed to unassign issue.",
error instanceof Error ? error.message : "Failed to unassign task.",
);
} finally {
assignmentOperationInFlightRef.current = false;
@@ -248,7 +248,7 @@ export function IssueAssigneesRow({
})}
{canSelfAssign && viewer ? (
<Button
className="h-6 px-1 text-xs text-muted-foreground hover:text-foreground"
className="h-5 px-1 text-xs text-muted-foreground hover:text-foreground"
data-testid="project-issue-self-assign"
disabled={assignMutation.isPending || unassignMutation.isPending}
onClick={() => {
@@ -265,7 +265,7 @@ export function IssueAssigneesRow({
<Dialog onOpenChange={setPickerOpen} open={pickerOpen}>
<DialogTrigger asChild>
<Button
className="h-6 px-1 text-xs text-muted-foreground hover:text-foreground"
className="h-5 px-1 text-xs text-muted-foreground hover:text-foreground"
data-testid="project-issue-assign"
disabled={assignMutation.isPending || unassignMutation.isPending}
size="xs"
@@ -277,9 +277,9 @@ export function IssueAssigneesRow({
</DialogTrigger>
<DialogContent className="max-w-md gap-0 overflow-hidden p-0">
<DialogHeader className="border-b border-border/60 px-6 py-5 pr-14">
<DialogTitle>Assign issue</DialogTitle>
<DialogTitle>Assign task</DialogTitle>
<DialogDescription>
Choose a person or agent to work on this issue.
Choose a person or agent to work on this task.
</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-2 border-b border-border/60 px-6 py-3">

View File

@@ -108,9 +108,7 @@ export function MergePullRequestButton({
setConfirmOpen(false);
}
toast.error(
error instanceof Error
? error.message
: "Failed to merge pull request.",
error instanceof Error ? error.message : "Failed to merge review.",
);
}
}, [mergeMutation, pullRequest]);
@@ -167,12 +165,12 @@ export function MergePullRequestButton({
statusEvent: unpublishedStatusEvent,
});
setUnpublishedStatusState(null);
toast.success("Published merged pull request status.");
toast.success("Published merged review status.");
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Failed to publish merged pull request status.",
: "Failed to publish merged review status.",
);
}
}, [publishMergedMutation, unpublishedStatusEvent]);
@@ -202,7 +200,7 @@ export function MergePullRequestButton({
</Button>
<AlertDialogContent data-testid="merge-pull-request-confirm">
<AlertDialogHeader>
<AlertDialogTitle>Merge pull request?</AlertDialogTitle>
<AlertDialogTitle>Merge review?</AlertDialogTitle>
<AlertDialogDescription>
Merge {pullRequest.branchName} into {targetBranch} and push the
result to the repository. The remote will reject the operation if
@@ -223,7 +221,7 @@ export function MergePullRequestButton({
}}
type="button"
>
{mergeMutation.isPending ? "Merging…" : "Merge pull request"}
{mergeMutation.isPending ? "Merging…" : "Merge review"}
</Button>
</AlertDialogAction>
</AlertDialogFooter>
@@ -254,8 +252,8 @@ export function MergePullRequestButton({
</pre>
) : (
<p className="rounded-md bg-background/80 p-2 text-xs text-muted-foreground">
Resolve in Terminal securely fetches the target and pull request
commits before showing copyable commands.
Resolve in Terminal securely fetches the target and review commits
before showing copyable commands.
</p>
)}
<div className="flex flex-wrap gap-2">

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

@@ -159,7 +159,7 @@ const PROJECT_STAT_ITEMS = [
iconClass: "text-primary",
barClass: "bg-primary",
columnClass: "w-16",
label: (count: number) => (count === 1 ? "PR" : "PRs"),
label: (count: number) => (count === 1 ? "review" : "reviews"),
},
{
key: "issueCount",
@@ -167,7 +167,7 @@ const PROJECT_STAT_ITEMS = [
iconClass: "text-orange-500",
barClass: "bg-orange-500",
columnClass: "w-20",
label: (count: number) => (count === 1 ? "issue" : "issues"),
label: (count: number) => (count === 1 ? "task" : "tasks"),
},
] as const;

View File

@@ -1,4 +1,4 @@
import { GitCommitHorizontal } from "lucide-react";
import { Calendar, GitCommitHorizontal, Hash } from "lucide-react";
import {
profileForCommit,
@@ -8,15 +8,23 @@ 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";
import { PROJECT_DETAIL_PANEL_CLASS } from "./projectPanelStyles";
import {
ProjectDetailMetaList,
ProjectDetailMetaRow,
} from "./ProjectDetailMeta";
import { ProjectDetailSection } from "./ProjectDetailSection";
import { PROJECT_DETAIL_READING_COLUMN_CLASS } from "./projectPanelStyles";
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, {
@@ -26,9 +34,8 @@ function commitDateLabel(timestamp: number) {
}
/**
* Detail view for a single commit: header with author identity and hash,
* followed by the commit-vs-parent diff rendered with the shared changed
* files panel.
* Detail view for a single commit: title and meta header, then collapsible
* description and the commit-vs-parent diff.
*/
export function ProjectCommitDetailPanel({
commit,
@@ -40,6 +47,7 @@ export function ProjectCommitDetailPanel({
originAgentName,
originChannelId,
profiles,
project,
viewerGitIdentity,
}: {
commit: ProjectRepoCommit | null;
@@ -52,6 +60,7 @@ export function ProjectCommitDetailPanel({
originAgentName?: string | null;
originChannelId?: string | null;
profiles?: UserProfileLookup;
project: Repository;
viewerGitIdentity?: ViewerGitIdentity | null;
}) {
const matchedProfile = commit
@@ -61,79 +70,104 @@ export function ProjectCommitDetailPanel({
? resolveUserLabel({ pubkey: matchedProfile.pubkey, profiles })
: (commit?.authorName ?? commit?.authorEmail ?? "Unknown author");
const shortHash = commit?.shortHash ?? commitHash.slice(0, 7);
const fileCount = diff?.files.length;
return (
<div className="space-y-3">
<header
className={`space-y-2 p-4 ${PROJECT_DETAIL_PANEL_CLASS}`}
data-project-detail-panel
>
<p className="flex flex-wrap items-center gap-1.5 text-xs font-medium text-muted-foreground">
<GitCommitHorizontal className="h-3.5 w-3.5" />
Commit from {authorLabel}
<ProjectOriginReference
agentName={originAgentName}
channelId={originChannelId}
<div
className={PROJECT_DETAIL_READING_COLUMN_CLASS}
data-project-detail-panel
data-testid="project-commit-detail"
>
<header className="space-y-2 px-6 pb-3 pt-5">
<h3 className="line-clamp-2 text-lg font-semibold leading-6 text-foreground">
{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"
/>
</p>
<div className="flex min-w-0 items-start gap-3">
</h3>
<p className="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs text-muted-foreground">
<ProfileIdentityButton
avatarClassName="mt-0.5 shrink-0"
avatarSize="md"
avatarClassName="shrink-0"
avatarSize="xs"
avatarUrl={matchedProfile?.profile.avatarUrl ?? null}
isAgent={matchedProfile?.profile.isAgent === true}
label={authorLabel}
pubkey={matchedProfile?.pubkey ?? null}
showLabel={false}
/>
<div className="min-w-0 flex-1 space-y-1">
<h3 className="line-clamp-2 text-base font-semibold text-foreground">
{commit?.subject ?? shortHash}
</h3>
<div className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs leading-4 text-muted-foreground">
<span className="flex items-center gap-0.5 font-mono">
{shortHash}
<CopyCommitHashButton
className="h-6 w-6"
hash={commit?.hash ?? commitHash}
/>
</span>
{commit ? (
<>
<span>·</span>
<span>{commitDateLabel(commit.timestamp)}</span>
</>
) : null}
{diff ? (
<>
<span>·</span>
<span className="text-green-500">+{diff.additions}</span>
<span className="text-destructive">-{diff.deletions}</span>
</>
) : null}
</div>
</div>
</div>
{diff?.commitBody ? (
<ProjectRichContent content={diff.commitBody} />
) : null}
<DiscussedInChannels
entityLabel="this commit"
query={commitDiscussionQuery({
hash: commit?.hash ?? commitHash,
shortHash: commit?.shortHash,
})}
testId="commit-discussed-in"
/>
<span className="font-medium text-foreground">{authorLabel}</span>
{commit ? (
<span
className="shrink-0 whitespace-nowrap"
title={commitDateLabel(commit.timestamp)}
>
{commitDateLabel(commit.timestamp)}
</span>
) : null}
<ProjectOriginReference
agentName={originAgentName}
channelId={originChannelId}
/>
</p>
</header>
<ProjectDiffFilesPanel
diff={diff}
error={diffError}
headerLabel={`${commit?.subject ?? "Commit"} · ${shortHash}`}
isLoading={diffLoading}
subjectLabel="commit"
<ProjectDetailMetaList>
<ProjectDetailMetaRow icon={Hash} label="Commit">
<span className="inline-flex min-w-0 items-center gap-1 font-mono text-xs">
{shortHash}
<CopyCommitHashButton
className="h-6 w-6"
hash={commit?.hash ?? commitHash}
/>
</span>
</ProjectDetailMetaRow>
{commit ? (
<ProjectDetailMetaRow icon={Calendar} label="Date">
<span className="text-muted-foreground">
{commitDateLabel(commit.timestamp)}
</span>
</ProjectDetailMetaRow>
) : null}
{diff ? (
<ProjectDetailMetaRow icon={GitCommitHorizontal} label="Changes">
<span className="flex items-center gap-1.5">
<span className="text-green-500">+{diff.additions}</span>
<span className="text-destructive">-{diff.deletions}</span>
</span>
</ProjectDetailMetaRow>
) : null}
</ProjectDetailMetaList>
{diff?.commitBody ? (
<ProjectDetailSection defaultOpen title="Description">
<ProjectRichContent
content={diff.commitBody}
hardLineBreaks={false}
/>
</ProjectDetailSection>
) : null}
<DiscussedInChannels
className="mx-6 mb-4"
entityLabel="this commit"
query={commitDiscussionQuery({
hash: commit?.hash ?? commitHash,
shortHash: commit?.shortHash,
})}
testId="commit-discussed-in"
/>
<ProjectDetailSection count={fileCount} defaultOpen title="Files changed">
<div className="-mx-6">
<ProjectDiffFilesPanel
diff={diff}
embedded
error={diffError}
headerLabel={`${commit?.subject ?? "Commit"} · ${shortHash}`}
isLoading={diffLoading}
subjectLabel="commit"
/>
</div>
</ProjectDetailSection>
</div>
);
}

View File

@@ -0,0 +1,362 @@
import { useQuery } from "@tanstack/react-query";
import * as React from "react";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import {
useChannelMembersQuery,
useChannelsQuery,
} from "@/features/channels/hooks";
import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane";
import {
useChannelSubscription,
useSendMessageMutation,
useToggleReactionMutation,
} from "@/features/messages/hooks";
import { getThreadReference } from "@/features/messages/lib/threading";
import { useIndependentThreadPanel } from "@/features/messages/useIndependentThreadPanel";
import { useThreadReplies } from "@/features/messages/useThreadReplies";
import { MessageThreadPanel } from "@/features/messages/ui/MessageThreadPanel";
import { MessageThreadPanelSkeleton } from "@/features/messages/ui/MessageThreadPanelSkeleton";
import type { TimelineMessage } from "@/features/messages/types";
import { useProfileQuery, useUsersBatchQuery } from "@/features/profile/hooks";
import { useIdentityQuery } from "@/shared/api/hooks";
import type { SearchHit } from "@/shared/api/searchTypes";
import { getEventById } from "@/shared/api/tauri";
import type { RelayEvent, RespondToMode } from "@/shared/api/types";
import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile";
import { Button } from "@/shared/ui/button";
const EMPTY_PERSONA_LOOKUP = new Map<string, string>();
const EMPTY_RESPOND_TO_LOOKUP = new Map<string, RespondToMode>();
async function loadConversationEvent(eventId: string) {
return getEventById(eventId);
}
/** Channel thread shown beside project task, review, and commit details. */
export function ProjectConversationPanel({
canResetWidth,
hit,
onClose,
onResetWidth,
onResizeStart,
sharedHeaderBackdrop,
widthPx,
}: {
canResetWidth: boolean;
hit: SearchHit;
onClose: () => void;
onResetWidth: () => void;
onResizeStart: (event: React.PointerEvent<HTMLButtonElement>) => void;
sharedHeaderBackdrop?: boolean;
widthPx: number;
}) {
const { goChannel } = useAppNavigation();
const channelsQuery = useChannelsQuery();
const activeChannel =
channelsQuery.data?.find((channel) => channel.id === hit.channelId) ?? null;
const channelId = activeChannel?.id ?? hit.channelId ?? null;
const channelLabel = activeChannel?.name ?? hit.channelName ?? null;
const identityQuery = useIdentityQuery();
const profileQuery = useProfileQuery();
const membersQuery = useChannelMembersQuery(activeChannel?.id ?? null);
const targetQuery = useQuery({
queryKey: ["project-conversation-target", hit.eventId],
queryFn: () => loadConversationEvent(hit.eventId),
});
const targetEvent = targetQuery.data ?? null;
const targetThread = targetEvent
? getThreadReference(targetEvent.tags)
: null;
const rootId =
hit.threadRootId ??
targetThread?.rootId ??
targetThread?.parentId ??
targetEvent?.id ??
null;
const rootQuery = useQuery({
queryKey: ["project-conversation-root", rootId ?? "none"],
queryFn: () =>
rootId ? loadConversationEvent(rootId) : Promise.resolve(null),
enabled: Boolean(rootId && rootId !== targetEvent?.id),
});
const threadRepliesQuery = useThreadReplies(activeChannel, rootId);
const repliesQueryEnabled =
activeChannel != null &&
activeChannel.channelType !== "forum" &&
rootId != null;
useChannelSubscription(activeChannel);
const threadReplyEvents = threadRepliesQuery.data ?? [];
const rootEvent =
rootId && targetEvent?.id === rootId
? targetEvent
: (rootQuery.data ?? null);
const rootEvents = React.useMemo(
() => (rootEvent ? [rootEvent] : []),
[rootEvent],
);
const profilePubkeys = React.useMemo(
() => [
...new Set(
[rootEvent, ...threadReplyEvents]
.filter((event): event is RelayEvent => event !== null)
.map((event) => event.pubkey),
),
],
[rootEvent, threadReplyEvents],
);
const profilesQuery = useUsersBatchQuery(profilePubkeys, {
enabled: profilePubkeys.length > 0,
});
const profiles = profilesQuery.data?.profiles;
const [expandedReplyIds, setExpandedReplyIds] = React.useState<Set<string>>(
() => new Set(),
);
const expansionKey = rootId ? `${hit.eventId}:${rootId}` : null;
const [appliedExpansionKey, setAppliedExpansionKey] = React.useState<
string | null
>(null);
const [replyTargetId, setReplyTargetId] = React.useState<string | null>(
rootId,
);
const [scrollTargetId, setScrollTargetId] = React.useState<string | null>(
hit.eventId === rootId ? null : hit.eventId,
);
const threadIdentity = `${hit.eventId}:${rootId ?? ""}`;
const threadIdentityRef = React.useRef(threadIdentity);
if (threadIdentityRef.current !== threadIdentity) {
threadIdentityRef.current = threadIdentity;
setAppliedExpansionKey(null);
setExpandedReplyIds(new Set());
setReplyTargetId(rootId);
setScrollTargetId(hit.eventId === rootId ? null : hit.eventId);
}
if (
expansionKey &&
repliesQueryEnabled &&
threadRepliesQuery.isFetched &&
appliedExpansionKey !== expansionKey
) {
setAppliedExpansionKey(expansionKey);
setExpandedReplyIds(new Set(threadReplyEvents.map((event) => event.id)));
}
const panelData = useIndependentThreadPanel({
activeChannel,
channelEvents: rootEvents,
threadReplyEvents,
rootId,
replyTargetId,
expandedReplyIds,
currentPubkey: identityQuery.data?.pubkey,
currentAvatarUrl: profileQuery.data?.avatarUrl ?? null,
profiles,
ownerProfiles: profiles,
members: membersQuery.data,
personaLookup: EMPTY_PERSONA_LOOKUP,
respondToLookup: EMPTY_RESPOND_TO_LOOKUP,
relaySelfPubkey: null,
});
const sendMessageMutation = useSendMessageMutation(
activeChannel,
identityQuery.data,
);
const toggleReactionMutation = useToggleReactionMutation();
const isOverlay = useIsThreadPanelOverlay();
const isComposerDisabled =
!activeChannel?.isMember ||
activeChannel.archivedAt !== null ||
activeChannel.channelType === "forum";
const canShowThread =
Boolean(activeChannel && panelData.threadHead) &&
(expansionKey == null ||
!repliesQueryEnabled ||
appliedExpansionKey === expansionKey);
const isResolving =
!canShowThread &&
(channelsQuery.isPending ||
targetQuery.isPending ||
Boolean(
rootId &&
targetEvent &&
rootId !== targetEvent.id &&
rootQuery.isPending,
) ||
Boolean(repliesQueryEnabled && !threadRepliesQuery.isFetched) ||
Boolean(
repliesQueryEnabled &&
expansionKey &&
threadRepliesQuery.isFetched &&
appliedExpansionKey !== expansionKey,
));
const openChannel = React.useCallback(() => {
if (channelId) void goChannel(channelId);
}, [channelId, goChannel]);
const layoutProps = {
canResetWidth,
enterMotion: !canShowThread,
headerTitle: channelLabel ? `#${channelLabel}` : "Thread",
headerTitleAriaLabel: channelLabel ? `Open #${channelLabel}` : undefined,
isFocusMode: false,
isSinglePanelView: !isOverlay,
layout: "standalone" as const,
onHeaderTitleClick: channelId ? openChannel : undefined,
onResetWidth,
onResizeStart,
showBackButton: false,
splitPaneClamp: false,
testId: isOverlay ? "project-conversation-panel" : "message-thread-panel",
transparentChrome: sharedHeaderBackdrop,
};
const handleSend = React.useCallback(
async (
content: string,
mentionPubkeys: string[],
mediaTags?: string[][],
channelId?: string | null,
threadContext?: {
parentEventId: string | null;
threadHeadId: string | null;
} | null,
) => {
if (!activeChannel || !rootId) return;
const parentEventId =
threadContext?.parentEventId ?? replyTargetId ?? rootId;
const sentMessage = await sendMessageMutation.mutateAsync({
channelId: channelId ?? activeChannel.id,
content,
mediaTags,
mentionPubkeys,
parentEventId,
targetChannel: activeChannel,
});
setReplyTargetId(rootId);
setExpandedReplyIds((current) => new Set(current).add(parentEventId));
setScrollTargetId(sentMessage.id);
},
[activeChannel, replyTargetId, rootId, sendMessageMutation.mutateAsync],
);
const handleExpandReplies = React.useCallback((message: TimelineMessage) => {
setExpandedReplyIds((current) => {
const next = new Set(current);
if (next.has(message.id)) next.delete(message.id);
else next.add(message.id);
return next;
});
}, []);
if (canShowThread && activeChannel && panelData.threadHead) {
return wrapProjectConversationPane(
<MessageThreadPanel
{...layoutProps}
activityAccessoryVisible={false}
channel={activeChannel}
channelId={activeChannel.id}
channelName={activeChannel.name}
currentPubkey={identityQuery.data?.pubkey}
disabled={isComposerDisabled}
isSending={sendMessageMutation.isPending}
onCancelReply={() => setReplyTargetId(rootId)}
onClose={onClose}
onExpandReplies={handleExpandReplies}
onScrollTargetResolved={() => setScrollTargetId(null)}
onSelectReplyTarget={(message) => setReplyTargetId(message.id)}
onSend={handleSend}
onToggleReaction={
isComposerDisabled
? undefined
: async (message, emoji, remove) => {
await toggleReactionMutation.mutateAsync({
emoji,
eventId: message.id,
remove,
});
}
}
profiles={profiles}
replyTargetMessage={panelData.replyTargetMessage}
scrollTargetId={scrollTargetId}
threadHead={panelData.threadHead}
threadReplies={panelData.visibleReplies}
threadRepliesPending={false}
threadTypingPubkeys={[]}
widthPx={widthPx}
/>,
{
canResetWidth,
isOverlay,
onResetWidth,
onResizeStart,
widthPx,
},
);
}
if (isResolving) {
return wrapProjectConversationPane(
<MessageThreadPanelSkeleton
{...layoutProps}
onClose={onClose}
widthPx={widthPx}
/>,
{
canResetWidth,
isOverlay,
onResetWidth,
onResizeStart,
widthPx,
},
);
}
return wrapProjectConversationPane(
<div className="flex h-full flex-col items-center justify-center gap-3 bg-background p-6 text-center">
<p className="text-sm text-muted-foreground">
This conversation could not be loaded.
</p>
<Button onClick={onClose} size="sm" variant="outline">
Close
</Button>
</div>,
{
canResetWidth,
isOverlay,
onResetWidth,
onResizeStart,
widthPx,
},
);
}
function wrapProjectConversationPane(
panel: React.ReactNode,
{
canResetWidth,
isOverlay,
onResetWidth,
onResizeStart,
widthPx,
}: {
canResetWidth: boolean;
isOverlay: boolean;
onResetWidth: () => void;
onResizeStart: (event: React.PointerEvent<HTMLButtonElement>) => void;
widthPx: number;
},
) {
if (isOverlay) return panel;
return (
<RightAuxiliaryPane
canResetWidth={canResetWidth}
onResetWidth={onResetWidth}
onResizeStart={onResizeStart}
testId="project-conversation-panel"
widthPx={widthPx}
>
<div className="flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden [&>aside]:!h-full [&>aside]:!w-full [&>aside]:!max-w-none [&>aside]:!border-l-0">
{panel}
</div>
</RightAuxiliaryPane>
);
}

View File

@@ -0,0 +1,133 @@
import * as React from "react";
import type { SearchHit } from "@/shared/api/searchTypes";
import { cn } from "@/shared/lib/cn";
import { ProjectConversationPanel } from "./ProjectConversationPanel";
import { PROJECT_COLUMN_HEADER_BACKDROP_CLASS } from "./projectPanelStyles";
type ProjectConversationPanelContextValue = {
openConversation: (hit: SearchHit) => void;
};
const ProjectConversationPanelContext =
React.createContext<ProjectConversationPanelContextValue | null>(null);
/** Makes project-related channel conversations available as an auxiliary panel. */
export function ProjectConversationPanelProvider({
children,
onOpenConversation,
}: {
children: React.ReactNode;
onOpenConversation: (hit: SearchHit) => void;
}) {
const value = React.useMemo(
() => ({ openConversation: onOpenConversation }),
[onOpenConversation],
);
return (
<ProjectConversationPanelContext.Provider value={value}>
{children}
</ProjectConversationPanelContext.Provider>
);
}
/** Owns project conversation selection and renders the shared auxiliary pane. */
export function ProjectConversationPanelController({
canResetWidth,
children,
closeWhen,
detachFallbackPanel = false,
fallbackPanel,
onOpenConversation,
onResetWidth,
onResizeStart,
resetKey,
sharedHeaderBackdrop,
widthPx,
}: {
canResetWidth: boolean;
children: React.ReactNode;
closeWhen: boolean;
detachFallbackPanel?: 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);
const previousResetKeyRef = React.useRef(resetKey);
React.useEffect(() => {
const changedContext = previousResetKeyRef.current !== resetKey;
previousResetKeyRef.current = resetKey;
if (closeWhen || changedContext) setHit(null);
}, [closeWhen, resetKey]);
const openConversation = React.useCallback(
(nextHit: SearchHit) => {
onOpenConversation();
setHit(nextHit);
},
[onOpenConversation],
);
const detached =
detachFallbackPanel && hit === null && fallbackPanel !== undefined;
return (
<ProjectConversationPanelProvider onOpenConversation={openConversation}>
<div
className={cn(
"relative flex min-h-0 min-w-0 flex-1 flex-row overflow-hidden",
detached && "gap-2 bg-sidebar pb-2 pr-2 pt-px",
)}
data-detached={detached ? "true" : "false"}
data-project-context-detached={detached ? "true" : undefined}
data-project-detail-screen
data-testid="project-panel-layout"
>
{sharedHeaderBackdrop && !detached ? (
<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}
<div
className={cn(
detached
? "relative ml-px flex min-h-0 min-w-0 flex-1 overflow-hidden rounded-2xl bg-background"
: "contents",
)}
data-testid={detached ? "project-content-pod" : undefined}
>
{sharedHeaderBackdrop && detached ? (
<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}
</div>
{hit ? (
<ProjectConversationPanel
canResetWidth={canResetWidth}
hit={hit}
onClose={() => setHit(null)}
onResetWidth={onResetWidth}
onResizeStart={onResizeStart}
sharedHeaderBackdrop={sharedHeaderBackdrop}
widthPx={widthPx}
/>
) : (
fallbackPanel
)}
</div>
</ProjectConversationPanelProvider>
);
}
/** Returns null outside project detail, where normal channel navigation remains. */
export function useProjectConversationPanel() {
return React.useContext(ProjectConversationPanelContext);
}

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,76 @@ 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 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"
style={{
left: "calc(50% + var(--app-top-chrome-center-offset, 0rem))",
}}
>
<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 +94,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 +112,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 {
@@ -12,61 +13,123 @@ import type {
} from "@/features/projects/hooks";
import { relativeTime } from "@/features/projects/lib/projectsViewHelpers";
import type { ProjectRepoCommit } from "@/shared/api/types";
import { truncatePubkey } from "@/shared/lib/pubkey";
import { BuzzLoadingState } from "@/shared/ui/BuzzLoadingState";
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 })
: truncatePubkey(pubkey),
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 +144,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>
@@ -154,14 +242,7 @@ export function ActivityPanel({
);
if (isLoading) {
return (
<p
className={PROJECT_DETAIL_PANEL_MESSAGE_CLASS}
data-project-detail-panel
>
Loading activity…
</p>
);
return <BuzzLoadingState label="Loading activity" />;
}
if (commits.length === 0) {
@@ -179,7 +260,7 @@ export function ActivityPanel({
return (
<section className={PROJECT_DETAIL_PANEL_CLASS} data-project-detail-panel>
<div className="divide-y divide-border/50">
<div className="space-y-0.5 px-2">
{commits.map((commit) => {
const matchedProfile = profileForCommit(
commit,
@@ -202,57 +283,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

@@ -0,0 +1,81 @@
import type { LucideIcon } from "lucide-react";
import type * as React from "react";
import { cn } from "@/shared/lib/cn";
const META_ROW_CLASS =
"flex min-h-7 min-w-0 items-center gap-2 py-1 text-xs leading-4";
/** Chip height matches the meta row's text/avatar line so Labels isn't taller. */
export const PROJECT_DETAIL_META_PILL_CLASS =
"inline-flex h-5 items-center rounded-full border border-border/60 px-1.5 text-2xs leading-none text-muted-foreground";
/** Labeled icon rows under the work-item title, in a padded muted card. */
export function ProjectDetailMetaList({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div
className={cn(
"mx-6 mb-4 mt-2 rounded-lg bg-muted/35 px-3 py-1.5",
className,
)}
data-testid="project-detail-meta"
>
{children}
</div>
);
}
export function ProjectDetailMetaRow({
children,
icon: Icon,
label,
labelClassName,
leading,
}: {
children: React.ReactNode;
icon?: LucideIcon;
label: string;
labelClassName?: string;
leading?: React.ReactNode;
}) {
return (
<div className={META_ROW_CLASS}>
{leading ??
(Icon ? (
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
) : (
<span className="h-4 w-4 shrink-0" />
))}
<span
className={cn(
"w-20 shrink-0 truncate whitespace-nowrap font-normal text-muted-foreground/75",
labelClassName,
)}
title={label}
>
{label}
</span>
<div className="flex min-h-5 min-w-0 flex-1 items-center font-medium text-foreground">
{children}
</div>
</div>
);
}
export function ProjectDetailMetaPills({ labels }: { labels: string[] }) {
return (
<div className="flex flex-wrap items-center gap-1">
{labels.map((label) => (
<span className={PROJECT_DETAIL_META_PILL_CLASS} key={label}>
{label}
</span>
))}
</div>
);
}

View File

@@ -0,0 +1,43 @@
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,
detachedRepository = false,
mode,
sharedHeaderBackdrop,
...repositoryProps
}: RepositoryPanelProps & {
context: ProjectDetailAgentContext;
detachedRepository?: boolean;
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
detached={detachedRepository}
{...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,31 +28,13 @@ 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 { openProjectMergeRecoveryTerminal } from "@/shared/api/projectGit";
import { useMainInsetRef } from "@/shared/layout/MainInsetContext";
import { channelContentTopPaddingMeasurement } from "@/shared/layout/chromeLayout";
import { useMeasuredCssVariable } from "@/shared/layout/useMeasuredCssVariable";
import { UserProfilePanel } from "@/features/profile/ui/UserProfilePanel";
import { ProfilePanelProvider } from "@/shared/context/ProfilePanelContext";
import { useHistorySearchState } from "@/shared/hooks/useHistorySearchState";
import { useThreadPanelWidth } from "@/shared/hooks/useThreadPanelWidth";
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,28 +46,38 @@ 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";
import { WorkspaceTabs } from "./ProjectWorkspaceTabs";
import type { RepoSourceHeaderControls } from "./ProjectRepositorySource";
import { showProjectCloneErrorToast } from "./projectGitErrorToast";
import {
projectTerminalLabel,
useOpenProjectTerminal,
} from "./useOpenProjectTerminal";
import { projectTerminalLabel } from "./useOpenProjectTerminal";
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 { useProjectPanelWidths } from "./useProjectPanelWidths";
import { useProjectRepositoryOpenActions } from "./useProjectRepositoryOpenActions";
import { pushPullTitle, snapshotHasContent } from "./projectDetailHelpers";
type ProjectDetailScreenProps = {
commitHash?: string;
@@ -98,11 +90,6 @@ type ProjectDetailScreenProps = {
tab?: EntityLinkTab;
};
const PROJECT_DETAIL_PANEL_SEARCH_KEYS = [
"profile",
"profileTab",
"profileView",
] as const;
const PROJECT_REPOSITORY_SEARCH_KEYS = [
"repositoryId",
"issueId",
@@ -120,28 +107,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);
@@ -180,6 +154,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
const [selectedIssueId, setSelectedIssueId] = React.useState<string | null>(
issueId ?? null,
);
const [createIssueRequestKey, setCreateIssueRequestKey] = React.useState(0);
// biome-ignore lint/correctness/useExhaustiveDependencies: the transient request ID deliberately reapplies an unchanged entity selection.
React.useEffect(() => {
setSelectedPullRequestId(pullRequestId ?? null);
@@ -192,19 +167,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);
@@ -252,6 +223,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
const [repoSource, setRepoSource] = React.useState<"remote" | "local">(
"remote",
);
const repositoryPanel = useProjectRepositoryPanel();
const repoSnapshotQuery = useProjectRepoSnapshotQuery(
repository,
activeBranch,
@@ -259,6 +231,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,
@@ -383,16 +366,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,
@@ -420,8 +416,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();
}
@@ -466,9 +470,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);
@@ -476,102 +477,41 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
}
}, [projectPending, repository]);
React.useEffect(() => {
setRepoSource((currentSource) => {
if (selectedTag) return "remote";
if (currentSource === "local" && !hasLocalCheckout) return "remote";
if (
currentSource === "remote" &&
!hasRemoteSnapshot &&
hasLocalCheckout
) {
return "local";
}
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,
if (selectedTag) {
if (repoSource !== "remote") setRepoSource("remote");
return;
}
if (repoSource === "local" && !hasLocalCheckout) {
setRepoSource("remote");
return;
}
if (repoSource === "remote" && !hasRemoteSnapshot && hasLocalCheckout) {
setRepoSource("local");
}
}, [hasLocalCheckout, hasRemoteSnapshot, repoSource, selectedTag]);
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 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 {
handleCloseProfilePanel,
handleOpenDm,
handleOpenProfilePanel,
handleProfilePanelTabChange,
handleProfilePanelViewChange,
profilePanelPubkey,
profilePanelTab,
profilePanelView,
} = useProjectProfilePanel();
const { activeRightPanelWidth, threadPanelWidth } = useProjectPanelWidths(
repositoryPanel.mode,
);
const handlePushLocalRepo = React.useCallback(async () => {
try {
@@ -583,7 +523,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
} else {
toast.success(
result.pullRequestUpdate.status === "updated"
? `${result.message} Pull request updated.`
? `${result.message} Review updated.`
: result.message,
);
}
@@ -611,9 +551,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,
@@ -643,9 +597,9 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
],
);
const handleCreateIssue = React.useCallback(
async ({ body, title }: CreateIssueDialogInput) => {
const issueId = await createIssueMutation.mutateAsync({ body, title });
toast.success("Issue created.");
async (input: CreateIssueDialogInput) => {
const issueId = await createIssueMutation.mutateAsync(input);
toast.success("Task created.");
await issuesQuery.refetch();
setSelectedIssueId(issueId);
},
@@ -659,15 +613,11 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
commit,
mergeBase: repoSyncStatusQuery.data?.mergeBase ?? null,
});
toast.success(
updated ? "Pull request updated." : "Pull request is already current.",
);
toast.success(updated ? "Review updated." : "Review is already current.");
await pullRequestsQuery.refetch();
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Failed to update pull request",
error instanceof Error ? error.message : "Failed to update review",
);
}
}, [
@@ -698,34 +648,35 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
repoStateQuery,
repoSyncStatusQuery,
]);
const openTerminal = useOpenProjectTerminal(activeCommunity?.reposDir);
const handleOpenTerminal = React.useCallback(() => {
if (!repository) return Promise.resolve();
return openTerminal(repository, {
branch: activeBranch,
hasLocalCheckout,
});
}, [activeBranch, hasLocalCheckout, openTerminal, repository]);
const handleOpenMergeRecoveryTerminal = React.useCallback(
async (input: {
expectedCommit: string;
sourceBranch: string;
sourceCloneUrl: string;
targetBranch: string;
}) => {
const targetCloneUrl = repository?.cloneUrls[0];
if (!repository || !targetCloneUrl) {
throw new Error("No project selected.");
}
return openProjectMergeRecoveryTerminal({
...input,
projectDtag: repository.dtag,
reposDir: activeCommunity?.reposDir,
targetCloneUrl,
});
},
[activeCommunity?.reposDir, repository],
);
const localRepositoryPath =
repoSyncStatusQuery.data?.localPath ??
localRepoSnapshotQuery.data?.path ??
null;
const {
handleOpenLocalRepository,
handleOpenMergeRecoveryTerminal,
handleOpenTerminal,
} = useProjectRepositoryOpenActions({
activeBranch,
hasLocalCheckout,
localRepositoryPath,
repository,
reposDir: activeCommunity?.reposDir,
});
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" />;
@@ -791,6 +742,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;
@@ -805,42 +763,43 @@ 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: "Pull Request",
title: selectedPullRequest.title,
clear: () => setSelectedPullRequestId(null),
}
: selectedIssue
? {
category: "Issues",
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 detachedRepositoryPanel =
!profilePanelPubkey &&
!repositoryPanel.collapsed &&
repositoryPanel.mode === "repository";
const sharedHeaderBackdrop =
!selectedPullRequestId && !selectedIssueId && !selectedCommitHash;
const handleRepositoryChange = (nextRepositoryId: string) => {
applyRepositorySearch({
repositoryId: nextRepositoryId,
@@ -865,123 +824,170 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
existingBranches={branchOptionsWithLocal}
/>
<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-col overflow-hidden">
<ProjectDetailChrome
activeTabCrumb={activeTabCrumb}
activeWorkItemCrumb={activeWorkItemCrumb}
chromeRef={projectDetailHeaderChromeRef}
onGoProjectHome={handleGoToProjectHome}
onGoProjects={() => {
void goProjects();
}}
project={project}
shareTab={
activeWorkItemCrumb
? undefined
: shareTabForWorkspaceTab(activeTab)
}
/>
<div className="flex min-h-0 min-w-0 flex-1 flex-col 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)]">
<WorkspaceTabs
key={`${project.id}:${repository.id}:${tabsResetKey}`}
initialTab={
requestedTab
? workspaceTabForShareTab(requestedTab)
: undefined
}
initialTabRequestKey={entityNavigationId}
commitDiff={commitDiffQuery.data}
commitDiffError={commitDiffQuery.error}
commitDiffLoading={commitDiffQuery.isLoading}
createIssueAction={{
onCreate: handleCreateIssue,
pending: createIssueMutation.isPending,
<ProjectConversationPanelController
canResetWidth={threadPanelWidth.canReset}
closeWhen={Boolean(profilePanelPubkey)}
detachFallbackPanel={detachedRepositoryPanel}
fallbackPanel={
profilePanelPubkey || repositoryPanel.collapsed ? null : (
<ProjectDetailRightPanel
activeTab={activeTab}
canResetWidth={activeRightPanelWidth.canReset}
contributors={displayedRepositoryContributors}
context={agentPageContext}
createIssuePending={createIssueMutation.isPending}
detachedRepository={detachedRepositoryPanel}
files={displayedRepositoryFiles}
identityPubkey={identityPubkey}
issues={issuesQuery.data ?? []}
mode={repositoryPanel.mode}
onCreateTask={() => {
setCreateIssueRequestKey((key) => key + 1);
}}
createPullRequestAction={{
onCreated: handlePullRequestCreated,
projects: projectsQuery.data ?? [project],
reposDir: activeCommunity?.reposDir,
}}
updatePullRequestAction={
openBranchPullRequest &&
repoSyncStatusQuery.data?.remoteHead &&
repoSyncStatusQuery.data.remoteHead !==
openBranchPullRequest.commit
? {
onUpdate: () => {
void handleUpdatePullRequest();
},
pending: updatePullRequestMutation.isPending,
}
: undefined
}
localSnapshot={localRepoSnapshotQuery.data}
localSnapshotError={localRepoSnapshotQuery.error}
localSnapshotLoading={localRepoSnapshotQuery.isLoading}
onBranchChange={handleBranchChange}
onOpenMergeRecoveryTerminal={handleOpenMergeRecoveryTerminal}
onOpenTerminal={() => {
void handleOpenTerminal();
}}
terminalTitle={projectTerminalLabel(hasLocalCheckout)}
onSelectedCommitHashChange={handleSelectedCommitHashChange}
onSelectedIssueIdChange={handleSelectedIssueIdChange}
onSelectedPullRequestIdChange={
handleSelectedPullRequestIdChange
}
onSelectedTabChange={setActiveTab}
onOpenLocalRepository={() => {
void handleOpenLocalRepository();
}}
onRepositoryChange={handleRepositoryChange}
onResetWidth={activeRightPanelWidth.onResetWidth}
onResizeStart={activeRightPanelWidth.onResizeStart}
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}
repoDiffLoading={displayedRepoDiffLoading}
pullRequests={pullRequestsQuery.data ?? []}
pullRequestsError={pullRequestsQuery.error}
pullRequestsLoading={pullRequestsQuery.isLoading}
repoContributors={repoContributors}
repoHost={repoRemote.host}
repoSource={repoSource}
selectedCommitHash={selectedCommitHash}
selectedIssueId={selectedIssueId}
selectedPullRequestId={selectedPullRequestId}
snapshot={repoSnapshotQuery.data}
snapshotError={repoSnapshotQuery.error}
snapshotLoading={repoSnapshotQuery.isLoading}
project={project}
projects={projectsQuery.data ?? []}
repository={repository}
sharedHeaderBackdrop={sharedHeaderBackdrop}
snapshot={displayedRepositorySnapshot}
sourceControls={filesSourceControls}
viewerGitIdentity={viewerGitIdentity}
terminalTitle={projectTerminalLabel(hasLocalCheckout)}
widthPx={activeRightPanelWidth.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}
onGoProjectHome={handleGoToProjectHome}
onGoProjects={() => {
void goProjects();
}}
project={project}
repository={repository}
shareTab={
activeWorkItemCrumb
? undefined
: shareTabForWorkspaceTab(activeTab)
}
/>
<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={
requestedTab
? workspaceTabForShareTab(requestedTab)
: undefined
}
initialTabRequestKey={entityNavigationId}
commitDiff={commitDiffQuery.data}
commitDiffError={commitDiffQuery.error}
commitDiffLoading={commitDiffQuery.isLoading}
contributorActivityCounts={contributorActivityCounts}
contributorPubkeys={contributorPubkeys}
createIssueAction={{
onCreate: handleCreateIssue,
pending: createIssueMutation.isPending,
}}
createIssueRequestKey={createIssueRequestKey}
createPullRequestAction={{
onCreated: handlePullRequestCreated,
projects: projectsQuery.data ?? [project],
reposDir: activeCommunity?.reposDir,
}}
updatePullRequestAction={
openBranchPullRequest &&
repoSyncStatusQuery.data?.remoteHead &&
repoSyncStatusQuery.data.remoteHead !==
openBranchPullRequest.commit
? {
onUpdate: () => {
void handleUpdatePullRequest();
},
pending: updatePullRequestMutation.isPending,
}
: undefined
}
localSnapshot={localRepoSnapshotQuery.data}
localSnapshotError={localRepoSnapshotQuery.error}
localSnapshotLoading={localRepoSnapshotQuery.isLoading}
onBranchChange={handleBranchChange}
onFilesContextChange={setFilesContext}
onOpenMergeRecoveryTerminal={handleOpenMergeRecoveryTerminal}
onSelectedCommitHashChange={handleSelectedCommitHashChange}
onSelectedIssueIdChange={handleSelectedIssueIdChange}
onSelectedPullRequestIdChange={
handleSelectedPullRequestIdChange
}
onSelectedTabChange={setActiveTab}
profiles={profiles}
project={repository}
projectId={project.id}
repoDiff={displayedRepoDiff}
repoDiffError={displayedRepoDiffError}
repoDiffLoading={displayedRepoDiffLoading}
pullRequests={pullRequestsQuery.data ?? []}
pullRequestsError={pullRequestsQuery.error}
pullRequestsLoading={pullRequestsQuery.isLoading}
repoContributors={repoContributors}
repoHost={repoRemote.host}
repoSource={repoSource}
selectedCommitHash={selectedCommitHash}
selectedIssueId={selectedIssueId}
selectedPullRequestId={selectedPullRequestId}
sharedHeaderBackdrop={sharedHeaderBackdrop}
snapshot={repoSnapshotQuery.data}
snapshotError={repoSnapshotQuery.error}
snapshotLoading={repoSnapshotQuery.isLoading}
sourceControls={filesSourceControls}
viewerGitIdentity={viewerGitIdentity}
/>
</div>
</div>
</div>
</div>
{profilePanelPubkey ? (
<UserProfilePanel
canResetWidth={threadPanelWidth.canReset}
currentPubkey={identityQuery.data?.pubkey}
onClose={handleCloseProfilePanel}
onOpenDm={handleOpenDm}
onOpenProfile={handleOpenProfilePanel}
onResetWidth={threadPanelWidth.onResetWidth}
onResizeStart={threadPanelWidth.onResizeStart}
onTabChange={handleProfilePanelTabChange}
onViewChange={handleProfilePanelViewChange}
pubkey={profilePanelPubkey}
tab={profilePanelTab}
view={profilePanelView}
widthPx={threadPanelWidth.widthPx}
/>
) : null}
{profilePanelPubkey ? (
<UserProfilePanel
canResetWidth={threadPanelWidth.canReset}
currentPubkey={identityPubkey}
onClose={handleCloseProfilePanel}
onOpenDm={handleOpenDm}
onOpenProfile={handleOpenProfilePanel}
onResetWidth={threadPanelWidth.onResetWidth}
onResizeStart={threadPanelWidth.onResizeStart}
onTabChange={handleProfilePanelTabChange}
onViewChange={handleProfilePanelViewChange}
pubkey={profilePanelPubkey}
tab={profilePanelTab}
transparentChrome={sharedHeaderBackdrop}
view={profilePanelView}
widthPx={threadPanelWidth.widthPx}
/>
) : null}
</ProjectConversationPanelController>
</div>
</ProfilePanelProvider>
);

View File

@@ -0,0 +1,66 @@
import { ChevronDown } from "lucide-react";
import * as React from "react";
import { cn } from "@/shared/lib/cn";
/**
* Expand/collapse block for work-item detail (description, files, activity).
* The chevron sits immediately after the title. It points down when open
* and right when collapsed.
*/
export function ProjectDetailSection({
children,
count,
defaultOpen = true,
onOpenChange,
open: openProp,
testId,
title,
}: {
children: React.ReactNode;
count?: number;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
open?: boolean;
testId?: string;
title: string;
}) {
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen);
const isControlled = openProp !== undefined;
const open = isControlled ? openProp : uncontrolledOpen;
const setOpen = (next: boolean) => {
if (!isControlled) setUncontrolledOpen(next);
onOpenChange?.(next);
};
return (
<section
className="border-border/50 border-t"
data-open={open ? "true" : "false"}
data-testid={testId ?? "project-detail-section"}
>
<button
aria-expanded={open}
className="flex min-h-10 w-full min-w-0 items-center gap-2 px-6 py-2 text-left text-sm font-medium leading-5 text-foreground transition-colors hover:bg-muted/20"
onClick={() => setOpen(!open)}
type="button"
>
<span className="flex min-w-0 items-center gap-1.5">
<span className="truncate">{title}</span>
<ChevronDown
className={cn(
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
!open && "-rotate-90",
)}
/>
{open && count != null ? (
<span className="shrink-0 font-medium text-muted-foreground">
{count}
</span>
) : null}
</span>
</button>
{open ? <div className="px-6 pb-6 pt-1">{children}</div> : null}
</section>
);
}

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,4 +1,14 @@
import { CircleCheck, CircleDot, CircleX, MessageSquare } from "lucide-react";
import {
Circle,
CircleCheck,
CircleDashed,
CircleDot,
CircleX,
MessageSquare,
Tag,
type LucideIcon,
User,
} from "lucide-react";
import * as React from "react";
import { toast } from "sonner";
@@ -17,40 +27,93 @@ import {
import { entityDiscussionQuery } from "@/features/projects/lib/discussionChannels";
import { issueShareLink } from "@/features/projects/lib/projectShareLinks";
import { relativeTime } from "@/features/projects/lib/projectsViewHelpers";
import {
projectTaskCategoryLabel,
projectTaskUserLabels,
} from "@/features/projects/projectTaskCategories";
import { useIdentityQuery } from "@/shared/api/hooks";
import type { ChannelMember } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { BuzzLoadingState } from "@/shared/ui/BuzzLoadingState";
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";
import { OverviewRailSection } from "./ProjectOverviewPanel";
import {
ProjectDetailMetaList,
ProjectDetailMetaPills,
ProjectDetailMetaRow,
} from "./ProjectDetailMeta";
import { ProjectDetailSection } from "./ProjectDetailSection";
import { ProfileIdentityButton } from "./ProjectProfileIdentity";
import { ProjectRichContent } from "./ProjectRichContent";
import { ShareLinkButton } from "./ShareLinkButton";
import { PROJECT_DETAIL_READING_COLUMN_CLASS } from "./projectPanelStyles";
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,
@@ -90,67 +153,87 @@ function IssueRow({
const status = issueStatusVisual(issue.status);
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
issue
</span>
<span>·</span>
<span>{issue.status}</span>
{issue.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 issue"
/>
</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()}
>
@@ -167,12 +250,10 @@ export function ProjectIssueDetail({
issue,
profiles,
project,
stackMetaRail = false,
}: {
issue: ProjectIssue;
profiles?: UserProfileLookup;
project: Project;
stackMetaRail?: boolean;
}) {
const commentMutation = useCreateProjectIssueCommentMutation(project);
const authorLabel = resolveUserLabel({ profiles, pubkey: issue.author });
@@ -203,46 +284,99 @@ export function ProjectIssueDetail({
},
[commentMutation, issue],
);
const identityQuery = useIdentityQuery();
const authorProfile = profiles?.[normalizePubkey(issue.author)];
const status = issueStatusVisual(issue.status);
const labels = projectTaskUserLabels(issue.labels);
const viewerPubkey = identityQuery.data?.pubkey;
const viewer = viewerPubkey ? normalizePubkey(viewerPubkey) : null;
const isAuthor = viewer === normalizePubkey(issue.author);
const isOwner = viewer === normalizePubkey(project.owner);
const isManagedAgentOwner = useIsManagedAgent(project.owner) === true;
const canAssignOthers =
Boolean(viewer) && (isAuthor || isOwner || isManagedAgentOwner);
return (
<div
className={cn(
"grid",
!stackMetaRail && "xl:grid-cols-[minmax(0,1fr)_18rem]",
)}
className={PROJECT_DETAIL_READING_COLUMN_CLASS}
data-project-detail-panel
data-testid="project-issue-detail"
>
<div className="min-w-0">
<header className="space-y-3 p-4">
<div className="min-w-0">
<p className="flex flex-wrap items-center gap-1.5 text-xs font-medium text-muted-foreground">
<CircleDot className="h-3.5 w-3.5" />
Issue from {authorLabel}
<ProjectOriginReference
agentName={issue.originAgentName}
channelId={issue.channelId}
/>
</p>
<h3 className="mt-1 line-clamp-2 text-base font-semibold text-foreground">
{issue.title}{" "}
<span className="font-normal text-muted-foreground">
#{issue.id.slice(0, 8)}
</span>
<ShareLinkButton
className="ml-1 inline-flex h-6 w-6 align-text-bottom"
label="Copy issue link"
link={issueShareLink(issue)}
testId="project-issue-copy-link"
/>
</h3>
</div>
{issue.content ? (
<ProjectRichContent content={issue.content} tags={issue.tags} />
) : null}
</header>
<section className="space-y-3 p-4">
<header className="space-y-2 px-6 pb-3 pt-5">
<h3 className="line-clamp-2 text-lg font-semibold leading-6 text-foreground">
{issue.title}{" "}
<span className="font-normal text-muted-foreground">
#{issue.id.slice(0, 8)}
</span>
<ShareLinkButton
className="ml-1 inline-flex h-7 w-7 align-text-bottom"
label="Copy task link"
link={issueShareLink(issue)}
testId="project-issue-copy-link"
/>
</h3>
<p className="flex flex-wrap items-center gap-x-1 gap-y-1 text-xs text-muted-foreground">
<ProfileIdentityButton
avatarClassName="shrink-0"
avatarSize="xs"
avatarUrl={authorProfile?.avatarUrl ?? null}
isAgent={authorProfile?.isAgent === true}
label={authorLabel}
pubkey={issue.author}
showLabel={false}
/>
<span className="font-medium text-foreground">{authorLabel}</span>
<span
className="shrink-0 whitespace-nowrap"
title={new Date(issue.createdAt * 1_000).toLocaleString()}
>
{relativeTime(issue.createdAt)}
</span>
<ProjectOriginReference
agentName={issue.originAgentName}
channelId={issue.channelId}
/>
</p>
</header>
<ProjectDetailMetaList>
<ProjectDetailMetaRow icon={status.icon} label="Status">
<span className={`font-medium ${status.className}`}>
{issue.status}
</span>
</ProjectDetailMetaRow>
<ProjectDetailMetaRow icon={CircleDot} label="Category">
{projectTaskCategoryLabel(issue.category)}
</ProjectDetailMetaRow>
{issue.assignees.length > 0 || viewer ? (
<ProjectDetailMetaRow icon={User} label="Assignees">
<IssueAssigneesRow
canAssignOthers={canAssignOthers}
issue={issue}
profiles={profiles}
project={project}
signAsManagedOwner={isManagedAgentOwner && !isOwner}
viewerPubkey={viewer}
/>
</ProjectDetailMetaRow>
) : null}
{labels.length > 0 ? (
<ProjectDetailMetaRow icon={Tag} label="Labels">
<ProjectDetailMetaPills labels={labels} />
</ProjectDetailMetaRow>
) : null}
</ProjectDetailMetaList>
{issue.content ? (
<ProjectDetailSection defaultOpen title="Description">
<ProjectRichContent content={issue.content} tags={issue.tags} />
</ProjectDetailSection>
) : null}
<ProjectDetailSection defaultOpen title="Activity">
<div className="space-y-3">
<DiscussedInChannels
entityLabel="this issue"
entityLabel="this task"
originChannelId={issue.channelId}
originCreatedAt={issue.createdAt}
originPubkey={issue.author}
query={entityDiscussionQuery(issue.id)}
testId="issue-discussed-in"
/>
@@ -251,126 +385,23 @@ 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>
</section>
</div>
<IssueMetaRail
issue={issue}
profiles={profiles}
project={project}
stacked={stackMetaRail}
/>
</div>
);
}
/** Right-hand meta column for the issue detail view: status, assignees,
* author, labels, and dates — keeps the conversation column focused. */
function IssueMetaRail({
issue,
profiles,
project,
stacked = false,
}: {
issue: ProjectIssue;
profiles?: UserProfileLookup;
project: Project;
stacked?: boolean;
}) {
const identityQuery = useIdentityQuery();
const authorProfile = profiles?.[normalizePubkey(issue.author)];
const authorLabel = resolveUserLabel({ profiles, pubkey: issue.author });
const status = issueStatusVisual(issue.status);
const viewerPubkey = identityQuery.data?.pubkey;
const viewer = viewerPubkey ? normalizePubkey(viewerPubkey) : null;
const isAuthor = viewer === normalizePubkey(issue.author);
const isOwner = viewer === normalizePubkey(project.owner);
const isManagedAgentOwner = useIsManagedAgent(project.owner) === true;
// Same trust rule as parsing (assigneesForIssue): the issue author or
// repo owner (directly or via a managed agent) can assign anyone;
// everyone else who is signed in may still self-assign.
const canAssignOthers =
Boolean(viewer) && (isAuthor || isOwner || isManagedAgentOwner);
return (
<aside
className={cn(
"space-y-6 border-border/60 p-4",
stacked ? "border-t" : "border-t xl:border-l xl:border-t-0",
)}
>
<OverviewRailSection title="Status">
<span
className={`inline-flex items-center gap-1.5 rounded-md border border-border/60 px-2.5 py-1 text-xs font-medium ${status.className}`}
>
<status.icon className="h-3.5 w-3.5" />
{issue.status}
</span>
</OverviewRailSection>
{issue.assignees.length > 0 || viewer ? (
<OverviewRailSection title="Assignees">
<IssueAssigneesRow
canAssignOthers={canAssignOthers}
issue={issue}
profiles={profiles}
project={project}
signAsManagedOwner={isManagedAgentOwner && !isOwner}
viewerPubkey={viewer}
/>
</OverviewRailSection>
) : null}
<OverviewRailSection title="Author">
<ProfileIdentityButton
align="center"
avatarSize="xs"
avatarUrl={authorProfile?.avatarUrl ?? null}
isAgent={authorProfile?.isAgent === true}
label={authorLabel}
pubkey={issue.author}
</div>
</ProjectDetailSection>
<div
className="border-border/50 border-t px-6 pb-6 pt-4"
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}
/>
</OverviewRailSection>
{issue.labels.length > 0 ? (
<OverviewRailSection title="Labels">
<div className="flex flex-wrap gap-1.5">
{issue.labels.map((label) => (
<span
className="rounded-full border border-border/60 px-1.5 py-0.5 text-2xs text-muted-foreground"
key={label}
>
{label}
</span>
))}
</div>
</OverviewRailSection>
) : null}
<OverviewRailSection title="Activity">
<dl className="space-y-1.5 text-xs text-muted-foreground">
<div className="flex items-center justify-between gap-3">
<dt>Created</dt>
<dd className="font-medium text-foreground">
{relativeTime(issue.createdAt)}
</dd>
</div>
<div className="flex items-center justify-between gap-3">
<dt>Updated</dt>
<dd className="font-medium text-foreground">
{relativeTime(issue.updatedAt)}
</dd>
</div>
</dl>
</OverviewRailSection>
</aside>
</div>
</div>
);
}
@@ -391,15 +422,15 @@ export function ProjectIssuesPanel({
issues.find((issue) => issue.id === selectedIssueId) ?? null;
if (issuesQuery.isLoading) {
return <p className="p-4 text-sm text-muted-foreground">Loading issues…</p>;
return <BuzzLoadingState label="Loading tasks" />;
}
if (issues.length === 0) {
return (
<p className="p-4 text-sm text-muted-foreground">
{issuesQuery.error
? "Could not load issues for this repository."
: "No issues yet."}
? "Could not load tasks for this repository."
: "No tasks yet."}
</p>
);
}
@@ -414,16 +445,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,49 +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_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;
@@ -51,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,
@@ -146,130 +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 && "xl:grid-cols-[minmax(0,1fr)_18rem]",
)}
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="space-y-6 border-t border-border/60 p-4 xl:border-l xl:border-t-0">
<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" />
Pull Requests
</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

@@ -79,7 +79,11 @@ export function ProfileIdentityButton({
return (
<UserProfilePopover pubkey={pubkey} triggerElement="span">
<button className={className} type="button">
<button
aria-label={showLabel ? undefined : label}
className={className}
type="button"
>
{inner}
</button>
</UserProfilePopover>

View File

@@ -39,6 +39,7 @@ import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { useIdentityQuery } from "@/shared/api/hooks";
import { cn } from "@/shared/lib/cn";
import type { ProjectRepoDiff, ProjectRepoDiffFile } from "@/shared/api/types";
import { BuzzLoadingState } from "@/shared/ui/BuzzLoadingState";
import { PROJECT_DETAIL_PANEL_CLASS } from "./projectPanelStyles";
import { ProjectPullRequestInlineCommentThread } from "./ProjectPullRequestInlineComments";
@@ -690,7 +691,7 @@ export function ProjectPullRequestFilesChangedPanel({
mediaTags?: string[][],
decision?: "request-changes",
) => {
if (!pullRequest) throw new Error("No pull request selected.");
if (!pullRequest) throw new Error("No review selected.");
try {
await postComment({
anchor,
@@ -726,7 +727,7 @@ export function ProjectPullRequestFilesChangedPanel({
focusedAnchor={focusedAnchor}
headerLabel={
pullRequest
? `${pullRequest.title} · ${pullRequest.commit?.slice(0, 7) ?? "PR"}`
? `${pullRequest.title} · ${pullRequest.commit?.slice(0, 7) ?? "Review"}`
: ""
}
inlineComments={
@@ -748,7 +749,7 @@ export function ProjectPullRequestFilesChangedPanel({
: undefined
}
isLoading={isLoading}
subjectLabel="pull request"
subjectLabel="review"
/>
);
}
@@ -814,14 +815,7 @@ export function ProjectDiffFilesPanel({
}, [filteredFiles, selectedPath]);
if (isLoading) {
return (
<div
className={cn("p-4 text-sm text-muted-foreground", outerBorderClass)}
data-project-detail-panel={embedded ? undefined : true}
>
Loading changed files…
</div>
);
return <BuzzLoadingState label="Loading changed files" />;
}
if (error) {

File diff suppressed because it is too large Load Diff

View File

@@ -1,21 +1,16 @@
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 { projectExternalRefUrl } from "@/features/projects/lib/projectExternalUrl";
import type { ProjectRepoUnavailableReason } from "@/features/projects/lib/projectRepoAvailability";
import { Button } from "@/shared/ui/button";
import { BuzzLoadingState } from "@/shared/ui/BuzzLoadingState";
import { Markdown, SyntaxHighlightedCode } from "@/shared/ui/markdown";
import {
baseName,
@@ -29,6 +24,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 +88,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 +95,9 @@ export function ReadmePanel({
externalHost,
externalUrl,
hideHeader,
ownerAvatarUrl,
ownerIsAgent,
ownerName,
sourceControls,
unavailableReason,
}: {
@@ -155,10 +112,17 @@ 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;
}) {
const externalOpenUrl = projectExternalRefUrl(
externalUrl,
sourceControls?.selectedTag ?? sourceControls?.branch,
);
// Two header rows, mirroring the files panel: controls on top, then the
// file identity row.
const header = hideHeader ? null : (
@@ -203,69 +167,28 @@ export function ReadmePanel({
return (
<section className="overflow-hidden">
{header}
<div className="flex items-center gap-2 p-6 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading repository…
</div>
<BuzzLoadingState label="Loading repository" />
</section>
);
}
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,53 +196,29 @@ 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 ? (
{externalOpenUrl ? (
<a
className="mt-2 max-w-lg truncate font-mono text-xs text-primary hover:underline focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
href={externalUrl}
href={externalOpenUrl}
rel="noreferrer"
target="_blank"
>
{externalUrl}
{externalOpenUrl}
</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}
@@ -333,9 +232,9 @@ export function ReadmePanel({
{sourceControls.clonePending ? "Cloning…" : "Clone locally"}
</Button>
) : null}
{externalUrl ? (
{externalOpenUrl ? (
<Button asChild size="sm" variant="outline">
<a href={externalUrl} rel="noreferrer" target="_blank">
<a href={externalOpenUrl} rel="noreferrer" target="_blank">
<ExternalLink className="h-4 w-4" />
Open on {externalHost}
</a>

View File

@@ -0,0 +1,546 @@
import {
CheckCircle2,
CircleDot,
DownloadCloud,
ExternalLink,
FileCode2,
FolderOpen,
GitCommitHorizontal,
GitMerge,
GitPullRequest,
Loader2,
MessageCircle,
Plus,
RefreshCw,
SquareTerminal,
UploadCloud,
Users,
} from "lucide-react";
import type * as React from "react";
import type {
Project,
ProjectIssue,
ProjectPullRequest,
ProjectRepoContributor,
ProjectRepoFile,
ProjectRepoSnapshot,
Repository,
} from "@/features/projects/hooks";
import { projectExternalRefUrl } from "@/features/projects/lib/projectExternalUrl";
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 { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import {
RepoSourceDropdown,
type RepoSourceHeaderControls,
RepositoryBranchDropdown,
} from "./ProjectRepositorySource";
import { ProjectRepositoryManagement } from "./ProjectRepositoryManagement";
import {
projectReviewActivity,
projectRightPanelScope,
projectTaskActivity,
} from "./projectRightPanelContext";
type ProjectRepositoryActionsPanelProps = {
activeTab: string;
canResetWidth: boolean;
contributors: ProjectRepoContributor[];
createIssuePending: boolean;
detached?: boolean;
files: ProjectRepoFile[];
identityPubkey?: string;
issues: ProjectIssue[];
onCreateTask: () => void;
onOpenLocalRepository: () => void;
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 RepositoryPanelSection({
children,
divided = false,
title,
}: {
children: React.ReactNode;
divided?: boolean;
title?: string;
}) {
return (
<section
className={
divided
? "!mt-2 space-y-0.5 border-border/50 border-t pt-2"
: "space-y-0.5"
}
>
{title ? (
<h3 className="flex h-7 min-w-0 items-center truncate text-sm font-normal text-muted-foreground/70">
{title}
</h3>
) : null}
{children}
</section>
);
}
function RepositoryPeople({
people,
profiles,
}: {
people: string[];
profiles?: UserProfileLookup;
}) {
return (
<div
className="flex -space-x-1.5 pb-1 pt-0"
data-testid="project-repository-people"
>
{people.slice(0, 18).map((person) => {
const profile = profiles?.[person];
const label =
profile?.displayName?.trim() ||
profile?.nip05Handle?.trim() ||
person;
return (
<Tooltip key={person}>
<TooltipTrigger asChild>
<span
className="relative inline-flex rounded-full ring-2 ring-background"
data-testid="project-repository-person"
>
<UserAvatar
accent={profile?.isAgent === true}
avatarUrl={profile?.avatarUrl ?? null}
displayName={label}
size="sm"
/>
</span>
</TooltipTrigger>
<TooltipContent data-testid="project-repository-person-tooltip">
{label}
</TooltipContent>
</Tooltip>
);
})}
</div>
);
}
function RepositoryActionButton({
children,
disabled,
onClick,
title,
}: {
children: React.ReactNode;
disabled?: boolean;
onClick: () => void;
title?: string;
}) {
return (
<Button
className="-mx-2 h-7 w-[calc(100%+1rem)] justify-start gap-3 rounded-md px-2 text-left text-sm font-normal hover:bg-muted/70 disabled:text-muted-foreground [&_svg]:h-4 [&_svg]:w-4 [&_svg]:shrink-0 [&_svg]:text-muted-foreground"
disabled={disabled}
onClick={onClick}
size="sm"
title={title}
type="button"
variant="ghost"
>
{children}
</Button>
);
}
export function ProjectRepositoryActionsPanel({
activeTab,
canResetWidth,
contributors,
createIssuePending,
detached = false,
files,
identityPubkey,
issues,
onCreateTask,
onOpenLocalRepository,
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 latestCommit = snapshot?.latestCommit ?? null;
const scope = projectRightPanelScope(activeTab);
const branchScoped = scope === "branch";
const taskActivity = projectTaskActivity(issues);
const reviewActivity = projectReviewActivity(pullRequests);
const cloneAction = sourceControls.localDisabled
? sourceControls.onCloneLocal
: undefined;
const fetchAction =
branchScoped &&
(sourceControls.source === "local" ||
sourceControls.remoteKind !== "external")
? sourceControls.onFetch
: undefined;
const pullAction =
branchScoped && sourceControls.canPull ? sourceControls.onPull : undefined;
const pushAction =
branchScoped && sourceControls.canPush ? sourceControls.onPush : undefined;
const externalOpenUrl = projectExternalRefUrl(
sourceControls.externalUrl,
branchScoped ? (sourceControls.selectedTag ?? sourceControls.branch) : null,
);
return (
<RightAuxiliaryPane
canResetWidth={canResetWidth}
detached={detached}
onResetWidth={onResetWidth}
onResizeStart={onResizeStart}
testId="project-repository-actions-panel"
widthPx={widthPx}
>
<div
className={
detached
? "relative z-30 min-h-0 flex-1 overflow-y-auto"
: "relative z-30 min-h-0 flex-1 overflow-y-auto p-3"
}
>
<div
className={`rounded-2xl ${detached ? "bg-background" : "border border-border/60 bg-muted/30"}`}
data-testid="project-context-card"
>
<div className="flex min-w-0 items-center justify-between gap-2 px-4 pt-3">
<h2 className="min-w-0 truncate text-sm font-normal text-muted-foreground/70">
{repository.name}
</h2>
{branchScoped ? (
<div
className="flex shrink-0 items-center"
data-testid="project-repository-management-actions"
>
<ProjectRepositoryManagement
compact
identityPubkey={identityPubkey}
onChange={onRepositoryChange}
project={project}
projects={projects}
repository={repository}
/>
</div>
) : null}
</div>
<div className="space-y-0.5 px-4 pb-3 pt-2">
{branchScoped ? (
<RepositoryPanelSection>
<div className="grid gap-0.5 [&_button]:-mx-2 [&_button]:h-7 [&_button]:w-[calc(100%+1rem)] [&_button]:max-w-none [&_button]:justify-start [&_button]:gap-3 [&_button]:rounded-md [&_button]:border-0 [&_button]:bg-transparent [&_button]:px-2 [&_button]:text-left [&_button]:text-sm [&_button]:font-normal [&_button]:shadow-none [&_button]:hover:bg-muted/70 [&_button>svg:last-child]:ml-auto">
<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>
) : activeTab === "issues" ? (
<RepositoryPanelSection>
<RepositoryActionButton
disabled={createIssuePending}
onClick={onCreateTask}
>
<Plus className="h-3.5 w-3.5" />
Create task
</RepositoryActionButton>
</RepositoryPanelSection>
) : null}
{branchScoped ? (
<RepositoryPanelSection>
<div className="grid gap-0.5">
{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.source === "local" ? (
<RepositoryActionButton
onClick={onOpenLocalRepository}
title="Open local repository folder"
>
<FolderOpen className="h-3.5 w-3.5" />
Open
</RepositoryActionButton>
) : externalOpenUrl ? (
<Button
asChild
className="-mx-2 h-7 w-[calc(100%+1rem)] justify-start gap-3 rounded-md px-2 text-left text-sm font-normal hover:bg-muted/70 [&_svg]:h-4 [&_svg]:w-4 [&_svg]:shrink-0 [&_svg]:text-muted-foreground"
size="sm"
variant="ghost"
>
<a
href={externalOpenUrl}
rel="noreferrer"
target="_blank"
>
<ExternalLink className="h-3.5 w-3.5" />
Open
</a>
</Button>
) : null}
</div>
</RepositoryPanelSection>
) : null}
{branchScoped ? (
<RepositoryPanelSection divided title="Details">
<RepositoryPeople people={people} profiles={profiles} />
<dl className="text-sm [&>div]:h-7 [&_dd]:ml-auto [&_dd]:tabular-nums [&_dt]:gap-3 [&_dt]:text-foreground [&_dt_svg]:h-4 [&_dt_svg]:w-4 [&_dt_svg]:shrink-0 [&_dt_svg]:text-muted-foreground">
<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>
) : (
<>
<RepositoryPanelSection
divided={activeTab === "issues"}
title={activeTab === "issues" ? "Details" : undefined}
>
<RepositoryPeople people={people} profiles={profiles} />
</RepositoryPanelSection>
<RepositoryPanelSection
title={
activeTab === "issues"
? undefined
: activeTab === "prs"
? "Review activity"
: "Repository activity"
}
>
<dl className="text-sm [&>div]:h-7 [&_dd]:ml-auto [&_dd]:tabular-nums [&_dt]:gap-3 [&_dt]:text-foreground [&_dt_svg]:h-4 [&_dt_svg]:w-4 [&_dt_svg]:shrink-0 [&_dt_svg]:text-muted-foreground">
{activeTab !== "prs" ? (
<>
<div className="flex items-center justify-between gap-3">
<dt className="flex items-center gap-1.5 text-muted-foreground">
<CircleDot className="h-3.5 w-3.5" />
{activeTab === "issues" ? "Tasks" : "Active tasks"}
</dt>
<dd className="font-medium text-foreground">
{activeTab === "issues"
? taskActivity.total
: taskActivity.active}
</dd>
</div>
{activeTab === "issues" ? (
<>
<div className="flex items-center justify-between gap-3">
<dt className="flex items-center gap-1.5 text-muted-foreground">
<CircleDot className="h-3.5 w-3.5" />
Active
</dt>
<dd className="font-medium text-foreground">
{taskActivity.active}
</dd>
</div>
<div className="flex items-center justify-between gap-3">
<dt className="flex items-center gap-1.5 text-muted-foreground">
<CheckCircle2 className="h-3.5 w-3.5" />
Completed
</dt>
<dd className="font-medium text-foreground">
{taskActivity.completed}
</dd>
</div>
</>
) : null}
</>
) : null}
{activeTab !== "issues" ? (
<>
<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" />
{activeTab === "prs" ? "Reviews" : "Open reviews"}
</dt>
<dd className="font-medium text-foreground">
{activeTab === "prs"
? reviewActivity.total
: reviewActivity.open}
</dd>
</div>
{activeTab === "prs" ? (
<>
<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" />
Open
</dt>
<dd className="font-medium text-foreground">
{reviewActivity.open}
</dd>
</div>
<div className="flex items-center justify-between gap-3">
<dt className="flex items-center gap-1.5 text-muted-foreground">
<GitMerge className="h-3.5 w-3.5" />
Merged
</dt>
<dd className="font-medium text-foreground">
{reviewActivity.merged}
</dd>
</div>
</>
) : null}
</>
) : null}
</dl>
</RepositoryPanelSection>
</>
)}
</div>
</div>
</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,15 +20,16 @@ import {
} from "@/shared/ui/dropdown-menu";
import { AddProjectRepositoryDialog } from "./AddProjectRepositoryDialog";
import { AttachProjectRepositoryDialog } from "./AttachProjectRepositoryDialog";
import { ProjectRepositoryPicker } from "./ProjectRepositoryPicker";
export function ProjectRepositoryManagement({
compact = false,
identityPubkey,
onChange,
project,
projects,
repository,
}: {
compact?: boolean;
identityPubkey?: string;
onChange: (repositoryId: string) => void;
project: Project;
@@ -131,25 +132,64 @@ 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={
compact
? "h-6 w-6 shrink-0 rounded-md text-muted-foreground hover:bg-muted/70 hover:text-foreground"
: "h-7 shrink-0 gap-1.5 rounded-md"
}
data-testid="add-project-repository"
size={compact ? "icon" : "sm"}
type="button"
variant={compact ? "ghost" : "outline"}
>
<Plus className={compact ? "h-4 w-4" : "h-3.5 w-3.5"} />
{compact ? null : "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>
<Button
className="h-7 shrink-0 gap-1.5 rounded-md"
aria-label="Manage repository access"
className={
compact
? "h-6 w-6 shrink-0 rounded-md text-muted-foreground hover:bg-muted/70 hover:text-foreground"
: "h-7 shrink-0 gap-1.5 rounded-md"
}
disabled={repairMutation.isPending}
size="sm"
size={compact ? "icon" : "sm"}
type="button"
variant="outline"
variant={compact ? "ghost" : "outline"}
>
<ShieldCheck className="h-3.5 w-3.5" />
{repairMutation.isPending ? "Updating…" : "Access"}
<ShieldCheck className={compact ? "h-4 w-4" : "h-3.5 w-3.5"} />
{compact
? null
: repairMutation.isPending
? "Updating…"
: "Access"}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-56">

View File

@@ -32,6 +32,7 @@ import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type { UserSearchResult } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { BuzzLoadingState } from "@/shared/ui/BuzzLoadingState";
import { SyntaxHighlightedCode } from "@/shared/ui/markdown";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import {
@@ -150,7 +151,7 @@ function RepositoryCommitCell({
if (!commit) return <span className="text-muted-foreground">—</span>;
return (
<p className="truncate text-sm text-foreground">
<p className="truncate text-xs text-foreground">
{commit.subject}
<span className="text-muted-foreground">
{" "}
@@ -423,9 +424,10 @@ function RepositoryEntryIcon({ entry }: { entry: RepositoryFileEntry }) {
return (
<span
className={cn(
"flex h-6 w-6 shrink-0 items-center justify-center",
"flex h-6 w-6 shrink-0 items-center justify-center rounded-md",
visual.containerClassName,
)}
data-testid="project-repository-entry-icon"
>
<Icon className={cn("h-4 w-4", visual.className)} />
</span>
@@ -555,7 +557,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 +589,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 +619,7 @@ export function RepositoryFilesPanel({
error,
profiles,
fallbackAuthorPubkey,
onContextChange,
sourceControls,
unavailableMessage,
}: {
@@ -626,6 +629,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 +640,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],
@@ -683,15 +696,43 @@ export function RepositoryFilesPanel({
// Loading/error/empty states keep the header controls visible — the
// remote/local toggle must stay reachable when one source fails to load.
const stateMessage = isLoading
? "Loading repository files…"
: unavailableMessage
? unavailableMessage
: error
? "Could not load the repository file tree."
: files.length === 0
? "No files have been pushed yet."
: null;
if (isLoading) {
if (!sourceControls) {
return <BuzzLoadingState label="Loading repository files" />;
}
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-4 py-3">
<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>
</div>
<BuzzLoadingState label="Loading repository files" />
</div>
);
}
const stateMessage = unavailableMessage
? unavailableMessage
: error
? "Could not load the repository file tree."
: files.length === 0
? "No files have been pushed yet."
: null;
if (stateMessage) {
if (!sourceControls) {
return (
@@ -705,7 +746,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 +786,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} />
@@ -796,8 +837,8 @@ export function RepositoryFilesPanel({
</div>
) : null}
<div className="overflow-x-auto">
<table className="w-full caption-bottom text-sm">
<div className="overflow-x-auto px-2 pb-2">
<table className="w-full border-separate border-spacing-y-0.5 caption-bottom text-sm">
<thead>
<tr className="border-border/50 border-b bg-muted/20">
<th className="px-4 py-3 text-left font-normal" colSpan={3}>
@@ -860,10 +901,7 @@ export function RepositoryFilesPanel({
return (
<tr
aria-label={`Open ${entry.type} ${entry.name}`}
className={cn(
"cursor-pointer transition-colors hover:bg-muted/35 focus-visible:bg-muted/35 focus-visible:outline-hidden",
!rowIsLast && "border-border/50 border-b",
)}
className="group/repository-entry cursor-pointer text-xs focus-visible:outline-hidden"
key={`${entry.type}:${entry.path}`}
onClick={openEntry}
onKeyDown={(event) =>
@@ -871,7 +909,12 @@ export function RepositoryFilesPanel({
}
tabIndex={0}
>
<td className="min-w-52 p-3 align-middle">
<td
className={cn(
"min-w-52 rounded-l-md px-3 py-2 align-middle transition-colors group-hover/repository-entry:bg-muted/35 group-focus-visible/repository-entry:bg-muted/35",
!rowIsLast && "border-border/50 border-b",
)}
>
<div className="flex min-w-0 items-center gap-2">
<RepositoryEntryIcon entry={entry} />
<span className="truncate font-medium text-foreground">
@@ -879,13 +922,23 @@ export function RepositoryFilesPanel({
</span>
</div>
</td>
<td className="max-w-96 p-3 align-middle">
<td
className={cn(
"max-w-96 p-2 align-middle transition-colors group-hover/repository-entry:bg-muted/35 group-focus-visible/repository-entry:bg-muted/35",
!rowIsLast && "border-border/50 border-b",
)}
>
<RepositoryCommitCell
commit={latestCommit}
profiles={profiles}
/>
</td>
<td className="w-36 whitespace-nowrap p-3 text-right align-middle text-muted-foreground">
<td
className={cn(
"w-36 whitespace-nowrap rounded-r-md p-2 text-right align-middle text-muted-foreground transition-colors group-hover/repository-entry:bg-muted/35 group-focus-visible/repository-entry:bg-muted/35",
!rowIsLast && "border-border/50 border-b",
)}
>
{latestCommit ? (
<time
dateTime={new Date(

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,8 @@ import {
} from "lucide-react";
import { Button } from "@/shared/ui/button";
import { projectExternalRefUrl } from "@/features/projects/lib/projectExternalUrl";
import type { ProjectRepoUnavailableReason } from "@/features/projects/lib/projectRepoAvailability";
import {
DropdownMenu,
DropdownMenuContent,
@@ -65,9 +67,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 (
@@ -75,12 +75,15 @@ export function RepositoryBranchDropdown({
<DropdownMenuTrigger asChild>
<Button
className={PROJECT_PICKER_TRIGGER_CLASS}
data-testid="project-repository-branch-trigger"
size="sm"
type="button"
variant="outline"
>
<RefIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate font-mono">{selectedTag ?? branch}</span>
<span className="min-w-0 flex-1 truncate text-left">
{selectedTag ?? branch}
</span>
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
@@ -99,7 +102,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="min-w-0 flex-1 truncate">{option}</span>
</DropdownMenuRadioItem>
))}
{tagOptions.length > 0 ? (
@@ -112,7 +115,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="min-w-0 flex-1 truncate">{option.name}</span>
<span className="ml-auto font-mono text-xs text-muted-foreground">
{option.commit.slice(0, 7)}
</span>
@@ -181,7 +184,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;
@@ -231,7 +237,7 @@ export function RepoSourceDropdown({
variant="outline"
>
<SourceIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate">
<span className="min-w-0 flex-1 truncate text-left">
{isLocal ? controls.localLabel : controls.remoteLabel}
</span>
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
@@ -287,8 +293,12 @@ export function RepoSyncActionButton({
}: {
controls: RepoSourceHeaderControls;
}) {
const externalOpenUrl = projectExternalRefUrl(
controls.externalUrl,
controls.selectedTag ?? controls.branch,
);
if (controls.remoteKind === "external") {
return controls.externalUrl ? (
return externalOpenUrl ? (
<Button
asChild
className={PROJECT_PANEL_ACTION_BUTTON_CLASS}
@@ -296,7 +306,7 @@ export function RepoSyncActionButton({
title={`Open repository on ${controls.remoteLabel}`}
variant="ghost"
>
<a href={controls.externalUrl} rel="noreferrer" target="_blank">
<a href={externalOpenUrl} rel="noreferrer" target="_blank">
<ExternalLink className="h-4 w-4" />
Open
</a>

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

@@ -10,10 +10,12 @@ import { parseImetaTags } from "@/shared/ui/markdown/parseImeta";
export function ProjectRichContent({
className = "text-sm",
content,
hardLineBreaks,
tags,
}: {
className?: string;
content: string;
hardLineBreaks?: boolean;
tags?: string[][];
}) {
const imetaByUrl = React.useMemo(
@@ -22,6 +24,11 @@ export function ProjectRichContent({
);
return (
<Markdown className={className} content={content} imetaByUrl={imetaByUrl} />
<Markdown
className={className}
content={content}
hardLineBreaks={hardLineBreaks}
imetaByUrl={imetaByUrl}
/>
);
}

View File

@@ -0,0 +1,112 @@
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";
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";
const repositoryOpen = !collapsed && mode === "repository";
const chatOpen = !collapsed && mode === "chat";
return (
<div className="flex items-center gap-0.5">
<Button
aria-label={
repositoryOpen ? "Hide project context" : "Show project context"
}
aria-pressed={repositoryOpen}
className={cn(
"h-7 w-7 text-sidebar-foreground/65 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
repositoryOpen && "bg-sidebar-accent text-sidebar-accent-foreground",
)}
data-testid="project-right-panel-repository-tab"
onClick={() => {
if (repositoryOpen) {
onCollapse();
return;
}
onModeChange("repository");
onExpand();
}}
size="icon"
title="Project context"
type="button"
variant="ghost"
>
<Info className="h-4 w-4" />
</Button>
<Button
aria-label={chatOpen ? "Hide project chat" : "Show project chat"}
aria-pressed={chatOpen}
className={cn(
"h-7 w-7 text-sidebar-foreground/65 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
chatOpen && "bg-sidebar-accent text-sidebar-accent-foreground",
)}
data-testid="project-right-panel-chat-tab"
onClick={() => {
if (chatOpen) {
onCollapse();
return;
}
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>
</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 className="mt-1 space-y-0.5 px-2">{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 rounded-md px-2 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,41 +1,29 @@
import { BookOpen } from "lucide-react";
import { Glasses } from "lucide-react";
import type { ProjectPullRequest } from "@/features/projects/hooks";
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>
@@ -44,7 +32,7 @@ export function ProjectTabsList({ prsActive }: { prsActive?: boolean }) {
<ProjectTabLabel>Commits</ProjectTabLabel>
</TabsTrigger>
<TabsTrigger className={PROJECT_TAB_TRIGGER_CLASS} value="issues">
<ProjectTabLabel>Issues</ProjectTabLabel>
<ProjectTabLabel>Tasks</ProjectTabLabel>
</TabsTrigger>
<TabsTrigger
aria-current={prsActive ? "page" : undefined}
@@ -54,7 +42,7 @@ export function ProjectTabsList({ prsActive }: { prsActive?: boolean }) {
)}
value="prs"
>
<ProjectTabLabel>Pull Request</ProjectTabLabel>
<ProjectTabLabel>Review</ProjectTabLabel>
</TabsTrigger>
<TabsTrigger className={PROJECT_TAB_TRIGGER_CLASS} value="channels">
<ProjectTabLabel>Channels</ProjectTabLabel>
@@ -65,42 +53,3 @@ export function ProjectTabsList({ prsActive }: { prsActive?: boolean }) {
</TabsList>
);
}
const PR_TAB_TRIGGER_CLASS =
"h-9 gap-1.5 rounded-none border-b-2 border-transparent px-0 text-muted-foreground hover:text-foreground data-[state=active]:border-foreground data-[state=active]:bg-transparent data-[state=active]:text-foreground data-[state=active]:shadow-none";
export function PullRequestTabsList({
filesCount,
pullRequest,
}: {
filesCount: number;
pullRequest: ProjectPullRequest;
}) {
const commitCount = Math.max(1, pullRequest.updateCount + 1);
return (
<TabsList className="h-9 w-fit justify-start gap-6 bg-transparent p-0">
<TabsTrigger className={PR_TAB_TRIGGER_CLASS} value="pr-conversation">
Conversation
<span className="rounded-full bg-muted px-1.5 py-0.5 text-2xs">
{pullRequest.comments.length}
</span>
</TabsTrigger>
<TabsTrigger className={PR_TAB_TRIGGER_CLASS} value="pr-commits">
Commits
<span className="rounded-full bg-muted px-1.5 py-0.5 text-2xs">
{commitCount}
</span>
</TabsTrigger>
<TabsTrigger className={PR_TAB_TRIGGER_CLASS} value="pr-checks">
Checks
<span className="rounded-full bg-muted px-1.5 py-0.5 text-2xs">0</span>
</TabsTrigger>
<TabsTrigger className={PR_TAB_TRIGGER_CLASS} value="pr-files">
Files changed
<span className="rounded-full bg-muted px-1.5 py-0.5 text-2xs">
{filesCount}
</span>
</TabsTrigger>
</TabsList>
);
}

View File

@@ -6,7 +6,6 @@ import {
GitPullRequest,
Hash,
RefreshCw,
SquareTerminal,
Users,
} from "lucide-react";
import * as React from "react";
@@ -23,27 +22,21 @@ 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 { BuzzLoadingState } from "@/shared/ui/BuzzLoadingState";
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";
@@ -53,17 +46,12 @@ import {
type GitDataState,
ProjectOverviewPanel,
} from "./ProjectOverviewPanel";
import {
PullRequestDetailHeader,
PullRequestMetaRail,
PullRequestsPanel,
} from "./ProjectPullRequestsPanel";
import {
ProjectTabsList,
PullRequestTabsList,
} from "./ProjectWorkspaceTabList";
import { PullRequestsPanel } from "./ProjectPullRequestsPanel";
import { ProjectTabsList } from "./ProjectWorkspaceTabList";
import { ProjectPullRequestFilesChangedPanel } from "./ProjectPullRequestFilesChangedPanel";
import { ProjectRepositoryUnavailableState } from "./ProjectRepositoryUnavailableState";
import {
PROJECT_COLUMN_HEADER_BACKDROP_CLASS,
PROJECT_DETAIL_PANEL_CLASS,
PROJECT_DETAIL_PANEL_MESSAGE_CLASS,
} from "./projectPanelStyles";
@@ -98,7 +86,10 @@ export function WorkspaceTabs({
commitDiff,
commitDiffError,
commitDiffLoading,
contributorActivityCounts,
contributorPubkeys,
createIssueAction,
createIssueRequestKey,
createPullRequestAction,
updatePullRequestAction,
initialTab,
@@ -114,16 +105,17 @@ export function WorkspaceTabs({
selectedCommitHash,
selectedIssueId,
selectedPullRequestId,
sharedHeaderBackdrop,
pullRequests,
pullRequestsError,
pullRequestsLoading,
onSelectedCommitHashChange,
onFilesContextChange,
onSelectedIssueIdChange,
onSelectedPullRequestIdChange,
onSelectedTabChange,
onBranchChange,
onOpenMergeRecoveryTerminal,
onOpenTerminal,
snapshot,
snapshotError,
snapshotLoading,
@@ -131,15 +123,16 @@ 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;
createIssueRequestKey?: number;
createPullRequestAction?: CreatePullRequestAction;
updatePullRequestAction?: UpdatePullRequestAction;
/** Tab to open on mount (workspace vocabulary), e.g. from a share link. */
@@ -157,17 +150,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;
@@ -175,11 +172,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;
@@ -191,8 +185,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
@@ -204,20 +211,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],
@@ -254,25 +269,26 @@ export function WorkspaceTabs({
pullRequestId: string;
} | null>(null);
const [createIssueOpen, setCreateIssueOpen] = React.useState(false);
const previousCreateIssueRequestKey = React.useRef(createIssueRequestKey);
const [createPullRequestOpen, setCreatePullRequestOpen] =
React.useState(false);
React.useEffect(() => {
if (previousCreateIssueRequestKey.current === createIssueRequestKey) return;
previousCreateIssueRequestKey.current = createIssueRequestKey;
setCreateIssueOpen(true);
}, [createIssueRequestKey]);
React.useEffect(() => {
onSelectedTabChange?.(selectedTab);
}, [onSelectedTabChange, selectedTab]);
React.useEffect(() => {
if (isPullRequestSelected) {
setSelectedTab((currentTab) =>
currentTab.startsWith("pr-") ? currentTab : "pr-conversation",
);
setSelectedTab("prs");
if (selectedPullRequest?.branchName) {
onBranchChange(selectedPullRequest.branchName);
}
} else {
setSelectedTab((currentTab) =>
currentTab.startsWith("pr-") ? "prs" : currentTab,
);
}
}, [isPullRequestSelected, onBranchChange, selectedPullRequest?.branchName]);
@@ -291,7 +307,7 @@ export function WorkspaceTabs({
const handleTabChange = React.useCallback(
(nextTab: string) => {
setSelectedTab(nextTab);
if (!nextTab.startsWith("pr-")) {
if (nextTab !== "prs") {
onSelectedPullRequestIdChange(null);
}
if (nextTab !== "issues") {
@@ -314,16 +330,13 @@ export function WorkspaceTabs({
anchor: { ...anchor },
pullRequestId: selectedPullRequestId,
});
setSelectedTab("pr-files");
},
[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" />
@@ -331,25 +344,24 @@ export function WorkspaceTabs({
<ProjectSectionHeader
action={{
disabled: createIssueAction.pending,
label: "New issue",
label: "Create task",
onClick: () => setCreateIssueOpen(true),
}}
icon={CircleDot}
title="Issues"
title="Tasks"
/>
) : selectedTab === "prs" ? (
) : selectedTab === "prs" && !selectedPullRequestId ? (
<ProjectSectionHeader
action={{
disabled:
!createPullRequestAction ||
createPullRequestAction.projects.length === 0,
label: "New pull request",
label: "Create review",
onClick: () => setCreatePullRequestOpen(true),
title:
"New pull request — choose a repository and branches to compare",
title: "Create review — choose a repository and branches to compare",
}}
icon={GitPullRequest}
title="Pull Requests"
title="Reviews"
/>
) : selectedTab === "channels" ? (
<ProjectSectionHeader icon={Hash} title="Channels" />
@@ -363,212 +375,133 @@ 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"
disabled={updatePullRequestAction.pending}
onClick={updatePullRequestAction.onUpdate}
size="sm"
title="Publish the pushed commit to this pull request"
title="Publish the pushed commit to this review"
variant="outline"
>
<RefreshCw className="h-4 w-4" />
{updatePullRequestAction.pending ? "Updating…" : "Update PR"}
{updatePullRequestAction.pending ? "Updating…" : "Update review"}
</Button>
) : 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}
{selectedPullRequest ? (
<div className={PROJECT_DETAIL_PANEL_CLASS} data-project-detail-panel>
{/* Two full-height columns: the meta rail runs all the way to the
top of the card, alongside the header and tabs. */}
<div className="grid xl:grid-cols-[minmax(0,1fr)_18rem]">
<div className="min-w-0">
<PullRequestDetailHeader
profiles={profiles}
pullRequest={selectedPullRequest}
/>
<div className="border-b border-border/60 px-4">
<PullRequestTabsList
filesCount={repoDiff?.files.length ?? files.length}
pullRequest={selectedPullRequest}
/>
</div>
{(["conversation", "commits", "checks"] as const).map(
(mode) => (
<TabsContent
className="m-0"
key={mode}
value={`pr-${mode}`}
>
<PullRequestsPanel
error={pullRequestsError}
isLoading={pullRequestsLoading}
mode={mode}
onOpenInlineComment={handleOpenPullRequestComment}
onOpenCommit={onSelectedCommitHashChange}
onOpenTerminal={onOpenMergeRecoveryTerminal}
onSelectedPullRequestIdChange={
onSelectedPullRequestIdChange
}
profiles={profiles}
project={project}
pullRequests={pullRequests}
selectedPullRequestId={selectedPullRequestId}
/>
</TabsContent>
),
)}
<TabsContent className="m-0" value="pr-files">
<ProjectPullRequestFilesChangedPanel
diff={repoDiff}
error={repoDiffError}
focusedAnchor={
pullRequestCommentTarget?.pullRequestId ===
selectedPullRequestId
? pullRequestCommentTarget.anchor
: null
}
isLoading={repoDiffLoading}
profiles={profiles}
project={project}
pullRequest={selectedPullRequest}
/>
</TabsContent>
</div>
<PullRequestMetaRail
profiles={profiles}
project={project}
pullRequest={selectedPullRequest}
/>
</div>
</div>
) : null}
<TabsContent className="m-0" value="overview">
<ProjectOverviewPanel
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
className={`m-0 ${PROJECT_DETAIL_PANEL_CLASS}`}
className={`m-0 ${
selectedPullRequestId ? "" : PROJECT_DETAIL_PANEL_CLASS
}`}
data-project-detail-panel
value="prs"
>
<PullRequestsPanel
diffStats={
repoDiff
? {
additions: repoDiff.additions,
deletions: repoDiff.deletions,
}
: null
}
error={pullRequestsError}
filesChanged={
repositoryUnavailableState ??
(selectedPullRequest ? (
<ProjectPullRequestFilesChangedPanel
diff={repoDiff}
error={repoDiffError}
focusedAnchor={
pullRequestCommentTarget?.pullRequestId ===
selectedPullRequestId
? pullRequestCommentTarget.anchor
: null
}
isLoading={repoDiffLoading}
profiles={profiles}
project={project}
pullRequest={selectedPullRequest}
/>
) : undefined)
}
filesCount={repoDiff?.files.length}
forceOpenFiles={
pullRequestCommentTarget?.pullRequestId === selectedPullRequestId
}
isLoading={pullRequestsLoading}
onOpenCommit={onSelectedCommitHashChange}
onOpenInlineComment={handleOpenPullRequestComment}
onOpenTerminal={onOpenMergeRecoveryTerminal}
onSelectedPullRequestIdChange={onSelectedPullRequestIdChange}
profiles={profiles}
@@ -579,7 +512,7 @@ export function WorkspaceTabs({
</TabsContent>
<TabsContent
className={`m-0 ${PROJECT_DETAIL_PANEL_CLASS}`}
className={`m-0 ${selectedIssueId ? "" : PROJECT_DETAIL_PANEL_CLASS}`}
data-project-detail-panel
value="issues"
>
@@ -592,40 +525,55 @@ 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
profiles={profiles}
repoContributors={displayedContributors}
/>
{displayedSnapshotLoading ? (
<BuzzLoadingState label="Loading contributors" />
) : (
<ContributorsPanel
activityCounts={contributorActivityCounts}
contributorPubkeys={contributorPubkeys}
contributorPubkeysByGitIdentity={contributorPubkeysByGitIdentity}
profiles={profiles}
repoContributors={displayedContributors}
/>
)}
</TabsContent>
</div>
{createPullRequestAction && createPullRequestOpen ? (

View File

@@ -23,6 +23,7 @@ import {
} from "@/features/projects/projectPullRequests.mjs";
import { cn } from "@/shared/lib/cn";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { BuzzLoadingState } from "@/shared/ui/BuzzLoadingState";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import {
@@ -154,7 +155,7 @@ function buildActivityItems({
createdAt: pullRequest.createdAt,
actorPubkey: pullRequest.author,
actorName: null,
action: "opened a pull request in",
action: "opened a review in",
title: pullRequest.title,
body: contentPreview(pullRequest.content),
detail: pullRequest.status,
@@ -167,7 +168,7 @@ function buildActivityItems({
createdAt: update.createdAt,
actorPubkey: update.author,
actorName: null,
action: "updated a pull request in",
action: "updated a review in",
title: pullRequest.title,
body: contentPreview(update.content),
detail: update.commit?.slice(0, 7) ?? null,
@@ -196,12 +197,12 @@ function buildActivityItems({
actorName: null,
action:
kind === "approval"
? "approved a pull request in"
? "approved a review in"
: kind === "changes-requested"
? "requested changes to a pull request in"
? "requested changes to a review in"
: kind === "review-request"
? "requested review in"
: "commented on a pull request in",
: "commented on a review in",
title: pullRequest.title,
body: contentPreview(comment.content),
detail:
@@ -223,7 +224,7 @@ function buildActivityItems({
createdAt: issue.createdAt,
actorPubkey: issue.author,
actorName: null,
action: "created an issue in",
action: "created a task in",
title: issue.title,
body: contentPreview(issue.content),
detail: issue.status,
@@ -236,7 +237,7 @@ function buildActivityItems({
createdAt: comment.createdAt,
actorPubkey: comment.author,
actorName: null,
action: "commented on an issue in",
action: "commented on a task in",
title: issue.title,
body: contentPreview(comment.content),
detail: null,
@@ -431,19 +432,7 @@ export function ProjectsActivityFeed(props: ProjectsActivityFeedProps) {
const items = buildActivityItems(props);
if (props.isLoading && items.length === 0) {
return (
<div className={cn(props.compact ? "space-y-2.5" : "space-y-3")}>
{["first", "second", "third"].map((key) => (
<div
className={cn(
"animate-pulse rounded-xl border border-border/60 bg-muted/20",
props.compact ? "h-24" : "h-28",
)}
key={key}
/>
))}
</div>
);
return <BuzzLoadingState label="Loading project activity" />;
}
if (items.length === 0) {
@@ -453,7 +442,7 @@ export function ProjectsActivityFeed(props: ProjectsActivityFeedProps) {
No project activity yet
</p>
<p className="mt-1 text-sm text-muted-foreground">
Commits, pull requests, reviews, and issues will appear here.
Commits, reviews, review decisions, and tasks will appear here.
</p>
</div>
);

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+$/, "");
@@ -111,8 +120,8 @@ function buildSuggestions(projects: readonly Project[]) {
const firstRepo = projects[0]?.name;
return [
{
label: "PR review",
prompt: "Which pull requests need attention today?",
label: "Reviews",
prompt: "Which reviews need attention today?",
},
{
label: "Release check",
@@ -121,8 +130,8 @@ function buildSuggestions(projects: readonly Project[]) {
: "Are we safe to cut a release this week?",
},
{
label: "Issues",
prompt: "Summarize the open issues and flag anything urgent.",
label: "Tasks",
prompt: "Summarize the open tasks and flag anything urgent.",
},
{
label: "Activity",
@@ -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

@@ -102,7 +102,7 @@ export function ProjectsCreateMenu({
type="button"
>
<CircleDot />
Issue
Task
</button>
<button
className={MENU_ITEM_CLASS}
@@ -111,7 +111,7 @@ export function ProjectsCreateMenu({
type="button"
>
<GitPullRequest />
Pull Request
Review
</button>
</div>
</div>

View File

@@ -8,6 +8,10 @@ import type {
} from "@/features/projects/hooks";
import { issueShareLink } from "@/features/projects/lib/projectShareLinks";
import { relativeTime } from "@/features/projects/lib/projectsViewHelpers";
import {
projectTaskCategoryLabel,
projectTaskUserLabels,
} from "@/features/projects/projectTaskCategories";
import type { ProjectWorkItemSection } from "@/features/projects/projectWorkItems";
import {
resolveUserLabel,
@@ -15,6 +19,7 @@ import {
} from "@/features/profile/lib/identity";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { BuzzLoadingState } from "@/shared/ui/BuzzLoadingState";
import { Card } from "@/shared/ui/card";
import { DropdownMenuItem } from "@/shared/ui/dropdown-menu";
import { CopyShareLinkMenuItem } from "./CopyShareLinkMenuItem";
@@ -24,19 +29,18 @@ import { ProjectEventTypeIcon } from "./ProjectEventTypeIcon";
import { ProjectListRowMenu } from "./ProjectListRowMenu";
import { ProjectsWorkItemsLoadNotice } from "./ProjectsWorkItemsLoadNotice";
import {
PROJECT_LIST_CONTAINER_CLASS,
PROJECT_LIST_ROW_CLASS,
PROJECT_LIST_ROW_CONTENT_CLASS,
PROJECT_LIST_ROW_DATE_CLASS,
PROJECT_LIST_ROW_STATUS_CLASS,
PROJECT_LIST_ROW_SUBTEXT_CLASS,
PROJECT_LIST_ROW_TITLE_CLASS,
PROJECT_LIST_ROW_TRAILING_CLASS,
} from "./projectListRowStyles";
import {
ProjectsWorkItemTableHeader,
WORK_ITEM_TABLE_GRID_CLASS,
} from "./ProjectsWorkItemTable";
type ProjectsIssuesListProps = {
/** Render without container chrome — a parent table container provides border and rounding. */
embedded?: boolean;
emptyMessage?: string;
error: unknown;
failedSections: ProjectWorkItemSection[];
isLoading: boolean;
@@ -53,16 +57,17 @@ type ProjectsIssuesListProps = {
};
function nextStepLabel(status: ProjectIssue["status"]) {
if (status === "Done" || status === "Closed") return "View issue";
if (status === "In Review") return "Review issue";
if (status === "Triage") return "Triage issue";
return "Open issue";
if (status === "Done" || status === "Closed") return "View task";
if (status === "In Review") return "Review task";
if (status === "Triage") return "Triage task";
return "Open task";
}
function issueLabelsSummary(issue: ProjectIssue) {
const visibleLabels = issue.labels.slice(0, 2);
const labels = projectTaskUserLabels(issue.labels);
const visibleLabels = labels.slice(0, 2);
if (visibleLabels.length === 0) return null;
const hiddenCount = issue.labels.length - visibleLabels.length;
const hiddenCount = labels.length - visibleLabels.length;
return `${visibleLabels.join(", ")}${hiddenCount > 0 ? ` +${hiddenCount}` : ""}`;
}
@@ -84,6 +89,9 @@ function IssueHeader({
<div className="-mt-0.5 min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-1.5">
<p className={PROJECT_LIST_ROW_TITLE_CLASS}>{issue.title}</p>
<span className="shrink-0 rounded-full border border-border/60 px-1.5 py-0.5 text-2xs font-medium text-muted-foreground">
{projectTaskCategoryLabel(issue.category)}
</span>
</div>
{/* Flex (not inline flow) so the 20px author avatar cannot grow the
line box — keeps row heights identical to the PR list. */}
@@ -137,7 +145,7 @@ function IssueGridCard({
type="button"
>
<span className="sr-only">
View issue {issue.title} by {authorLabel} in {repository.name}
View task {issue.title} by {authorLabel} in {repository.name}
</span>
</button>
<div className="flex min-h-0 flex-1 flex-col gap-3">
@@ -199,50 +207,87 @@ function IssueListRow({
repository: Repository;
}) {
const authorLabel = resolveUserLabel({ profiles, pubkey: issue.author });
const labelsSummary = issueLabelsSummary(issue);
return (
<div
className={PROJECT_LIST_ROW_CLASS}
<tr
className={cn(
WORK_ITEM_TABLE_GRID_CLASS,
"group relative cursor-pointer px-3 py-2.5 transition-colors duration-150 hover:bg-muted/20 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring",
)}
aria-label={`Open task ${issue.title}`}
data-testid={`projects-issue-row-${issue.id}`}
onClick={(event) => {
if ((event.target as Element).closest("button, a, [role='menuitem']")) {
return;
}
onOpen(project, issue);
}}
onKeyDown={(event) => {
if (
event.target === event.currentTarget &&
(event.key === "Enter" || event.key === " ")
) {
event.preventDefault();
onOpen(project, issue);
}
}}
tabIndex={0}
>
<button
className="absolute inset-0"
onClick={() => onOpen(project, issue)}
type="button"
>
<span className="sr-only">
View issue {issue.title} by {authorLabel} in {repository.name}
</span>
</button>
<div className={PROJECT_LIST_ROW_CONTENT_CLASS}>
<ProjectEventTypeIcon className="h-5 w-5" kind="issue" />
<IssueHeader
authorTestId="projects-issue-author"
issue={issue}
profiles={profiles}
repository={repository}
/>
<div className={PROJECT_LIST_ROW_TRAILING_CLASS}>
<td className="min-w-0">
<div className="flex min-w-0 items-start gap-2 text-left">
<ProjectEventTypeIcon className="h-5 w-5" kind="issue" />
<div className="-mt-0.5 min-w-0 flex-1">
<p className={PROJECT_LIST_ROW_TITLE_CLASS}>{issue.title}</p>
<div
className={`flex min-w-0 items-center gap-x-1 overflow-hidden whitespace-nowrap ${PROJECT_LIST_ROW_SUBTEXT_CLASS}`}
>
<ProjectAuthorIdentity
label={authorLabel}
profiles={profiles}
pubkey={issue.author}
testId="projects-issue-author"
/>
<span>opened this in</span>
<span className="truncate">{repository.name}</span>
{labelsSummary ? (
<>
<span>and tagged it</span>
<span className="truncate">{labelsSummary}</span>
</>
) : null}
<span className="-ml-1">.</span>
</div>
</div>
<IssueAssigneeFacepile
assignees={issue.assignees}
profiles={profiles}
/>
<span className={PROJECT_LIST_ROW_STATUS_CLASS}>{issue.status}</span>
<div className="hidden w-14 shrink-0 justify-end md:flex">
{issue.comments.length > 0 ? (
<span className="flex items-center gap-1 text-2xs leading-3 text-muted-foreground">
<MessageSquare className="h-3.5 w-3.5" />
{issue.comments.length}
</span>
) : null}
</div>
<span
className={PROJECT_LIST_ROW_DATE_CLASS}
data-testid="projects-row-date"
title={new Date(issue.createdAt * 1_000).toLocaleString()}
>
{relativeTime(issue.createdAt)}
</span>
</div>
</td>
<td className="min-w-0">
<span className="inline-flex max-w-full truncate rounded-full border border-border/60 px-1.5 py-0.5 text-2xs font-medium text-muted-foreground">
{projectTaskCategoryLabel(issue.category)}
</span>
</td>
<td className="truncate text-2xs text-muted-foreground">
{issue.status}
</td>
<td className="text-2xs text-muted-foreground">
<span className="flex items-center justify-end gap-1">
<MessageSquare className="h-3.5 w-3.5" />
{issue.comments.length}
</span>
</td>
<td
className="truncate text-right text-xs text-muted-foreground/70"
data-testid="projects-row-date"
title={new Date(issue.updatedAt * 1_000).toLocaleString()}
>
{relativeTime(issue.updatedAt)}
</td>
<td className="relative z-10">
<div className="flex justify-end">
<ProjectListRowMenu label={`More options for ${issue.title}`}>
<DropdownMenuItem onSelect={() => onOpen(project, issue)}>
<Eye className="h-4 w-4" />
@@ -250,17 +295,19 @@ function IssueListRow({
</DropdownMenuItem>
<CopyShareLinkMenuItem
link={issueShareLink(issue)}
label="Copy task link"
testId={`projects-issue-copy-link-${issue.id}`}
/>
</ProjectListRowMenu>
</div>
</div>
</div>
</td>
</tr>
);
}
export function ProjectsIssuesList({
embedded,
emptyMessage = "No tasks yet.",
error,
failedSections,
isLoading,
@@ -272,16 +319,7 @@ export function ProjectsIssuesList({
viewMode,
}: ProjectsIssuesListProps) {
if (isLoading) {
return (
<div
className={cn(
"px-4 py-12 text-center text-sm text-muted-foreground",
!embedded && "border border-border/60",
)}
>
Loading issues...
</div>
);
return <BuzzLoadingState label="Loading tasks" />;
}
const loadNotice = (
@@ -308,7 +346,7 @@ export function ProjectsIssuesList({
!embedded && "border border-dashed border-border/60",
)}
>
No issues yet.
{emptyMessage}
</div>
</div>
);
@@ -339,25 +377,29 @@ export function ProjectsIssuesList({
return (
<div className="space-y-3">
{loadNotice}
<div
className={
embedded ? "divide-y divide-border/60" : PROJECT_LIST_CONTAINER_CLASS
}
<table
className={cn(
"block w-full overflow-x-auto bg-transparent",
!embedded && "rounded-xl border border-border/60",
)}
data-testid="projects-list-container"
>
{issues.map(({ project, issue, repository }) => (
<IssueListRow
issue={issue}
key={`${repository.id}:${issue.id}`}
onOpen={(selectedProject, selectedIssue) =>
onOpen(selectedProject, repository, selectedIssue)
}
profiles={profiles}
project={project}
repository={repository}
/>
))}
</div>
<ProjectsWorkItemTableHeader itemLabel="Task" typeLabel="Type" />
<tbody className="block divide-y divide-border/60">
{issues.map(({ project, issue, repository }) => (
<IssueListRow
issue={issue}
key={`${repository.id}:${issue.id}`}
onOpen={(selectedProject, selectedIssue) =>
onOpen(selectedProject, repository, selectedIssue)
}
profiles={profiles}
project={project}
repository={repository}
/>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -34,14 +34,14 @@ const PULL_REQUEST_SCOPE_OPTIONS: Array<{
value: ProjectsWorkItemScope;
}> = [
{ label: "All", value: "all" },
{ label: "My Pull Requests", value: "mine" },
{ label: "My Reviews", value: "mine" },
];
const ISSUE_SCOPE_OPTIONS: Array<{
label: string;
value: ProjectsWorkItemScope;
}> = [
{ label: "All", value: "all" },
{ label: "My Issues", value: "mine" },
{ label: "My Tasks", value: "mine" },
{ label: "Assigned to me", value: "assigned" },
];
@@ -86,14 +86,14 @@ export function ProjectsListHeaderBar({
const scopeDropdown =
filter === "prs" ? (
<ProjectsListScopeDropdown
label="Filter pull requests"
label="Filter reviews"
onChange={onPullRequestScopeChange}
options={PULL_REQUEST_SCOPE_OPTIONS}
value={pullRequestScope}
/>
) : filter === "issues" ? (
<ProjectsListScopeDropdown
label="Filter issues"
label="Filter tasks"
onChange={onIssueScopeChange}
options={ISSUE_SCOPE_OPTIONS}
value={issueScope}
@@ -124,7 +124,7 @@ export function ProjectsListHeaderBar({
)}
data-testid="projects-list-header"
>
{scopeDropdown}
<div className="flex flex-wrap items-center gap-2">{scopeDropdown}</div>
<div className="flex flex-wrap items-center gap-2">
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="sr-only">Sort projects</span>

View File

@@ -106,18 +106,18 @@ export function ProjectsOverviewPanel({
label="Repositories"
onClick={() => onSelectSection("repositories")}
/>
<StatPill
count={stats.prs}
icon={GitPullRequest}
label="Pull requests"
onClick={() => onSelectSection("prs")}
/>
<StatPill
count={stats.issues}
icon={CircleDot}
label="Issues"
label="Tasks"
onClick={() => onSelectSection("issues")}
/>
<StatPill
count={stats.prs}
icon={GitPullRequest}
label="Reviews"
onClick={() => onSelectSection("prs")}
/>
</div>
{metadata}
</div>

View File

@@ -15,6 +15,7 @@ import {
type UserProfileLookup,
} from "@/features/profile/lib/identity";
import { Button } from "@/shared/ui/button";
import { BuzzLoadingState } from "@/shared/ui/BuzzLoadingState";
import { Card } from "@/shared/ui/card";
import { DropdownMenuItem } from "@/shared/ui/dropdown-menu";
import { CopyShareLinkMenuItem } from "./CopyShareLinkMenuItem";
@@ -23,15 +24,13 @@ import { ProjectEventTypeIcon } from "./ProjectEventTypeIcon";
import { ProjectListRowMenu } from "./ProjectListRowMenu";
import { ProjectsWorkItemsLoadNotice } from "./ProjectsWorkItemsLoadNotice";
import {
PROJECT_LIST_CONTAINER_CLASS,
PROJECT_LIST_ROW_CLASS,
PROJECT_LIST_ROW_CONTENT_CLASS,
PROJECT_LIST_ROW_DATE_CLASS,
PROJECT_LIST_ROW_STATUS_CLASS,
PROJECT_LIST_ROW_SUBTEXT_CLASS,
PROJECT_LIST_ROW_TITLE_CLASS,
PROJECT_LIST_ROW_TRAILING_CLASS,
} from "./projectListRowStyles";
import {
ProjectsWorkItemTableHeader,
WORK_ITEM_TABLE_GRID_CLASS,
} from "./ProjectsWorkItemTable";
type ProjectsPullRequestsListProps = {
/** Render without container chrome — a parent table container provides border and rounding. */
@@ -55,7 +54,7 @@ function nextStepLabel(status: ProjectPullRequest["status"]) {
if (status === "Draft") return "View draft";
if (status === "Merged") return "View merge";
if (status === "Closed") return "View closed";
return "Review PR";
return "Open review";
}
function PullRequestContext({
@@ -147,8 +146,7 @@ function PullRequestGridCard({
type="button"
>
<span className="sr-only">
View pull request {pullRequest.title} by {authorLabel} in{" "}
{repository.name}
View review {pullRequest.title} by {authorLabel} in {repository.name}
</span>
</button>
<div className="flex min-h-0 flex-1 flex-col gap-3">
@@ -226,55 +224,70 @@ function PullRequestListRow({
});
return (
<div
className={PROJECT_LIST_ROW_CLASS}
<tr
className={cn(
WORK_ITEM_TABLE_GRID_CLASS,
"group relative cursor-pointer px-3 py-2.5 transition-colors duration-150 hover:bg-muted/20 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring",
)}
aria-label={`Open review ${pullRequest.title}`}
data-testid={`projects-pr-row-${pullRequest.id}`}
onClick={(event) => {
if ((event.target as Element).closest("button, a, [role='menuitem']")) {
return;
}
onOpen(project, pullRequest);
}}
onKeyDown={(event) => {
if (
event.target === event.currentTarget &&
(event.key === "Enter" || event.key === " ")
) {
event.preventDefault();
onOpen(project, pullRequest);
}
}}
tabIndex={0}
>
<button
className="absolute inset-0"
onClick={() => onOpen(project, pullRequest)}
type="button"
>
<span className="sr-only">
View pull request {pullRequest.title} by {authorLabel} in{" "}
{repository.name}
</span>
</button>
<div className={PROJECT_LIST_ROW_CONTENT_CLASS}>
<ProjectEventTypeIcon className="h-5 w-5" kind="pull-request" />
<div className="-mt-0.5 min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-1.5">
<td className="min-w-0">
<div className="flex min-w-0 items-start gap-2 text-left">
<ProjectEventTypeIcon className="h-5 w-5" kind="pull-request" />
<div className="-mt-0.5 min-w-0 flex-1">
<p className={PROJECT_LIST_ROW_TITLE_CLASS}>{pullRequest.title}</p>
<PullRequestContext
authorLabel={authorLabel}
authorTestId="projects-pr-author"
className={PROJECT_LIST_ROW_SUBTEXT_CLASS}
profiles={profiles}
pullRequest={pullRequest}
repository={repository}
showMobileStatus
/>
</div>
<PullRequestContext
authorLabel={authorLabel}
authorTestId="projects-pr-author"
className={PROJECT_LIST_ROW_SUBTEXT_CLASS}
profiles={profiles}
pullRequest={pullRequest}
repository={repository}
showMobileStatus
/>
</div>
<div className={PROJECT_LIST_ROW_TRAILING_CLASS}>
<span className={PROJECT_LIST_ROW_STATUS_CLASS}>
{pullRequest.status}
</span>
<div className="hidden w-14 shrink-0 justify-end md:flex">
{pullRequest.comments.length > 0 ? (
<span className="flex items-center gap-1 text-2xs leading-3 text-muted-foreground">
<MessageSquare className="h-3.5 w-3.5" />
{pullRequest.comments.length}
</span>
) : null}
</div>
<span
className={PROJECT_LIST_ROW_DATE_CLASS}
data-testid="projects-row-date"
title={new Date(pullRequest.createdAt * 1_000).toLocaleString()}
>
{relativeTime(pullRequest.createdAt)}
</span>
</td>
<td className="min-w-0">
<span className="inline-flex max-w-full truncate rounded-full border border-border/60 px-1.5 py-0.5 text-2xs font-medium text-muted-foreground">
Review
</span>
</td>
<td className="truncate text-2xs text-muted-foreground">
{pullRequest.status}
</td>
<td className="text-2xs text-muted-foreground">
<span className="flex items-center justify-end gap-1">
<MessageSquare className="h-3.5 w-3.5" />
{pullRequest.comments.length}
</span>
</td>
<td
className="truncate text-right text-xs text-muted-foreground/70"
data-testid="projects-row-date"
title={new Date(pullRequest.updatedAt * 1_000).toLocaleString()}
>
{relativeTime(pullRequest.updatedAt)}
</td>
<td className="relative z-10">
<div className="flex justify-end">
<ProjectListRowMenu label={`More options for ${pullRequest.title}`}>
<DropdownMenuItem onSelect={() => onOpen(project, pullRequest)}>
<GitPullRequest className="h-4 w-4" />
@@ -282,12 +295,13 @@ function PullRequestListRow({
</DropdownMenuItem>
<CopyShareLinkMenuItem
link={pullRequestShareLink(pullRequest)}
label="Copy review link"
testId={`projects-pull-request-copy-link-${pullRequest.id}`}
/>
</ProjectListRowMenu>
</div>
</div>
</div>
</td>
</tr>
);
}
@@ -304,16 +318,7 @@ export function ProjectsPullRequestsList({
viewMode,
}: ProjectsPullRequestsListProps) {
if (isLoading) {
return (
<div
className={cn(
"px-4 py-12 text-center text-sm text-muted-foreground",
!embedded && "border border-border/60",
)}
>
Loading pull requests...
</div>
);
return <BuzzLoadingState label="Loading reviews" />;
}
const loadNotice = (
@@ -340,7 +345,7 @@ export function ProjectsPullRequestsList({
!embedded && "border border-dashed border-border/60",
)}
>
No pull requests yet.
No reviews yet.
</div>
</div>
);
@@ -371,25 +376,29 @@ export function ProjectsPullRequestsList({
return (
<div className="space-y-3">
{loadNotice}
<div
className={
embedded ? "divide-y divide-border/60" : PROJECT_LIST_CONTAINER_CLASS
}
<table
className={cn(
"block w-full overflow-x-auto bg-transparent",
!embedded && "rounded-xl border border-border/60",
)}
data-testid="projects-list-container"
>
{pullRequests.map(({ project, pullRequest, repository }) => (
<PullRequestListRow
key={`${repository.id}:${pullRequest.id}`}
onOpen={(selectedProject, selectedPullRequest) =>
onOpen(selectedProject, repository, selectedPullRequest)
}
profiles={profiles}
project={project}
pullRequest={pullRequest}
repository={repository}
/>
))}
</div>
<ProjectsWorkItemTableHeader itemLabel="Review" typeLabel="Type" />
<tbody className="block divide-y divide-border/60">
{pullRequests.map(({ project, pullRequest, repository }) => (
<PullRequestListRow
key={`${repository.id}:${pullRequest.id}`}
onOpen={(selectedProject, selectedPullRequest) =>
onOpen(selectedProject, repository, selectedPullRequest)
}
profiles={profiles}
project={project}
pullRequest={pullRequest}
repository={repository}
/>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -122,8 +122,8 @@ export function ProjectsToolbar({
{ label: "Activity", value: "all" },
{ label: "Projects", value: "projects" },
{ label: "Repositories", value: "repositories" },
{ label: "Pull Requests", value: "prs" },
{ label: "Issues", value: "issues" },
{ label: "Tasks", value: "issues" },
{ label: "Reviews", value: "prs" },
];
return (

View File

@@ -0,0 +1,23 @@
export const WORK_ITEM_TABLE_GRID_CLASS =
"grid w-full grid-cols-[minmax(0,1fr)_5.5rem_4.5rem_3rem_5rem_1.5rem] items-center gap-x-3";
export function ProjectsWorkItemTableHeader({
itemLabel,
typeLabel,
}: {
itemLabel: string;
typeLabel: string;
}) {
return (
<thead className="sr-only">
<tr>
<th scope="col">{itemLabel}</th>
<th scope="col">{typeLabel}</th>
<th scope="col">Status</th>
<th scope="col">Replies</th>
<th scope="col">Updated</th>
<th scope="col">Actions</th>
</tr>
</thead>
);
}

View File

@@ -6,7 +6,7 @@ import { Button } from "@/shared/ui/button";
const SECTION_LABELS: Record<ProjectWorkItemSection, string> = {
assignments: "assignments",
comments: "comments",
"pull-request-updates": "pull request updates",
"pull-request-updates": "review updates",
statuses: "statuses",
};
@@ -28,14 +28,20 @@ export function ProjectsWorkItemsLoadNotice({
}: ProjectsWorkItemsLoadNoticeProps) {
if (!error && failedSections.length === 0) return null;
const displaySubject =
subject === "pull requests"
? "reviews"
: subject === "issues"
? "tasks"
: subject;
const detailSubject =
subject === "pull requests"
? "pull request"
? "review"
: subject === "issues"
? "issue"
? "task"
: subject;
const title = error
? `Could not load ${subject}.`
? `Could not load ${displaySubject}.`
: `Some ${detailSubject} details could not be loaded.`;
const description = error
? error instanceof Error

View File

@@ -0,0 +1,105 @@
import { GitBranch, GitMerge, GitPullRequest, Tag } from "lucide-react";
import { useIsManagedAgent } from "@/features/agent-memory/hooks";
import type {
ProjectPullRequest,
Repository as Project,
} from "@/features/projects/hooks";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { useIdentityQuery } from "@/shared/api/hooks";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { CopyCommitHashButton } from "./ProjectCommitCopyButton";
import {
ProjectDetailMetaList,
ProjectDetailMetaPills,
ProjectDetailMetaRow,
} from "./ProjectDetailMeta";
import { PullRequestReviewersRow } from "./PullRequestReviewersRow";
function statusClassName(status: ProjectPullRequest["status"]) {
if (status === "Closed") return "text-destructive";
if (status === "Draft") return "text-muted-foreground";
if (status === "Merged") return "text-purple-400";
return "text-green-500";
}
/** Compact metadata under the review title: branches, reviewers, status. */
export function PullRequestMetaHeader({
diffStats,
profiles,
project,
pullRequest,
}: {
diffStats?: { additions: number; deletions: number } | null;
profiles?: UserProfileLookup;
project: Project;
pullRequest: ProjectPullRequest;
}) {
const identityQuery = useIdentityQuery();
const targetBranch =
pullRequest.targetBranch || project.defaultBranch || "default branch";
const sourceBranch = pullRequest.branchName || "unknown branch";
const labels = pullRequest.labels.filter(
(label) => label.toLowerCase() !== "draft",
);
const viewerPubkey = identityQuery.data?.pubkey;
const viewer = viewerPubkey ? normalizePubkey(viewerPubkey) : null;
const isAuthor = viewer === normalizePubkey(pullRequest.author);
const isOwner = viewer === normalizePubkey(project.owner);
const isManagedAgentOwner = useIsManagedAgent(project.owner) === true;
const canRequestReview =
Boolean(viewer) && (isAuthor || isOwner || isManagedAgentOwner);
const StatusIcon =
pullRequest.status === "Merged" ? GitMerge : GitPullRequest;
return (
<ProjectDetailMetaList>
<ProjectDetailMetaRow icon={GitBranch} label="Branch">
<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>
<span className="truncate text-foreground">{targetBranch}</span>
{diffStats ? (
<>
<span className="text-green-500">+{diffStats.additions}</span>
<span className="text-destructive">-{diffStats.deletions}</span>
</>
) : null}
{pullRequest.commit ? (
<span className="inline-flex min-w-0 items-center gap-0.5 text-muted-foreground">
<code
className="truncate font-mono text-xs"
title={pullRequest.commit}
>
{pullRequest.commit.slice(0, 7)}
</code>
<CopyCommitHashButton
className="h-5 w-5 p-0"
hash={pullRequest.commit}
/>
</span>
) : null}
</p>
</ProjectDetailMetaRow>
<PullRequestReviewersRow
canRequest={canRequestReview}
profiles={profiles}
project={project}
pullRequest={pullRequest}
signAsManagedOwner={isManagedAgentOwner && !isOwner}
/>
<ProjectDetailMetaRow icon={StatusIcon} label="Status">
<span className={`font-medium ${statusClassName(pullRequest.status)}`}>
{pullRequest.status}
</span>
</ProjectDetailMetaRow>
{labels.length > 0 ? (
<ProjectDetailMetaRow icon={Tag} label="Labels">
<ProjectDetailMetaPills labels={labels} />
</ProjectDetailMetaRow>
) : null}
</ProjectDetailMetaList>
);
}

View File

@@ -95,9 +95,9 @@ export function PullRequestReviewCard({
status === "draft"
? "Converted to draft."
: status === "closed"
? "Pull request closed."
? "Review closed."
: pullRequest.status === "Closed"
? "Pull request reopened."
? "Review reopened."
: "Marked as ready for review.",
);
} catch (error) {
@@ -150,7 +150,7 @@ export function PullRequestReviewCard({
const handleApprove = React.useCallback(async () => {
const approved = await runReviewDecision(
approvePullRequest,
"Pull request approved.",
"Review approved.",
"Failed to approve.",
approvalSummary,
);
@@ -237,14 +237,14 @@ export function PullRequestReviewCard({
variant="secondary"
>
<RotateCcw className="h-3.5 w-3.5" />
Reopen pull request
Reopen review
</Button>
) : null}
{hasOverflowAction ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label="More pull request actions"
aria-label="More review actions"
className="ml-auto h-8 w-8"
disabled={isUpdatingStatus}
size="icon-xs"
@@ -273,7 +273,7 @@ export function PullRequestReviewCard({
}}
>
<X className="h-4 w-4" />
Close pull request
Close review
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
@@ -291,7 +291,7 @@ export function PullRequestReviewCard({
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Approve pull request</DialogTitle>
<DialogTitle>Approve review</DialogTitle>
<DialogDescription>
Add an optional summary for the author and other reviewers.
</DialogDescription>

Some files were not shown because too many files have changed in this diff Show More