diff --git a/desktop/src/app/AppTopChrome.tsx b/desktop/src/app/AppTopChrome.tsx index dd0fabe8a..84b017b31 100644 --- a/desktop/src/app/AppTopChrome.tsx +++ b/desktop/src/app/AppTopChrome.tsx @@ -101,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 + } >
diff --git a/desktop/src/features/projects/lib/projectExternalUrl.test.mjs b/desktop/src/features/projects/lib/projectExternalUrl.test.mjs new file mode 100644 index 000000000..83a114ad1 --- /dev/null +++ b/desktop/src/features/projects/lib/projectExternalUrl.test.mjs @@ -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"); +}); diff --git a/desktop/src/features/projects/lib/projectExternalUrl.ts b/desktop/src/features/projects/lib/projectExternalUrl.ts new file mode 100644 index 000000000..86427722d --- /dev/null +++ b/desktop/src/features/projects/lib/projectExternalUrl.ts @@ -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; + } +} diff --git a/desktop/src/features/projects/ui/DiscussionChannels.tsx b/desktop/src/features/projects/ui/DiscussionChannels.tsx index 344e6be09..d48521f34 100644 --- a/desktop/src/features/projects/ui/DiscussionChannels.tsx +++ b/desktop/src/features/projects/ui/DiscussionChannels.tsx @@ -34,6 +34,7 @@ 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"; @@ -491,11 +492,7 @@ export function DiscussionChannelsPanel({ const profiles = profilesQuery.data?.profiles; if (isLoading) { - return ( -

- Searching channel discussions… -

- ); + return ; } if (channels.length === 0) { return ( diff --git a/desktop/src/features/projects/ui/ProjectConversationPanel.tsx b/desktop/src/features/projects/ui/ProjectConversationPanel.tsx index 9d4ab63d1..59f3264b0 100644 --- a/desktop/src/features/projects/ui/ProjectConversationPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectConversationPanel.tsx @@ -40,6 +40,7 @@ export function ProjectConversationPanel({ onClose, onResetWidth, onResizeStart, + sharedHeaderBackdrop, widthPx, }: { canResetWidth: boolean; @@ -47,6 +48,7 @@ export function ProjectConversationPanel({ onClose: () => void; onResetWidth: () => void; onResizeStart: (event: React.PointerEvent) => void; + sharedHeaderBackdrop?: boolean; widthPx: number; }) { const { goChannel } = useAppNavigation(); @@ -204,7 +206,7 @@ export function ProjectConversationPanel({ showBackButton: false, splitPaneClamp: false, testId: isOverlay ? "project-conversation-panel" : "message-thread-panel", - transparentChrome: false, + transparentChrome: sharedHeaderBackdrop, }; const handleSend = React.useCallback( diff --git a/desktop/src/features/projects/ui/ProjectConversationPanelContext.tsx b/desktop/src/features/projects/ui/ProjectConversationPanelContext.tsx index 2de4a1047..2bb73d270 100644 --- a/desktop/src/features/projects/ui/ProjectConversationPanelContext.tsx +++ b/desktop/src/features/projects/ui/ProjectConversationPanelContext.tsx @@ -86,6 +86,7 @@ export function ProjectConversationPanelController({ onClose={() => setHit(null)} onResetWidth={onResetWidth} onResizeStart={onResizeStart} + sharedHeaderBackdrop={sharedHeaderBackdrop} widthPx={widthPx} /> ) : ( diff --git a/desktop/src/features/projects/ui/ProjectDetailChrome.tsx b/desktop/src/features/projects/ui/ProjectDetailChrome.tsx index 1c41f9278..843fb890f 100644 --- a/desktop/src/features/projects/ui/ProjectDetailChrome.tsx +++ b/desktop/src/features/projects/ui/ProjectDetailChrome.tsx @@ -42,7 +42,10 @@ export function ProjectDetailChrome({ >
{createPullRequestAction && createPullRequestOpen ? ( diff --git a/desktop/src/features/projects/ui/ProjectsActivityFeed.tsx b/desktop/src/features/projects/ui/ProjectsActivityFeed.tsx index 2a540c7c3..2e81ffd61 100644 --- a/desktop/src/features/projects/ui/ProjectsActivityFeed.tsx +++ b/desktop/src/features/projects/ui/ProjectsActivityFeed.tsx @@ -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 { @@ -431,19 +432,7 @@ export function ProjectsActivityFeed(props: ProjectsActivityFeedProps) { const items = buildActivityItems(props); if (props.isLoading && items.length === 0) { - return ( -
- {["first", "second", "third"].map((key) => ( -
- ))} -
- ); + return ; } if (items.length === 0) { diff --git a/desktop/src/features/projects/ui/ProjectsIssuesList.tsx b/desktop/src/features/projects/ui/ProjectsIssuesList.tsx index 28f511087..4bbf8fe38 100644 --- a/desktop/src/features/projects/ui/ProjectsIssuesList.tsx +++ b/desktop/src/features/projects/ui/ProjectsIssuesList.tsx @@ -19,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"; @@ -318,16 +319,7 @@ export function ProjectsIssuesList({ viewMode, }: ProjectsIssuesListProps) { if (isLoading) { - return ( -
- Loading tasks... -
- ); + return ; } const loadNotice = ( diff --git a/desktop/src/features/projects/ui/ProjectsPullRequestsList.tsx b/desktop/src/features/projects/ui/ProjectsPullRequestsList.tsx index fa978f84b..70a36d6ed 100644 --- a/desktop/src/features/projects/ui/ProjectsPullRequestsList.tsx +++ b/desktop/src/features/projects/ui/ProjectsPullRequestsList.tsx @@ -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"; @@ -317,16 +318,7 @@ export function ProjectsPullRequestsList({ viewMode, }: ProjectsPullRequestsListProps) { if (isLoading) { - return ( -
- Loading reviews... -
- ); + return ; } const loadNotice = ( diff --git a/desktop/src/features/projects/ui/projectPanelStyles.ts b/desktop/src/features/projects/ui/projectPanelStyles.ts index b2557e82c..d119b5df4 100644 --- a/desktop/src/features/projects/ui/projectPanelStyles.ts +++ b/desktop/src/features/projects/ui/projectPanelStyles.ts @@ -7,7 +7,7 @@ export const PROJECT_PANEL_ACTION_BUTTON_CLASS = * they read as one consistent control family in the workspace header. */ export const PROJECT_PICKER_TRIGGER_CLASS = - "h-7 max-w-full shrink-0 gap-1.5 rounded-md px-3 text-sm font-medium hover:border-input"; + "h-7 min-w-0 max-w-full gap-1.5 rounded-md px-3 text-sm font-medium hover:border-input"; /** Bordered shell that lets the project page surface show through. */ export const PROJECT_DETAIL_PANEL_CLASS = diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 227572feb..779e450c3 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -597,7 +597,7 @@ export function AppSidebar({ ) : null} diff --git a/desktop/src/features/sidebar/ui/SidebarProjectsSection.test.mjs b/desktop/src/features/sidebar/ui/SidebarProjectsSection.test.mjs index 2a733ae12..c16c16270 100644 --- a/desktop/src/features/sidebar/ui/SidebarProjectsSection.test.mjs +++ b/desktop/src/features/sidebar/ui/SidebarProjectsSection.test.mjs @@ -3,12 +3,54 @@ import { test } from "node:test"; import { listSidebarProjects, + readSidebarProjectExpansion, selectedProjectRouteId, + writeSidebarProjectExpansion, } from "./listSidebarProjects.ts"; const OWNER = "a".repeat(64); const VIEWER = "b".repeat(64); +test("project expansion persists independently per relay and viewer", () => { + const values = new Map(); + const previousLocalStorage = globalThis.localStorage; + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => values.set(key, value), + }, + }); + try { + writeSidebarProjectExpansion( + { "project:one": true, "project:two": false }, + "https://relay.example", + VIEWER, + ); + assert.deepEqual( + readSidebarProjectExpansion("https://relay.example", VIEWER), + { "project:one": true, "project:two": false }, + ); + assert.deepEqual( + readSidebarProjectExpansion("https://other.example", VIEWER), + {}, + ); + assert.deepEqual( + readSidebarProjectExpansion("https://relay.example", OWNER), + {}, + ); + } finally { + if (previousLocalStorage === undefined) { + delete globalThis.localStorage; + } else { + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: previousLocalStorage, + }); + } + } +}); + function makeProject(overrides = {}) { return { createdAt: 0, diff --git a/desktop/src/features/sidebar/ui/SidebarProjectsSection.tsx b/desktop/src/features/sidebar/ui/SidebarProjectsSection.tsx index 1190d803b..3f288fc50 100644 --- a/desktop/src/features/sidebar/ui/SidebarProjectsSection.tsx +++ b/desktop/src/features/sidebar/ui/SidebarProjectsSection.tsx @@ -89,11 +89,14 @@ import { } from "@/features/sidebar/ui/sidebarSectionStyles"; import { listSidebarProjects, + readSidebarProjectExpansion, readSidebarProjectsFilter, readSidebarProjectsSort, selectedProjectRouteId, + type SidebarProjectExpansionState, type SidebarProjectsFilter, type SidebarProjectsSort, + writeSidebarProjectExpansion, writeSidebarProjectsFilter, writeSidebarProjectsSort, } from "@/features/sidebar/ui/listSidebarProjects"; @@ -144,6 +147,10 @@ function SidebarProjectsSectionContent() { const [sort, setSort] = React.useState( readSidebarProjectsSort, ); + const [projectExpansion, setProjectExpansion] = + React.useState(() => + readSidebarProjectExpansion(relayOrigin, currentPubkey), + ); const [addedProjectAddresses, setAddedProjectAddresses] = React.useState< string[] >(() => readProjectSidebarMembership(relayOrigin, currentPubkey)); @@ -160,6 +167,11 @@ function SidebarProjectsSectionContent() { return () => globalThis.removeEventListener(PROJECT_SIDEBAR_MEMBERSHIP_EVENT, refresh); }, [currentPubkey, relayOrigin]); + React.useEffect(() => { + setProjectExpansion( + readSidebarProjectExpansion(relayOrigin, currentPubkey), + ); + }, [currentPubkey, relayOrigin]); const addedProjectAddressSet = React.useMemo( () => new Set(addedProjectAddresses), [addedProjectAddresses], @@ -175,6 +187,23 @@ function SidebarProjectsSectionContent() { }), [addedProjectAddressSet, currentPubkey, filter, projectsQuery.data, sort], ); + React.useEffect(() => { + if (!routeProjectId) return; + const selectedProject = projects.find((project) => + projectMatchesRouteId(project, routeProjectId), + ); + if ( + !selectedProject || + projectExpansion[selectedProject.projectAddress] !== undefined + ) { + return; + } + setProjectExpansion((current) => { + const next = { ...current, [selectedProject.projectAddress]: true }; + writeSidebarProjectExpansion(next, relayOrigin, currentPubkey); + return next; + }); + }, [currentPubkey, projectExpansion, projects, relayOrigin, routeProjectId]); const handleFilterChange = (next: SidebarProjectsFilter) => { setFilter(next); @@ -184,6 +213,13 @@ function SidebarProjectsSectionContent() { setSort(next); writeSidebarProjectsSort(next); }; + const setProjectExpanded = (project: Project, expanded: boolean) => { + setProjectExpansion((current) => { + const next = { ...current, [project.projectAddress]: expanded }; + writeSidebarProjectExpansion(next, relayOrigin, currentPubkey); + return next; + }); + }; const handleAdd = (project: Project) => { addProjectToSidebar(project.projectAddress, relayOrigin, currentPubkey); }; @@ -282,6 +318,8 @@ function SidebarProjectsSectionContent() { const selectedRepository = isActive ? selectProjectRepository(project, routeRepositoryId) : null; + const isExpanded = + projectExpansion[project.projectAddress] ?? isActive; return ( @@ -292,12 +330,20 @@ function SidebarProjectsSectionContent() { )} deleteDisabled={deleteProjectMutation.isPending} isActive={isActive} + isExpanded={isExpanded} onDelete={() => setProjectToDelete(project)} - onOpen={() => goProject(project.id)} + onOpen={() => { + if (isActive) { + setProjectExpanded(project, !isExpanded); + return; + } + setProjectExpanded(project, true); + void goProject(project.id); + }} onRemove={() => handleRemove(project)} project={project} /> - {isActive + {isExpanded ? project.repositories.map((repository) => ( void; onOpen: () => void; onRemove: () => void; project: Project; }) { const shareLink = projectShareLink(project); - const ProjectIcon = isActive ? FolderOpen : Folders; + const ProjectIcon = isExpanded ? FolderOpen : Folders; return ( ; + +function expandedProjectsStorageKey( + relayOrigin: string | null, + currentPubkey?: string, +) { + return `${SIDEBAR_PROJECTS_EXPANDED_KEY}:${encodeURIComponent(relayOrigin ?? "unknown")}:${currentPubkey ?? "anonymous"}`; +} + +export function readSidebarProjectExpansion( + relayOrigin: string | null, + currentPubkey?: string, +): SidebarProjectExpansionState { + try { + const value = globalThis.localStorage?.getItem( + expandedProjectsStorageKey(relayOrigin, currentPubkey), + ); + if (!value) return {}; + const parsed: unknown = JSON.parse(value); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) + return {}; + return Object.fromEntries( + Object.entries(parsed).filter((entry): entry is [string, boolean] => { + return typeof entry[1] === "boolean"; + }), + ); + } catch { + return {}; + } +} + +export function writeSidebarProjectExpansion( + expansion: SidebarProjectExpansionState, + relayOrigin: string | null, + currentPubkey?: string, +) { + try { + globalThis.localStorage?.setItem( + expandedProjectsStorageKey(relayOrigin, currentPubkey), + JSON.stringify(expansion), + ); + } catch { + // Persistence is best-effort; the in-memory toggle still works. + } +} export function selectedProjectRouteId(pathname: string): string | undefined { if (!pathname.startsWith("/projects/")) return undefined; diff --git a/desktop/src/shared/ui/BuzzLoadingState.tsx b/desktop/src/shared/ui/BuzzLoadingState.tsx new file mode 100644 index 000000000..18e67a4e8 --- /dev/null +++ b/desktop/src/shared/ui/BuzzLoadingState.tsx @@ -0,0 +1,34 @@ +import { cn } from "@/shared/lib/cn"; +import BuzzLogoAnimation from "@/shared/ui/buzz-logo/BuzzLogoAnimation"; + +/** Centered, low-emphasis loading state for page and panel fetches. */ +export function BuzzLoadingState({ + className, + fill = false, + label = "Loading", +}: { + className?: string; + fill?: boolean; + label?: string; +}) { + return ( +
+ +
+ ); +} diff --git a/desktop/src/shared/ui/ViewLoadingFallback.tsx b/desktop/src/shared/ui/ViewLoadingFallback.tsx index 5fe435949..8a1322917 100644 --- a/desktop/src/shared/ui/ViewLoadingFallback.tsx +++ b/desktop/src/shared/ui/ViewLoadingFallback.tsx @@ -1,4 +1,5 @@ import { Card } from "@/shared/ui/card"; +import { BuzzLoadingState } from "@/shared/ui/BuzzLoadingState"; import { Skeleton } from "@/shared/ui/skeleton"; import { cn } from "@/shared/lib/cn"; import { channelChrome } from "@/shared/layout/chromeLayout"; @@ -402,7 +403,9 @@ export function ViewLoadingFallback({ {shouldShowChannelHeader ? : null} {kind === "agents" ? : null} {kind === "workflows" ? : null} - {kind === "projects" ? : null} + {kind === "projects" ? ( + + ) : null} {kind === "channel" ? ( ) : null} diff --git a/desktop/src/shared/ui/buzz-logo/buzz-logo-animation.css b/desktop/src/shared/ui/buzz-logo/buzz-logo-animation.css index dce20a847..f33c51b80 100644 --- a/desktop/src/shared/ui/buzz-logo/buzz-logo-animation.css +++ b/desktop/src/shared/ui/buzz-logo/buzz-logo-animation.css @@ -56,6 +56,21 @@ animation: buzz-logo-pulse 1.8s ease-in-out infinite; } +@keyframes buzz-logo-scale-pulse { + 0%, + 100% { + transform: scale(0.94); + } + 50% { + transform: scale(1.06); + } +} + +.buzz-logo--scale-pulse .buzz-logo__mark { + animation: buzz-logo-scale-pulse 1.8s ease-in-out infinite; + transform-origin: center; +} + @media (prefers-reduced-motion: reduce) { .buzz-logo animate { display: none; @@ -65,4 +80,9 @@ animation: none; opacity: 0.8; } + + .buzz-logo--scale-pulse .buzz-logo__mark { + animation: none; + transform: none; + } } diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index a60c184c2..e5b9445e6 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -192,6 +192,8 @@ type E2eConfig = { projectAccessChannelId?: string; /** Make remote project snapshots fail with this git-facing message. */ projectRepoSnapshotError?: string; + /** Delay remote repository snapshots so project loading UI is observable. */ + projectRepoSnapshotDelayMs?: number; /** Builderlab account returned by hosted-community onboarding. Null/omitted = signed out. */ builderlabAuth?: { email?: string; @@ -1260,6 +1262,8 @@ declare global { __BUZZ_E2E_REJECT_PROJECT_QUERY_KINDS__?: number[]; /** Captured aggregate project-history filters for request-count assertions. */ __BUZZ_E2E_PROJECT_QUERY_FILTERS__?: MockFilter[]; + /** Optional local repository snapshot returned for project branch tests. */ + __BUZZ_E2E_PROJECT_LOCAL_REPO_SNAPSHOT__?: unknown; __BUZZ_E2E_PROJECT_REPO_SYNC_STATUS__?: { local_path: string | null; local_branch: string | null; @@ -5364,6 +5368,7 @@ const MOCK_PROJECT_SEEDS = [ description: "Relay, desktop, and mobile clients for the Buzz community platform.", cloneUrl: `${DEFAULT_RELAY_HTTP_URL}/git/${MOCK_IDENTITY_PUBKEY}/buzz`, + webUrl: null, owner: MOCK_IDENTITY_PUBKEY, contributors: [ALICE_PUBKEY, BOB_PUBKEY, CHARLIE_PUBKEY], activityLevel: 4, @@ -5373,6 +5378,7 @@ const MOCK_PROJECT_SEEDS = [ name: "relay-tools", description: "Operator tooling and admin CLI for relay deployments.", cloneUrl: "https://github.com/block/relay-tools.git", + webUrl: "https://github.com/block/relay-tools", owner: ALICE_PUBKEY, contributors: [MOCK_IDENTITY_PUBKEY, BOB_PUBKEY], activityLevel: 2, @@ -5382,6 +5388,7 @@ const MOCK_PROJECT_SEEDS = [ name: "design-system", description: "Shared UI tokens, typography ramps, and component library.", cloneUrl: `${DEFAULT_RELAY_HTTP_URL}/git/${BOB_PUBKEY}/design-system`, + webUrl: null, owner: BOB_PUBKEY, contributors: [ALICE_PUBKEY], activityLevel: 1, @@ -5485,6 +5492,7 @@ function buildMockProjectEvents(): RelayEvent[] { "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50", ], ["clone", seed.cloneUrl], + ...(seed.webUrl ? [["web", seed.webUrl]] : []), ...seed.contributors.map((pubkey) => ["p", pubkey]), ], owner, @@ -11543,6 +11551,14 @@ export function maybeInstallE2eTauriMocks() { // viewer-identity avatar attribution is exercised in e2e. return { name: "Thomas P", email: "thomasp@example.com" }; case "get_project_repo_snapshot": + if (activeConfig?.mock?.projectRepoSnapshotDelayMs) { + await new Promise((resolve) => + window.setTimeout( + resolve, + activeConfig.mock?.projectRepoSnapshotDelayMs, + ), + ); + } if (activeConfig?.mock?.projectRepoSnapshotError) { throw new Error(activeConfig.mock.projectRepoSnapshotError); } @@ -11641,7 +11657,7 @@ export function maybeInstallE2eTauriMocks() { ], }; case "get_project_local_repo_snapshot": - return null; + return window.__BUZZ_E2E_PROJECT_LOCAL_REPO_SNAPSHOT__ ?? null; case "get_project_repo_diff": return { additions: 27, diff --git a/desktop/tests/e2e/project-commit-detail.spec.ts b/desktop/tests/e2e/project-commit-detail.spec.ts index eec6b919d..cc6b6f4e9 100644 --- a/desktop/tests/e2e/project-commit-detail.spec.ts +++ b/desktop/tests/e2e/project-commit-detail.spec.ts @@ -437,8 +437,74 @@ test("multi-repository projects switch the active repository", async ({ const relayToolsRepository = page.getByTestId( "sidebar-project-repository-relay-tools", ); + const projectRow = page.getByTestId("sidebar-project-buzz"); + await expect(projectRow).toHaveAttribute("aria-expanded", "true"); await expect(primaryRepository).toHaveAttribute("data-active", "true"); await expect(relayToolsRepository).toBeVisible(); + + await projectRow.click(); + await expect(projectRow).toHaveAttribute("aria-expanded", "false"); + await expect(relayToolsRepository).toBeHidden(); + + await page.reload({ waitUntil: "domcontentloaded" }); + await addProjectToSidebar(page, "buzz"); + await expect(projectRow).toHaveAttribute("aria-expanded", "false"); + await expect(relayToolsRepository).toBeHidden(); + + await projectRow.click(); + await expect(projectRow).toHaveAttribute("aria-expanded", "true"); + await expect(relayToolsRepository).toBeVisible(); + + await page.getByTestId("channel-general").click(); + await expect(projectRow).toHaveAttribute("aria-expanded", "true"); + await expect(relayToolsRepository).toBeVisible(); + const sidebarScrollContent = page.getByTestId("sidebar-scroll-content"); + const channelSidebarMetrics = await sidebarScrollContent.evaluate( + (element) => { + const bounds = element.getBoundingClientRect(); + return { + clientWidth: element.clientWidth, + left: bounds.left, + top: bounds.top, + width: bounds.width, + }; + }, + ); + await projectRow.click(); + await expect(page).toHaveURL(/\/projects\//); + await expect(relayToolsRepository).toBeVisible(); + const projectSidebarMetrics = await sidebarScrollContent.evaluate( + (element) => { + const bounds = element.getBoundingClientRect(); + return { + clientWidth: element.clientWidth, + left: bounds.left, + top: bounds.top, + width: bounds.width, + }; + }, + ); + expect(projectSidebarMetrics).toEqual(channelSidebarMetrics); + + await projectRow.click(); + await expect(projectRow).toHaveAttribute("aria-expanded", "false"); + await page.getByTestId("channel-general").click(); + const sidebarScroller = page.locator('[data-sidebar="content"]'); + const anchoredScrollTop = await sidebarScroller.evaluate((element) => { + element.scrollTop = Math.min( + 20, + Math.max(0, element.scrollHeight - element.clientHeight), + ); + return element.scrollTop; + }); + await projectRow.click(); + await expect(page).toHaveURL(/\/projects\//); + await expect(projectRow).toHaveAttribute("aria-expanded", "true"); + await expect + .poll(() => + sidebarScroller.evaluate((element) => Math.round(element.scrollTop)), + ) + .toBe(Math.round(anchoredScrollTop)); await waitForAnimations(page); await page.screenshot({ path: `${SHOTS}/04-multi-repository-picker.png`, diff --git a/desktop/tests/e2e/project-pr-review.spec.ts b/desktop/tests/e2e/project-pr-review.spec.ts index 35060f262..a1a91b1db 100644 --- a/desktop/tests/e2e/project-pr-review.spec.ts +++ b/desktop/tests/e2e/project-pr-review.spec.ts @@ -32,6 +32,17 @@ async function openBuzzProject(page: import("@playwright/test").Page) { await projectEntry.click(); } +async function addProjectToSidebar( + page: import("@playwright/test").Page, + dtag: string, +) { + await page.getByTestId("sidebar-projects-section-label").hover(); + await page.getByTestId("sidebar-projects-create").click(); + const browser = page.getByTestId("project-browser-dialog"); + await browser.getByRole("searchbox", { name: "Search projects" }).fill(dtag); + await browser.getByTestId(`project-browser-result-${dtag}`).click(); +} + test("same-second request changes supersedes approval", async ({ page }) => { await enableProjectsFeature(page); await page.addInitScript(() => { @@ -1276,6 +1287,123 @@ test("project branches can be deleted but the default branch cannot", async ({ expect(commands).toContain("delete_project_remote_branch"); }); +test("external repositories stay on local source after a branch round trip", async ({ + page, +}) => { + await enableProjectsFeature(page); + await page.addInitScript(() => { + const commit = "0123456789abcdef0123456789abcdef01234567"; + const localBranch = + "wintermute/entity-link-recipient-cards-with-a-long-branch-name"; + window.sessionStorage.setItem( + "buzz-e2e-project-branches", + JSON.stringify({ "relay-tools": { [localBranch]: commit } }), + ); + window.__BUZZ_E2E_PROJECT_REPO_SYNC_STATUS__ = { + local_path: "/tmp/buzz/REPOS/relay-tools", + local_branch: localBranch, + local_branches: ["main", localBranch], + local_head: commit, + local_short_head: commit.slice(0, 7), + remote_branch: localBranch, + remote_head: commit, + remote_short_head: commit.slice(0, 7), + merge_base: commit, + ahead_count: 0, + behind_count: 0, + has_uncommitted_changes: false, + has_untracked_files: false, + can_push: false, + push_block_reason: "Local branch is already pushed.", + can_pull: false, + pull_block_reason: "Local branch is up to date.", + }; + window.__BUZZ_E2E_PROJECT_LOCAL_REPO_SNAPSHOT__ = { + path: "/tmp/buzz/REPOS/relay-tools", + snapshot: { + latest_commit: null, + commits: [], + contributors: [], + files: [ + { + path: "README.md", + kind: "text", + size: 21, + preview_content: "# Local branch README", + last_changed_at: null, + latest_commit: null, + }, + ], + }, + }; + }); + await installMockBridge(page); + await page.goto("/", { waitUntil: "domcontentloaded" }); + await addProjectToSidebar(page, "buzz"); + await page.getByTestId("sidebar-project-repository-relay-tools").click(); + + await expect( + page.getByRole("heading", { name: "Local branch README" }), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Local", exact: true }), + ).toBeVisible(); + await expect( + page.getByRole("link", { name: "Open", exact: true }), + ).toHaveAttribute("href", "https://github.com/block/relay-tools/tree/main"); + + await page.getByRole("button", { name: /main/ }).click(); + await page + .getByRole("menuitemradio", { + name: "wintermute/entity-link-recipient-cards-with-a-long-branch-name", + }) + .click(); + const branchTrigger = page.getByTestId("project-repository-branch-trigger"); + await expect( + page.getByRole("button", { + name: /wintermute\/entity-link-recipient-cards-with-a-long-branch-name/, + }), + ).toBeVisible(); + await expect + .poll(() => + branchTrigger.evaluate( + (element) => element.scrollWidth <= element.clientWidth, + ), + ) + .toBe(true); + await expect + .poll(() => + branchTrigger + .locator("span") + .evaluate((element) => element.scrollWidth > element.clientWidth), + ) + .toBe(true); + await expect( + page.getByRole("button", { name: "Local", exact: true }), + ).toBeVisible(); + await expect( + page.getByRole("link", { name: "Open", exact: true }), + ).toHaveAttribute( + "href", + "https://github.com/block/relay-tools/tree/wintermute%2Fentity-link-recipient-cards-with-a-long-branch-name", + ); + + await branchTrigger.click(); + await page.getByRole("menuitemradio", { name: "main" }).click(); + + await expect(page.getByRole("button", { name: /main/ })).toBeVisible(); + await expect( + page.getByRole("button", { name: "Local", exact: true }), + ).toBeVisible(); + await expect( + page.getByRole("heading", { name: "Local branch README" }), + ).toBeVisible(); + await expect( + page.getByRole("link", { name: "Open", exact: true }), + ).toHaveAttribute("href", "https://github.com/block/relay-tools/tree/main"); + await expect(page.getByText("Code hosted on github.com")).toHaveCount(0); +}); + test("pushed local branch can open a pull request", async ({ page }) => { await enableProjectsFeature(page); await page.addInitScript(() => { diff --git a/desktop/tests/e2e/projects-v3-screenshots.spec.ts b/desktop/tests/e2e/projects-v3-screenshots.spec.ts index 316fa64a7..ac32f3231 100644 --- a/desktop/tests/e2e/projects-v3-screenshots.spec.ts +++ b/desktop/tests/e2e/projects-v3-screenshots.spec.ts @@ -113,6 +113,27 @@ test("restricted repositories keep event work visible and offer access help", as await expect(chatPanel.getByTestId("message-composer")).toBeVisible(); }); +test("repository pages show a centered Buzz loader while fetching", async ({ + page, +}) => { + await installMockBridge(page, { projectRepoSnapshotDelayMs: 750 }); + await openBuzzProject(page); + + const loader = page.getByTestId("buzz-loading-state"); + await expect(loader).toBeVisible(); + await expect( + loader.getByRole("img", { name: "Loading repository" }), + ).toBeVisible(); + const animatedMark = loader.locator(".buzz-logo__mark"); + await expect(animatedMark).toHaveCSS( + "animation-name", + "buzz-logo-scale-pulse", + ); + await expect(animatedMark).toHaveCSS("opacity", "1"); + await expect(loader).toHaveCSS("justify-content", "center"); + await expect(loader).toBeHidden({ timeout: 5_000 }); +}); + // Walks the Projects v3 workspace through its headline states so PR // screenshots capture distinct pixels per feature (overview box, tab-strip // plus, issue detail with inline copy link + avatar timeline, PR detail). @@ -177,6 +198,31 @@ test("projects v3 workspace screenshot states", async ({ page }) => { ).toBeGreaterThan(13); expect((await filesTab.boundingBox())?.height).toBe(28); await expect(repositoryActionsPanel).toBeVisible(); + const sharedHeaderBackdrop = page.getByTestId( + "project-shared-header-backdrop", + ); + await expect(sharedHeaderBackdrop).toBeVisible(); + await expect + .poll(() => + sharedHeaderBackdrop.evaluate( + (element) => getComputedStyle(element).backdropFilter, + ), + ) + .not.toBe("none"); + const [sharedHeaderBackdropBounds, repositoryActionsPanelBounds] = + await Promise.all([ + sharedHeaderBackdrop.boundingBox(), + repositoryActionsPanel.boundingBox(), + ]); + expect(sharedHeaderBackdropBounds).not.toBeNull(); + expect(repositoryActionsPanelBounds).not.toBeNull(); + expect( + (sharedHeaderBackdropBounds?.x ?? 0) + + (sharedHeaderBackdropBounds?.width ?? 0), + ).toBeGreaterThanOrEqual( + (repositoryActionsPanelBounds?.x ?? 0) + + (repositoryActionsPanelBounds?.width ?? 0), + ); const repositoryPanelTab = page.getByTestId( "project-right-panel-repository-tab", ); @@ -228,19 +274,6 @@ test("projects v3 workspace screenshot states", async ({ page }) => { (tabMenuHeaderBounds?.height ?? 0) - (agentContextBounds?.height ?? 0), ), ).toBeLessThanOrEqual(1); - const sharedHeaderBackdrop = page.getByTestId( - "project-shared-header-backdrop", - ); - await expect(sharedHeaderBackdrop).toBeVisible(); - await expect - .poll(() => - sharedHeaderBackdrop.evaluate( - (element) => getComputedStyle(element).backdropFilter, - ), - ) - .not.toBe("none"); - const sharedHeaderBackdropBounds = await sharedHeaderBackdrop.boundingBox(); - expect(sharedHeaderBackdropBounds).not.toBeNull(); expect(sharedHeaderBackdropBounds?.x).toBeLessThanOrEqual( tabMenuHeaderBounds?.x ?? 0, );