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:
Thomas Petersen
2026-08-17 22:07:36 -04:00
parent 44c16c4801
commit e2a74131be
13 changed files with 389 additions and 269 deletions
@@ -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();
});
+89 -22
View File
@@ -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(