mirror of
https://github.com/block/buzz.git
synced 2026-11-02 15:31:27 +00:00
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
18
desktop/src/app/AppTopChromePortal.tsx
Normal file
18
desktop/src/app/AppTopChromePortal.tsx
Normal 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;
|
||||
}
|
||||
38
desktop/src/app/TerminalContextOverrideContext.tsx
Normal file
38
desktop/src/app/TerminalContextOverrideContext.tsx
Normal 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]);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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.`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}`,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
121
desktop/src/features/projects/lib/projectDetailAgentContext.ts
Normal file
121
desktop/src/features/projects/lib/projectDetailAgentContext.ts
Normal 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+$/, "");
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
26
desktop/src/features/projects/lib/projectExternalUrl.ts
Normal file
26
desktop/src/features/projects/lib/projectExternalUrl.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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(" · ");
|
||||
}
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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,
|
||||
|
||||
39
desktop/src/features/projects/projectTaskCategories.ts
Normal file
39
desktop/src/features/projects/projectTaskCategories.ts
Normal 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));
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
314
desktop/src/features/projects/ui/CreateProjectFormContent.tsx
Normal file
314
desktop/src/features/projects/ui/CreateProjectFormContent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
235
desktop/src/features/projects/ui/ProjectAgentChatPanel.tsx
Normal file
235
desktop/src/features/projects/ui/ProjectAgentChatPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
192
desktop/src/features/projects/ui/ProjectBrowserDialog.tsx
Normal file
192
desktop/src/features/projects/ui/ProjectBrowserDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
362
desktop/src/features/projects/ui/ProjectConversationPanel.tsx
Normal file
362
desktop/src/features/projects/ui/ProjectConversationPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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()}
|
||||
>
|
||||
|
||||
81
desktop/src/features/projects/ui/ProjectDetailMeta.tsx
Normal file
81
desktop/src/features/projects/ui/ProjectDetailMeta.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
desktop/src/features/projects/ui/ProjectDetailRightPanel.tsx
Normal file
43
desktop/src/features/projects/ui/ProjectDetailRightPanel.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
66
desktop/src/features/projects/ui/ProjectDetailSection.tsx
Normal file
66
desktop/src/features/projects/ui/ProjectDetailSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
112
desktop/src/features/projects/ui/ProjectRightPanelControls.tsx
Normal file
112
desktop/src/features/projects/ui/ProjectRightPanelControls.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
34
desktop/src/features/projects/ui/ProjectWorkItemGroup.tsx
Normal file
34
desktop/src/features/projects/ui/ProjectWorkItemGroup.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
desktop/src/features/projects/ui/ProjectWorkItemRow.tsx
Normal file
86
desktop/src/features/projects/ui/ProjectWorkItemRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
23
desktop/src/features/projects/ui/ProjectsWorkItemTable.tsx
Normal file
23
desktop/src/features/projects/ui/ProjectsWorkItemTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
105
desktop/src/features/projects/ui/PullRequestMetaRail.tsx
Normal file
105
desktop/src/features/projects/ui/PullRequestMetaRail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user