mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(desktop): refine Projects overview and list presentation
Separate overview context and agent rails from the content pod, simplify project and repository rows, and align the surrounding navigation chrome and metadata. Signed-off-by: Thomas Petersen <thomasp@squareup.com>
This commit is contained in:
@@ -22,6 +22,7 @@ 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 { cn } from "@/shared/lib/cn";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
@@ -42,6 +43,7 @@ export function ProjectAgentChatPanel({
|
||||
canResetWidth,
|
||||
constrainToAvailableSpace = true,
|
||||
context,
|
||||
detached = false,
|
||||
onClose,
|
||||
onResetWidth,
|
||||
onResizeStart,
|
||||
@@ -51,6 +53,7 @@ export function ProjectAgentChatPanel({
|
||||
canResetWidth: boolean;
|
||||
constrainToAvailableSpace?: boolean;
|
||||
context: ProjectDetailAgentContext;
|
||||
detached?: boolean;
|
||||
onClose?: () => void;
|
||||
onResetWidth: () => void;
|
||||
onResizeStart: (event: React.PointerEvent<HTMLButtonElement>) => void;
|
||||
@@ -166,79 +169,87 @@ export function ProjectAgentChatPanel({
|
||||
<RightAuxiliaryPane
|
||||
canResetWidth={canResetWidth}
|
||||
constrainToAvailableSpace={constrainToAvailableSpace}
|
||||
detached={detached}
|
||||
onResetWidth={onResetWidth}
|
||||
onResizeStart={onResizeStart}
|
||||
testId="project-agent-chat-panel"
|
||||
widthPx={widthPx}
|
||||
>
|
||||
<ProjectAgentContextStrip
|
||||
context={context}
|
||||
onClose={onClose}
|
||||
sharedBackdrop={sharedHeaderBackdrop}
|
||||
/>
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div
|
||||
className="flex min-h-0 flex-1 flex-col overflow-x-hidden overflow-y-auto 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 min-h-40 flex-1 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>
|
||||
{context.selection?.length ? (
|
||||
<ProjectAgentSelectionComposerBanner items={context.selection} />
|
||||
) : null}
|
||||
<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
|
||||
className={cn(
|
||||
"relative flex min-h-0 min-w-0 flex-1 flex-col",
|
||||
detached && "bg-background",
|
||||
)}
|
||||
>
|
||||
<ProjectAgentContextStrip
|
||||
context={context}
|
||||
onClose={onClose}
|
||||
sharedBackdrop={sharedHeaderBackdrop}
|
||||
/>
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div
|
||||
className="flex min-h-0 flex-1 flex-col overflow-x-hidden overflow-y-auto 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 min-h-40 flex-1 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>
|
||||
{context.selection?.length ? (
|
||||
<ProjectAgentSelectionComposerBanner items={context.selection} />
|
||||
) : null}
|
||||
<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>
|
||||
</div>
|
||||
</RightAuxiliaryPane>
|
||||
);
|
||||
|
||||
@@ -20,10 +20,8 @@ import type {
|
||||
ProjectActivitySummary,
|
||||
} from "@/features/projects/hooks";
|
||||
import {
|
||||
formatExactTimestamp,
|
||||
getProjectUpdatedAt,
|
||||
listRowDescription,
|
||||
relativeTime,
|
||||
} from "@/features/projects/lib/projectsViewHelpers";
|
||||
import type { ProjectRepoUnavailableReason } from "@/features/projects/lib/projectRepoAvailability";
|
||||
import { projectShareLink } from "@/features/projects/lib/projectShareLinks";
|
||||
@@ -55,39 +53,6 @@ import { ProjectEntityListRow } from "./ProjectEntityListRow";
|
||||
import { PROJECT_GRID_CARD_BODY_CLASS } from "./projectGridCardStyles";
|
||||
import { ProjectListRowMenu } from "./ProjectListRowMenu";
|
||||
|
||||
function ProjectUpdatedLabel({
|
||||
profiles,
|
||||
project,
|
||||
summary,
|
||||
}: {
|
||||
profiles?: UserProfileLookup;
|
||||
project: Project;
|
||||
summary: ProjectActivitySummary | undefined;
|
||||
}) {
|
||||
const updatedAt = getProjectUpdatedAt(project, summary);
|
||||
const latestCommit = summary?.latestCommit;
|
||||
const authorLabel = latestCommit?.author
|
||||
? resolveUserLabel({ profiles, pubkey: latestCommit.author })
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="whitespace-nowrap text-xs leading-4 text-muted-foreground/70">
|
||||
{relativeTime(updatedAt)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-96 break-words">
|
||||
{latestCommit
|
||||
? `${latestCommit.title || latestCommit.commit.slice(0, 7)}${
|
||||
authorLabel ? ` · ${authorLabel}` : ""
|
||||
} · ${formatExactTimestamp(latestCommit.createdAt)}`
|
||||
: `Created ${formatExactTimestamp(project.createdAt)}`}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectPeopleStack({
|
||||
pubkeys,
|
||||
profiles,
|
||||
@@ -540,12 +505,7 @@ export function ProjectGridCard({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pointer-events-auto relative z-10 flex shrink-0 items-center gap-1">
|
||||
<ProjectUpdatedLabel
|
||||
profiles={profiles}
|
||||
project={project}
|
||||
summary={summary}
|
||||
/>
|
||||
<div className="pointer-events-auto relative z-10 shrink-0">
|
||||
<ProjectActionsMenu
|
||||
canDelete={canDelete}
|
||||
disabled={deleteDisabled}
|
||||
@@ -613,6 +573,7 @@ export function ProjectListRow({
|
||||
<span>{repositoryCount}</span>
|
||||
</span>
|
||||
}
|
||||
affiliationClassName="w-auto"
|
||||
affiliationTestId="projects-row-context"
|
||||
affiliationTitle={`${repositoryCount} ${
|
||||
repositoryCount === 1 ? "repository" : "repositories"
|
||||
|
||||
@@ -131,6 +131,7 @@ export function ProjectEntitySelectControl({
|
||||
|
||||
export function ProjectEntityListRow({
|
||||
affiliation,
|
||||
affiliationClassName,
|
||||
affiliationTestId,
|
||||
affiliationTitle,
|
||||
beforeDate,
|
||||
@@ -158,6 +159,7 @@ export function ProjectEntityListRow({
|
||||
trailing,
|
||||
}: {
|
||||
affiliation?: React.ReactNode;
|
||||
affiliationClassName?: string;
|
||||
affiliationTestId?: string;
|
||||
affiliationTitle?: string;
|
||||
beforeDate?: React.ReactNode;
|
||||
@@ -291,7 +293,10 @@ export function ProjectEntityListRow({
|
||||
) : null}
|
||||
{affiliation ? (
|
||||
<span
|
||||
className="hidden w-36 shrink-0 truncate text-right text-xs text-muted-foreground/65 md:block"
|
||||
className={cn(
|
||||
"hidden w-36 shrink-0 truncate text-right text-xs text-muted-foreground/65 md:block",
|
||||
affiliationClassName,
|
||||
)}
|
||||
data-projects-text-priority="secondary"
|
||||
data-testid={affiliationTestId}
|
||||
title={
|
||||
|
||||
@@ -872,7 +872,10 @@ export function RepositoryFilesPanel({
|
||||
>
|
||||
<th className="px-4 py-3 text-left font-normal" colSpan={3}>
|
||||
{latestCommit ? (
|
||||
<div className="flex min-w-0 items-center justify-between gap-3 text-sm">
|
||||
<div
|
||||
className="flex min-w-0 items-center justify-between gap-3 text-xs"
|
||||
data-testid="project-repository-latest-commit-summary"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<UserAvatar
|
||||
accent={latestCommitProfile?.isAgent === true}
|
||||
@@ -938,7 +941,7 @@ export function RepositoryFilesPanel({
|
||||
}
|
||||
tabIndex={0}
|
||||
>
|
||||
<td className="min-w-52 px-3 py-2 align-middle transition-colors group-hover/repository-entry:bg-muted/35 group-focus-visible/repository-entry:bg-muted/35">
|
||||
<td className="min-w-52 px-3 py-2 align-middle transition-colors group-hover/repository-entry:rounded-l-md group-hover/repository-entry:bg-muted/35 group-focus-visible/repository-entry:rounded-l-md group-focus-visible/repository-entry:bg-muted/35">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<RepositoryEntryIcon entry={entry} />
|
||||
<span className="truncate font-medium text-foreground">
|
||||
@@ -952,7 +955,7 @@ export function RepositoryFilesPanel({
|
||||
profiles={profiles}
|
||||
/>
|
||||
</td>
|
||||
<td className="w-36 whitespace-nowrap 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">
|
||||
<td className="w-36 whitespace-nowrap p-2 text-right align-middle text-muted-foreground transition-colors group-hover/repository-entry:rounded-r-md group-hover/repository-entry:bg-muted/35 group-focus-visible/repository-entry:rounded-r-md group-focus-visible/repository-entry:bg-muted/35">
|
||||
{latestCommit ? (
|
||||
<time
|
||||
dateTime={new Date(
|
||||
|
||||
@@ -118,9 +118,7 @@ export function ProjectsListHeaderBar({
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-wrap items-center justify-between gap-2 bg-muted/20 px-3 py-1.5",
|
||||
variant === "bar"
|
||||
? "rounded-xl border border-border/60"
|
||||
: "border-b border-border/60",
|
||||
variant === "bar" ? "rounded-xl" : "border-b border-border/60",
|
||||
)}
|
||||
data-testid="projects-list-header"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Info, MessageCircle } from "lucide-react";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
|
||||
export function ProjectsOverviewChromeActions({
|
||||
chatOpen,
|
||||
contextOpen,
|
||||
onToggleChat,
|
||||
onToggleContext,
|
||||
sectionTitle,
|
||||
}: {
|
||||
chatOpen: boolean;
|
||||
contextOpen: boolean;
|
||||
onToggleChat: () => void;
|
||||
onToggleContext: () => void;
|
||||
sectionTitle: string;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
aria-label={`Chat with an agent about ${sectionTitle}`}
|
||||
aria-pressed={chatOpen}
|
||||
className="h-7 w-7 text-sidebar-foreground hover:bg-sidebar-accent"
|
||||
data-testid="projects-overview-chat-toggle"
|
||||
onClick={onToggleChat}
|
||||
size="icon"
|
||||
title={`Chat with an agent about ${sectionTitle}`}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<MessageCircle
|
||||
className={cn(
|
||||
"h-4 w-4 transition-opacity duration-200 ease-linear",
|
||||
chatOpen ? "opacity-100" : "opacity-60",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
aria-label={
|
||||
contextOpen ? "Hide project context" : "Show project context"
|
||||
}
|
||||
aria-pressed={contextOpen}
|
||||
className="h-7 w-7 text-sidebar-foreground hover:bg-sidebar-accent"
|
||||
data-testid="projects-overview-context-toggle"
|
||||
onClick={onToggleContext}
|
||||
size="icon"
|
||||
title={contextOpen ? "Hide project context" : "Show project context"}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Info
|
||||
className={cn(
|
||||
"h-4 w-4 transition-opacity duration-200 ease-linear",
|
||||
contextOpen ? "opacity-100" : "opacity-60",
|
||||
)}
|
||||
data-testid="projects-overview-context-icon"
|
||||
/>
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Info, MessageCircle, Search } from "lucide-react";
|
||||
import { Search } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
ProjectsOverviewContextPanel,
|
||||
ProjectsOverviewPanel,
|
||||
} from "@/features/projects/ui/ProjectsOverviewPanel";
|
||||
import { ProjectsOverviewChromeActions } from "@/features/projects/ui/ProjectsOverviewChromeActions";
|
||||
import { ProjectContextRail } from "@/features/projects/ui/ProjectContextRail";
|
||||
import {
|
||||
openAppSearch,
|
||||
@@ -678,33 +679,24 @@ export function ProjectsView() {
|
||||
</>
|
||||
);
|
||||
|
||||
const overviewDetached = overviewPanelOpen && !isNarrowProjectsLayout;
|
||||
const overviewChatOpen =
|
||||
selectionAgentContext !== null && !isNarrowProjectsLayout;
|
||||
const overviewContextOpen = overviewPanelOpen && !isNarrowProjectsLayout;
|
||||
const overviewDetached = overviewContextOpen || overviewChatOpen;
|
||||
const chromeActions = (
|
||||
<>
|
||||
{isNarrowProjectsLayout ? null : (
|
||||
<Button
|
||||
aria-label={
|
||||
overviewPanelOpen ? "Hide project context" : "Show project context"
|
||||
<ProjectsOverviewChromeActions
|
||||
chatOpen={overviewChatOpen}
|
||||
contextOpen={overviewPanelOpen}
|
||||
onToggleChat={() =>
|
||||
setSelectionAgentContext((context) =>
|
||||
context ? null : overviewAgentContext,
|
||||
)
|
||||
}
|
||||
aria-pressed={overviewPanelOpen}
|
||||
className="h-7 w-7 text-sidebar-foreground hover:bg-sidebar-accent"
|
||||
data-testid="projects-overview-context-toggle"
|
||||
onClick={() => setOverviewPanelOpen((open) => !open)}
|
||||
size="icon"
|
||||
title={
|
||||
overviewPanelOpen ? "Hide project context" : "Show project context"
|
||||
}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Info
|
||||
className={cn(
|
||||
"h-4 w-4 transition-opacity duration-200 ease-linear",
|
||||
overviewPanelOpen ? "opacity-100" : "opacity-60",
|
||||
)}
|
||||
data-testid="projects-overview-context-icon"
|
||||
/>
|
||||
</Button>
|
||||
onToggleContext={() => setOverviewPanelOpen((open) => !open)}
|
||||
sectionTitle={projectsSectionTitle(filter)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
@@ -828,30 +820,6 @@ export function ProjectsView() {
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
aria-label={`Chat with an agent about ${projectsSectionTitle(filter)}`}
|
||||
aria-pressed={selectionAgentContext !== null}
|
||||
className="-mr-2 ml-auto h-7 w-7 shrink-0 text-muted-foreground hover:bg-muted/70 hover:text-foreground"
|
||||
data-testid="projects-overview-chat-toggle"
|
||||
onClick={() =>
|
||||
setSelectionAgentContext((context) =>
|
||||
context ? null : overviewAgentContext,
|
||||
)
|
||||
}
|
||||
size="icon"
|
||||
title={`Chat with an agent about ${projectsSectionTitle(filter)}`}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<MessageCircle
|
||||
className={cn(
|
||||
"h-4 w-4 transition-opacity duration-200 ease-linear",
|
||||
selectionAgentContext
|
||||
? "opacity-100"
|
||||
: "opacity-60",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
@@ -934,20 +902,29 @@ export function ProjectsView() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{selectionAgentContext && !isNarrowProjectsLayout ? (
|
||||
<ProjectAgentChatPanel
|
||||
canResetWidth={overviewAgentPanelWidth.canReset}
|
||||
context={selectionAgentContext}
|
||||
onClose={() => setSelectionAgentContext(null)}
|
||||
onResetWidth={overviewAgentPanelWidth.onResetWidth}
|
||||
onResizeStart={overviewAgentPanelWidth.onResizeStart}
|
||||
widthPx={overviewAgentPanelWidth.widthPx}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<ProjectContextRail
|
||||
open={overviewDetached}
|
||||
open={overviewChatOpen}
|
||||
panelWidthPx={overviewAgentPanelWidth.widthPx}
|
||||
resizing={overviewAgentPanelWidth.isResizing}
|
||||
testId="projects-overview-agent-rail"
|
||||
>
|
||||
{selectionAgentContext ? (
|
||||
<ProjectAgentChatPanel
|
||||
canResetWidth={overviewAgentPanelWidth.canReset}
|
||||
constrainToAvailableSpace={false}
|
||||
context={selectionAgentContext}
|
||||
detached
|
||||
onClose={() => setSelectionAgentContext(null)}
|
||||
onResetWidth={overviewAgentPanelWidth.onResetWidth}
|
||||
onResizeStart={overviewAgentPanelWidth.onResizeStart}
|
||||
widthPx={overviewAgentPanelWidth.widthPx}
|
||||
/>
|
||||
) : null}
|
||||
</ProjectContextRail>
|
||||
<ProjectContextRail
|
||||
open={overviewContextOpen}
|
||||
panelWidthPx={PROJECT_CONTEXT_PANEL_DEFAULT_WIDTH_PX}
|
||||
testId="projects-overview-context-rail"
|
||||
>
|
||||
|
||||
@@ -289,58 +289,83 @@ export function PullRequestReviewersRow({
|
||||
|
||||
return (
|
||||
<ProjectDetailMetaRow icon={Users} label="Reviewers">
|
||||
<div className="grid min-w-0 gap-1">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<div
|
||||
className="flex min-w-0 items-center gap-2"
|
||||
data-testid="project-reviewers-content"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden whitespace-nowrap">
|
||||
{showSummary && displayedDecisionActors.length === 0 ? (
|
||||
<span className="font-medium" data-testid={summaryTestId}>
|
||||
<span className="truncate font-medium" data-testid={summaryTestId}>
|
||||
{reviewSummary}
|
||||
</span>
|
||||
) : null}
|
||||
{displayedDecisionActors.map((pubkey, index) => {
|
||||
const label = labelForPubkey(pubkey, profiles);
|
||||
const hasApproved = approvedBy.has(pubkey);
|
||||
const hasRequestedChanges = changesRequestedBy.has(pubkey);
|
||||
const needsRereview = staleDecisionActors.has(pubkey);
|
||||
const DecisionIcon = hasApproved
|
||||
? Check
|
||||
: hasRequestedChanges
|
||||
? TriangleAlert
|
||||
: needsRereview
|
||||
? History
|
||||
: null;
|
||||
const decisionLabel = hasApproved
|
||||
? `Approved by ${label}`
|
||||
: hasRequestedChanges
|
||||
? `Changes requested by ${label}`
|
||||
: needsRereview
|
||||
? `Re-review needed from ${label}`
|
||||
: `Awaiting review from ${label}`;
|
||||
return (
|
||||
<React.Fragment key={pubkey}>
|
||||
{index > 0 ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="shrink-0 text-muted-foreground/50"
|
||||
>
|
||||
·
|
||||
</span>
|
||||
) : null}
|
||||
<span
|
||||
className={cn(
|
||||
"flex min-w-0 shrink items-center gap-1 text-sm",
|
||||
hasApproved && "text-green-600 dark:text-green-400",
|
||||
hasRequestedChanges && "text-amber-600 dark:text-amber-400",
|
||||
!hasApproved &&
|
||||
!hasRequestedChanges &&
|
||||
"text-muted-foreground",
|
||||
)}
|
||||
data-testid="project-reviewer-decision"
|
||||
title={decisionLabel}
|
||||
>
|
||||
<span className="sr-only">{decisionLabel}</span>
|
||||
{DecisionIcon ? (
|
||||
<DecisionIcon
|
||||
aria-hidden="true"
|
||||
className="h-3.5 w-3.5 shrink-0"
|
||||
/>
|
||||
) : null}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="truncate"
|
||||
data-testid="project-reviewer-name"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</span>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{hasHistoricalDecision ? (
|
||||
<span className="flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400">
|
||||
<span className="flex min-w-0 items-center gap-1 truncate text-xs text-amber-600 dark:text-amber-400">
|
||||
<History className="h-3.5 w-3.5 shrink-0" />
|
||||
Earlier decision applies to another commit
|
||||
</span>
|
||||
) : null}
|
||||
{requestAction}
|
||||
</div>
|
||||
{displayedDecisionActors.map((pubkey) => {
|
||||
const label = labelForPubkey(pubkey, profiles);
|
||||
const hasApproved = approvedBy.has(pubkey);
|
||||
const hasRequestedChanges = changesRequestedBy.has(pubkey);
|
||||
const needsRereview = staleDecisionActors.has(pubkey);
|
||||
const DecisionIcon = hasApproved
|
||||
? Check
|
||||
: hasRequestedChanges
|
||||
? TriangleAlert
|
||||
: needsRereview
|
||||
? History
|
||||
: null;
|
||||
const decisionLabel = hasApproved
|
||||
? `Approved by ${label}`
|
||||
: hasRequestedChanges
|
||||
? `Changes requested by ${label}`
|
||||
: needsRereview
|
||||
? `Re-review needed from ${label}`
|
||||
: `Awaiting review from ${label}`;
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"flex min-w-0 items-center gap-1 text-sm",
|
||||
hasApproved && "text-green-600 dark:text-green-400",
|
||||
hasRequestedChanges && "text-amber-600 dark:text-amber-400",
|
||||
!hasApproved && !hasRequestedChanges && "text-muted-foreground",
|
||||
)}
|
||||
data-testid="project-reviewer-decision"
|
||||
key={pubkey}
|
||||
>
|
||||
{DecisionIcon ? (
|
||||
<DecisionIcon className="h-3.5 w-3.5 shrink-0" />
|
||||
) : null}
|
||||
<span className="truncate">{decisionLabel}</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{requestAction}
|
||||
</div>
|
||||
</ProjectDetailMetaRow>
|
||||
);
|
||||
|
||||
@@ -18,10 +18,6 @@ import {
|
||||
selectionItemFromRepository,
|
||||
type ProjectSelectionItem,
|
||||
} from "@/features/projects/lib/projectSelection";
|
||||
import {
|
||||
formatExactTimestamp,
|
||||
relativeTime,
|
||||
} from "@/features/projects/lib/projectsViewHelpers";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import { useRelayOrigin } from "@/shared/lib/useRelayOrigin";
|
||||
@@ -155,27 +151,6 @@ function RepositoryIdentity({
|
||||
);
|
||||
}
|
||||
|
||||
function RepositoryUpdatedLabel({
|
||||
repository,
|
||||
summary,
|
||||
}: Pick<RepositoryItemProps, "repository" | "summary">) {
|
||||
const updatedAt = summary?.updatedAt || repository.createdAt;
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="whitespace-nowrap text-xs leading-4 text-muted-foreground/70">
|
||||
{relativeTime(updatedAt)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{summary?.latestCommit
|
||||
? `${summary.latestCommit.title || summary.latestCommit.commit.slice(0, 7)} · ${formatExactTimestamp(summary.latestCommit.createdAt)}`
|
||||
: `Created ${formatExactTimestamp(repository.createdAt)}`}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function repositoryPeople(
|
||||
repository: Repository,
|
||||
summary: ProjectActivitySummary | undefined,
|
||||
@@ -261,13 +236,12 @@ export function RepositoryGridCard(props: RepositoryItemProps) {
|
||||
>
|
||||
{repository.description || "A repository in this project."}
|
||||
</p>
|
||||
<div className="pointer-events-auto mt-auto flex items-center justify-between gap-3">
|
||||
<div className="pointer-events-auto mt-auto">
|
||||
<ProjectPeopleStack
|
||||
profiles={profiles}
|
||||
pubkeys={repositoryPeople(repository, summary)}
|
||||
workOwnerPubkey={repository.owner}
|
||||
/>
|
||||
<RepositoryUpdatedLabel repository={repository} summary={summary} />
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<ProjectStatsRow summary={summary} />
|
||||
|
||||
@@ -589,8 +589,10 @@ function SidebarProjectRow({
|
||||
tooltip={project.name}
|
||||
type="button"
|
||||
>
|
||||
<ProjectIcon className="h-4 w-4" />
|
||||
<SidebarMenuLabel>{project.name}</SidebarMenuLabel>
|
||||
<ProjectIcon className={cn("h-4 w-4", !isActive && "opacity-80")} />
|
||||
<SidebarMenuLabel className={cn(!isActive && "opacity-80")}>
|
||||
{project.name}
|
||||
</SidebarMenuLabel>
|
||||
</SidebarMenuButton>
|
||||
{canDelete ? (
|
||||
<SidebarMenuAction
|
||||
|
||||
@@ -596,6 +596,26 @@ test("latest files commit opens its detail without a divider", async ({
|
||||
const latestCommit = page.getByTestId("project-repository-latest-commit");
|
||||
await expect(latestCommit).toBeVisible();
|
||||
await expect(latestCommit).toHaveCSS("border-bottom-width", "0px");
|
||||
await expect(
|
||||
page.getByTestId("project-repository-latest-commit-summary"),
|
||||
).toHaveCSS("font-size", "12px");
|
||||
await expect(
|
||||
page.getByTestId("project-repository-entry-row").first(),
|
||||
).toHaveCSS("font-size", "12px");
|
||||
const repositoryEntryRow = page
|
||||
.getByTestId("project-repository-entry-row")
|
||||
.first();
|
||||
const repositoryEntryCells = repositoryEntryRow.locator("td");
|
||||
await expect(repositoryEntryCells.first()).toHaveCSS("border-radius", "0px");
|
||||
await repositoryEntryRow.hover();
|
||||
await expect(repositoryEntryCells.first()).toHaveCSS(
|
||||
"border-top-left-radius",
|
||||
"8px",
|
||||
);
|
||||
await expect(repositoryEntryCells.last()).toHaveCSS(
|
||||
"border-top-right-radius",
|
||||
"8px",
|
||||
);
|
||||
await latestCommit.click();
|
||||
await expect(page.getByTestId("project-commit-detail")).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -266,11 +266,20 @@ test("PR creator/owner can toggle draft, request reviews, and approve", async ({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(page.getByTestId("project-review-summary")).toHaveCount(0);
|
||||
const reviewerDecision = page
|
||||
.getByTestId("project-reviewer-decision")
|
||||
.filter({ hasText: "bob" });
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("project-reviewer-decision")
|
||||
.filter({ hasText: "Awaiting review from bob" }),
|
||||
).toHaveCount(1);
|
||||
reviewerDecision.getByTestId("project-reviewer-name"),
|
||||
).toHaveText("bob");
|
||||
await expect(reviewerDecision).toHaveAttribute(
|
||||
"title",
|
||||
"Awaiting review from bob",
|
||||
);
|
||||
await expect(page.getByTestId("project-reviewers-content")).toHaveCSS(
|
||||
"flex-wrap",
|
||||
"nowrap",
|
||||
);
|
||||
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({
|
||||
@@ -296,9 +305,9 @@ test("PR creator/owner can toggle draft, request reviews, and approve", async ({
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByTestId("project-review-summary")).toHaveCount(0);
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("project-reviewer-decision")
|
||||
.filter({ hasText: "Changes requested" }),
|
||||
page.locator(
|
||||
'[data-testid="project-reviewer-decision"][title^="Changes requested by "]',
|
||||
),
|
||||
).toHaveCount(1);
|
||||
const changeRequestEvent = await page.evaluate(() =>
|
||||
window.__BUZZ_E2E_SIGNED_EVENTS__
|
||||
@@ -380,9 +389,9 @@ test("PR creator/owner can toggle draft, request reviews, and approve", async ({
|
||||
).toHaveCount(0);
|
||||
await expect(page.getByTestId("project-review-summary")).toHaveCount(0);
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("project-reviewer-decision")
|
||||
.filter({ hasText: "Approved by" }),
|
||||
page.locator(
|
||||
'[data-testid="project-reviewer-decision"][title^="Approved by "]',
|
||||
),
|
||||
).toHaveCount(1);
|
||||
const approvalEvent = await page.evaluate(() =>
|
||||
window.__BUZZ_E2E_SIGNED_EVENTS__
|
||||
@@ -1104,10 +1113,19 @@ test("sidebar distinguishes the Projects overview from an open project", async (
|
||||
await projectsOverview.click();
|
||||
await expect(projectsOverview).toHaveAttribute("data-active", "true");
|
||||
await expect(sidebarProject).toHaveAttribute("data-active", "false");
|
||||
await expect(sidebarProject.locator("svg").first()).toHaveCSS(
|
||||
"opacity",
|
||||
"0.8",
|
||||
);
|
||||
await expect(sidebarProject.locator('[data-sidebar="menu-label"]')).toHaveCSS(
|
||||
"opacity",
|
||||
"0.8",
|
||||
);
|
||||
|
||||
await sidebarProject.click();
|
||||
await expect(projectsOverview).toHaveAttribute("data-active", "false");
|
||||
await expect(sidebarProject).toHaveAttribute("data-active", "true");
|
||||
await expect(projectsOverview).toHaveAttribute("data-active", "true");
|
||||
await expect(sidebarProject).toHaveAttribute("data-active", "false");
|
||||
await expect(sidebarProject).toHaveAttribute("aria-expanded", "false");
|
||||
});
|
||||
|
||||
test("collapsed sidebar leaves a balanced Projects surface gutter", async ({
|
||||
@@ -1596,7 +1614,9 @@ test("project overview presents collapsible context beside grouped activity", as
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("project overview content header toggles agent chat", async ({ page }) => {
|
||||
test("project overview chrome toggles a detached resizable agent chat", async ({
|
||||
page,
|
||||
}) => {
|
||||
await enableProjectsFeature(page);
|
||||
await installMockBridge(page);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
@@ -1613,25 +1633,36 @@ test("project overview content header toggles agent chat", async ({ page }) => {
|
||||
"Chat with an agent about Reviews",
|
||||
);
|
||||
await expect(overviewChat).toHaveAttribute("aria-pressed", "false");
|
||||
const [contentBox, chatBox] = await Promise.all([
|
||||
page.getByTestId("projects-overview-content-pod").boundingBox(),
|
||||
const overviewInfo = page.getByTestId("projects-overview-context-toggle");
|
||||
const [chatBox, infoBox] = await Promise.all([
|
||||
overviewChat.boundingBox(),
|
||||
overviewInfo.boundingBox(),
|
||||
]);
|
||||
expect(contentBox).not.toBeNull();
|
||||
expect(chatBox).not.toBeNull();
|
||||
expect(infoBox).not.toBeNull();
|
||||
expect(
|
||||
(contentBox?.x ?? 0) +
|
||||
(contentBox?.width ?? 0) -
|
||||
((chatBox?.x ?? 0) + (chatBox?.width ?? 0)),
|
||||
).toBeLessThanOrEqual(20);
|
||||
(infoBox?.x ?? 0) - ((chatBox?.x ?? 0) + (chatBox?.width ?? 0)),
|
||||
).toBeLessThanOrEqual(4);
|
||||
const contextRail = page.getByTestId("projects-overview-context-rail");
|
||||
await expect(overviewInfo).toHaveAttribute("aria-pressed", "true");
|
||||
await overviewInfo.click();
|
||||
await expect(overviewInfo).toHaveAttribute("aria-pressed", "false");
|
||||
await expect(contextRail).toHaveCSS("width", "0px");
|
||||
await overviewChat.click();
|
||||
await expect(overviewChat).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(overviewInfo).toHaveAttribute("aria-pressed", "false");
|
||||
await expect(contextRail).toHaveCSS("width", "0px");
|
||||
await expect(overviewChat).toBeVisible();
|
||||
const chatRail = page.getByTestId("projects-overview-agent-rail");
|
||||
await expect(chatRail.getByTestId("project-agent-chat-panel")).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("projects-overview-content-pod")
|
||||
.getByTestId("project-agent-chat-panel"),
|
||||
).toBeVisible();
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
chatRail.getByTestId("projects-overview-agent-rail-panel"),
|
||||
).toHaveCSS("border-radius", "16px");
|
||||
const agentHeader = page.getByTestId("project-agent-context");
|
||||
await expect
|
||||
.poll(() =>
|
||||
@@ -1640,11 +1671,46 @@ test("project overview content header toggles agent chat", async ({ page }) => {
|
||||
),
|
||||
)
|
||||
.not.toBe("none");
|
||||
await expect(page.getByTestId("projects-overview-agent-rail")).toHaveCount(0);
|
||||
await expect(contextRail).toHaveAttribute("aria-hidden", "true");
|
||||
await overviewInfo.click();
|
||||
await expect(overviewInfo).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(contextRail).toHaveAttribute("aria-hidden", "false");
|
||||
await expect(
|
||||
page.getByTestId("projects-overview-context-panel"),
|
||||
).toBeVisible();
|
||||
const chatPanel = page.getByTestId("project-agent-chat-panel");
|
||||
const resizeHandle = chatPanel.getByTestId(
|
||||
"right-auxiliary-pane-resize-handle",
|
||||
);
|
||||
const [initialPanelBox, resizeHandleBox] = await Promise.all([
|
||||
chatPanel.boundingBox(),
|
||||
resizeHandle.boundingBox(),
|
||||
]);
|
||||
expect(initialPanelBox).not.toBeNull();
|
||||
expect(resizeHandleBox).not.toBeNull();
|
||||
const resizeStartX =
|
||||
(resizeHandleBox?.x ?? 0) + (resizeHandleBox?.width ?? 0) / 2;
|
||||
const resizeStartY =
|
||||
(resizeHandleBox?.y ?? 0) + (resizeHandleBox?.height ?? 0) / 2;
|
||||
await resizeHandle.dispatchEvent("pointerdown", {
|
||||
button: 0,
|
||||
buttons: 1,
|
||||
clientX: resizeStartX,
|
||||
clientY: resizeStartY,
|
||||
pointerId: 1,
|
||||
pointerType: "mouse",
|
||||
});
|
||||
await expect(chatRail).toHaveAttribute("data-resizing", "true");
|
||||
await page.mouse.move(resizeStartX - 32, resizeStartY);
|
||||
await expect
|
||||
.poll(() =>
|
||||
chatPanel.evaluate((element) =>
|
||||
Math.round(element.getBoundingClientRect().width),
|
||||
),
|
||||
)
|
||||
.toBe(Math.round(initialPanelBox?.width ?? 0) + 32);
|
||||
await page.mouse.up();
|
||||
await expect(chatRail).toHaveAttribute("data-resizing", "false");
|
||||
await chatPanel.getByTestId("message-input").fill("Summarize these reviews");
|
||||
await chatPanel.getByTestId("message-input").press("Enter");
|
||||
const readSentContent = () =>
|
||||
@@ -1674,6 +1740,7 @@ test("project overview content header toggles agent chat", async ({ page }) => {
|
||||
await overviewChat.click();
|
||||
await expect(overviewChat).toHaveAttribute("aria-pressed", "false");
|
||||
await expect(page.getByTestId("project-agent-chat-panel")).not.toBeVisible();
|
||||
await expect(chatRail).toHaveCSS("width", "0px");
|
||||
await expect(
|
||||
page.getByTestId("projects-overview-context-panel"),
|
||||
).toBeVisible();
|
||||
|
||||
@@ -1105,9 +1105,24 @@ test("projects v3 work-item list metadata", async ({ page }) => {
|
||||
);
|
||||
|
||||
await page.getByTestId("projects-section-projects").click();
|
||||
await expectSinglePrimaryTextColumn(
|
||||
page.getByTestId(/^project-row-/).first(),
|
||||
await expect(page.getByTestId("projects-list-header")).toHaveCSS(
|
||||
"border-left-width",
|
||||
"0px",
|
||||
);
|
||||
const projectRow = page.getByTestId(/^project-row-/).first();
|
||||
await expectSinglePrimaryTextColumn(projectRow);
|
||||
const [projectTitleBox, repositoryCountBox] = await Promise.all([
|
||||
projectRow.locator('[data-projects-text-priority="primary"]').boundingBox(),
|
||||
projectRow.getByTestId("projects-row-context").boundingBox(),
|
||||
]);
|
||||
expect(projectTitleBox).not.toBeNull();
|
||||
expect(repositoryCountBox).not.toBeNull();
|
||||
expect(
|
||||
Math.round(
|
||||
(repositoryCountBox?.x ?? 0) -
|
||||
((projectTitleBox?.x ?? 0) + (projectTitleBox?.width ?? 0)),
|
||||
),
|
||||
).toBe(12);
|
||||
|
||||
await page.getByTestId("projects-section-repositories").click();
|
||||
await expectSinglePrimaryTextColumn(
|
||||
|
||||
Reference in New Issue
Block a user