feat(desktop): Projects follow-ups — access restrictions, fast loading, activity feed polish (#5073)

## Summary

Follow-up batch on the Projects overview (continues merged #1677):

- **Repository access restrictions** — repositories the viewer can't
reach are surfaced with a reason instead of failing silently.
Channel-ACL denials (which arrive as the same 404 as a missing repo, for
anti-enumeration) are re-classified using the repository's channel
binding and the viewer's memberships (`useRepositoryAccess.ts`,
`projectRepoAvailability.ts`).
- **Projects loads in seconds instead of minutes** — enumeration no
longer crawls every kind:5 deletion event on the relay. It fetches
project/repo announcements first, then queries deletions scoped to those
coordinates via chunked `#a` filters (3 queries instead of hundreds on
staging).
- **Activity feed layout polish** — bare event-type glyph beside the
headline (no badge circle), timeline spine runs through the avatars
connecting consecutive cards, linkable actor/project names are bold in
theme foreground, rounded hover state, alignment fixes.
- **Create button pinned** — the "+" create menu is pinned to the pane's
top-right corner (equal 16px insets) and no longer scrolls away with the
page header.
- **List controls as a table header** — the scope selector (left) and
sort + layout toggle (right) render as the first row of the list
container on the Projects/Repositories/PRs/Issues tabs; in card view the
identical bar stands alone with the cards below
(`ProjectsListHeaderBar.tsx`).
- **Repository rows show the git location** — subtitle is
`github.com/org/repo` for external repos or `owner/repo` (resolved
profile name) for Buzz-hosted ones, instead of repeating the project
name (`repositoryDisplayPath`).
- **Uniform work-item row heights** — issue rows previously ran the
author chip in inline flow, letting the 20px avatar grow the line box
~3px taller than PR rows; both lists now share the same flex subtitle.

📸 Screenshots: [feed layout / pinned
button](https://github.com/block/buzz/pull/5073#issuecomment-5214114069)
· [list header / repo subtitles / row
heights](https://github.com/block/buzz/pull/5073#issuecomment-5217480712).

Note: two empty `chore: retrigger CI` commits exist on the branch from
working around the Aug 6 GitHub Actions incident; happy to drop them
with a signoff rebase before undrafting if preferred. Latest `main` is
merged in (`a0cc35220`).

## Test plan

- [x] Desktop unit tests (4,493 pass after merging main), Biome, tsc
- [x] New unit tests for scoped deletion enumeration and repo
availability re-classification
- [x] New unit tests for `repositoryDisplayPath` (external, Buzz-hosted,
unresolvable)
- [x] Screenshot verification of feed layout, connector spine, and
pinned button (top + scrolled states) — posted to the PR
- [x] Screenshot verification of the list header row (list + card), repo
subtitles, and matching PR/issue row heights — posted to the PR
- [ ] Manual pass against staging (projects list load time,
restricted-repo states)

---------

Signed-off-by: Thomas Petersen <thomasp@squareup.com>
This commit is contained in:
thomaspblock
2026-08-07 19:23:35 +02:00
committed by GitHub
parent b2ac66cde8
commit fb73561e64
26 changed files with 1262 additions and 260 deletions

View File

@@ -788,7 +788,7 @@ export function AppShell() {
/>
) : null}
<SidebarProvider
className="relative z-10 min-h-0 flex-1 flex-col overflow-visible"
className="relative z-10 min-h-0 min-w-0 flex-1 flex-col overflow-visible"
data-testid="app-sidebar-layer"
>
<AppProfilePanelProvider>

View File

@@ -62,6 +62,7 @@ import {
} from "./projectModels";
import {
buildProjectsFromFetcher,
type FetchProjectEventsExhaustively,
fetchProjectEventsExhaustively,
} from "./projectEnumeration";
import { projectMatchesRouteId } from "./projectRoutes";
@@ -163,9 +164,7 @@ export function eventToProject(
}
export async function fetchProjects(
fetchExhaustively: (
kinds: number[],
) => Promise<RelayEvent[]> = fetchProjectEventsExhaustively,
fetchExhaustively: FetchProjectEventsExhaustively = fetchProjectEventsExhaustively,
): Promise<Project[]> {
// Delegates to `buildProjectsFromFetcher` in `projectEnumeration.ts`, which
// is the pure, Tauri-free core of this operation. That helper's javadoc

View File

@@ -5,6 +5,7 @@ import { deriveRelayCloneUrl, effectiveCloneUrls } from "./projectCloneUrl.ts";
import {
projectRepoHost,
projectRepoHostForProject,
repositoryDisplayPath,
} from "./projectRepoHost.ts";
const OWNER = "a".repeat(64);
@@ -104,3 +105,52 @@ test("projectRepoHostForProject recognizes an implicit relay repository", () =>
{ kind: "buzz" },
);
});
test("repositoryDisplayPath renders an external repo as host/path without .git", () => {
assert.equal(
repositoryDisplayPath(
{
cloneUrls: ["https://github.com/block/buzz.git"],
dtag: "buzz",
owner: OWNER,
},
ORIGIN,
),
"github.com/block/buzz",
);
});
test("repositoryDisplayPath renders a relay-hosted repo as owner/repo", () => {
assert.equal(
repositoryDisplayPath(
{ cloneUrls: [], dtag: "buzz", owner: OWNER },
ORIGIN,
"thomas",
),
"thomas/buzz",
);
});
test("repositoryDisplayPath falls back to a shortened pubkey owner", () => {
assert.equal(
repositoryDisplayPath(
{ cloneUrls: [], dtag: "buzz", owner: OWNER },
ORIGIN,
),
`${"a".repeat(8)}…/buzz`,
);
});
test("repositoryDisplayPath fails closed without a resolvable clone URL", () => {
assert.equal(
repositoryDisplayPath({ cloneUrls: [], dtag: "buzz", owner: OWNER }, null),
null,
);
assert.equal(
repositoryDisplayPath(
{ cloneUrls: ["not a URL"], dtag: "buzz", owner: OWNER },
ORIGIN,
),
null,
);
});

View File

@@ -1,7 +1,10 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { projectRepoUnavailableReason } from "./projectRepoAvailability.ts";
import {
projectRepoUnavailableReason,
refineRepoUnavailableReason,
} from "./projectRepoAvailability.ts";
test("classifies a missing repository", () => {
assert.equal(
@@ -47,3 +50,58 @@ test("keeps unmatched failures generic", () => {
"unknown",
);
});
test("refines a masked 404 into an unbound-repository reason", () => {
assert.equal(
refineRepoUnavailableReason({
reason: "missing",
repositoryChannelId: null,
memberChannelIds: ["11111111-1111-4111-8111-111111111111"],
}),
"unbound",
);
});
test("refines a masked 404 into an access-denied reason for non-members", () => {
assert.equal(
refineRepoUnavailableReason({
reason: "missing",
repositoryChannelId: "22222222-2222-4222-8222-222222222222",
memberChannelIds: ["11111111-1111-4111-8111-111111111111"],
}),
"access",
);
});
test("keeps missing when the viewer is a member of the bound channel", () => {
assert.equal(
refineRepoUnavailableReason({
reason: "missing",
repositoryChannelId: "11111111-1111-4111-8111-111111111111",
memberChannelIds: ["11111111-1111-4111-8111-111111111111"],
}),
"missing",
);
});
test("does not guess while memberships are still loading", () => {
assert.equal(
refineRepoUnavailableReason({
reason: "missing",
repositoryChannelId: "22222222-2222-4222-8222-222222222222",
memberChannelIds: null,
}),
"missing",
);
});
test("never rewrites non-missing reasons", () => {
assert.equal(
refineRepoUnavailableReason({
reason: "network",
repositoryChannelId: null,
memberChannelIds: [],
}),
"network",
);
});

View File

@@ -1,5 +1,7 @@
export type ProjectRepoUnavailableReason =
| "missing"
| "access"
| "unbound"
| "authentication"
| "network"
| "ref"
@@ -46,3 +48,33 @@ export function projectRepoUnavailableReason(
}
return "unknown";
}
/**
* The relay deliberately answers channel-ACL denials with the same 404 as a
* genuinely absent repository (SEC-005 anti-enumeration), so the git error
* alone cannot distinguish "never initialized" from "you have no access".
* The announcement events ARE visible to every relay member though, so the
* client can re-classify a `missing` result using the repository's
* `buzz-channel` binding and the viewer's own channel memberships:
*
* - no binding at all → `unbound` (the relay refuses access for everyone
* until the owner binds a channel)
* - bound to a channel the viewer is not a member of → `access`
* - bound to a channel the viewer IS a member of → keep `missing` (the
* repository truly has no git data pointer on the relay)
*
* `memberChannelIds === null` means memberships are still loading — the
* reason is left untouched rather than guessed.
*/
export function refineRepoUnavailableReason(input: {
reason: ProjectRepoUnavailableReason;
repositoryChannelId: string | null | undefined;
memberChannelIds: readonly string[] | null;
}): ProjectRepoUnavailableReason {
if (input.reason !== "missing") return input.reason;
if (!input.repositoryChannelId) return "unbound";
if (input.memberChannelIds === null) return input.reason;
return input.memberChannelIds.includes(input.repositoryChannelId)
? input.reason
: "access";
}

View File

@@ -52,6 +52,41 @@ export function projectRepoHostForRepository(
return projectRepoHost(cloneUrl, relayOrigin);
}
/**
* Human-readable location of a repository's git data — "github.com/block/buzz"
* for external repos (host + path, `.git` stripped), or "owner/repo" for
* Buzz-hosted ones (the relay host and full owner pubkey carry no signal;
* `ownerLabel` should be the resolved profile name, falling back to a
* shortened pubkey). Returns `null` when no clone URL can be resolved.
*/
export function repositoryDisplayPath(
repository: RepositoryHostInput | null | undefined,
relayOrigin: string | null | undefined,
ownerLabel?: string | null,
): string | null {
if (!repository) return null;
const cloneUrl = effectiveCloneUrls(
repository.cloneUrls,
relayOrigin,
repository.owner,
repository.dtag,
)[0];
if (!cloneUrl) return null;
if (projectRepoHost(cloneUrl, relayOrigin).kind === "buzz") {
const owner = ownerLabel?.trim() || `${repository.owner.slice(0, 8)}…`;
return `${owner}/${repository.dtag}`;
}
try {
const url = new URL(cloneUrl);
const path = url.pathname.replace(/\.git$/, "").replace(/\/+$/, "");
return `${url.host}${path}`;
} catch {
return null;
}
}
export function projectRepoHostForProject(
project:
| RepositoryHostInput

View File

@@ -1,7 +1,11 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { relativeTime } from "./projectsViewHelpers.ts";
import {
isProjectAccessibleToViewer,
isRepositoryAccessibleToViewer,
relativeTime,
} from "./projectsViewHelpers.ts";
const DAY_SECONDS = 24 * 60 * 60;
@@ -9,6 +13,133 @@ function localSeconds(year, month, day) {
return Math.floor(new Date(year, month, day, 12).getTime() / 1_000);
}
const REPO_OWNER = "a".repeat(64);
const VIEWER = "b".repeat(64);
const BOUND_CHANNEL = "11111111-1111-4111-8111-111111111111";
const RELAY_ORIGIN = "https://relay.example";
function makeRepository(overrides = {}) {
return {
channelId: BOUND_CHANNEL,
cloneUrls: [`${RELAY_ORIGIN}/git/${REPO_OWNER}/repo-a`],
contributors: [],
createdAt: 0,
defaultBranch: "main",
dtag: "repo-a",
id: `${REPO_OWNER}:repo-a`,
name: "repo-a",
owner: REPO_OWNER,
repoAddress: `30617:${REPO_OWNER}:repo-a`,
...overrides,
};
}
function makeAccessInput(overrides = {}) {
return {
currentPubkey: VIEWER,
localRepoNames: new Set(),
memberChannelIds: [],
relayOrigin: RELAY_ORIGIN,
...overrides,
};
}
test("a channel-bound repository is accessible only to channel members", () => {
const repository = makeRepository();
assert.equal(
isRepositoryAccessibleToViewer(
repository,
makeAccessInput({ memberChannelIds: [BOUND_CHANNEL] }),
),
true,
);
assert.equal(
isRepositoryAccessibleToViewer(
repository,
makeAccessInput({ memberChannelIds: [] }),
),
false,
);
});
test("an unbound repository stays accessible to its owner", () => {
const repository = makeRepository({ channelId: null });
assert.equal(
isRepositoryAccessibleToViewer(repository, makeAccessInput()),
false,
);
assert.equal(
isRepositoryAccessibleToViewer(
repository,
makeAccessInput({ currentPubkey: REPO_OWNER }),
),
true,
);
});
test("external hosting and local checkouts bypass the channel gate", () => {
assert.equal(
isRepositoryAccessibleToViewer(
makeRepository({ cloneUrls: ["https://github.com/acme/site.git"] }),
makeAccessInput(),
),
true,
);
assert.equal(
isRepositoryAccessibleToViewer(
makeRepository(),
makeAccessInput({ localRepoNames: new Set(["repo-a"]) }),
),
true,
);
});
test("channel-bound repositories stay visible while memberships load", () => {
assert.equal(
isRepositoryAccessibleToViewer(
makeRepository(),
makeAccessInput({ memberChannelIds: null }),
),
true,
);
});
test("a project is accessible when any repository is, or when owned", () => {
const accessible = makeRepository({ channelId: BOUND_CHANNEL });
const restricted = makeRepository({
channelId: "22222222-2222-4222-8222-222222222222",
dtag: "repo-b",
id: `${REPO_OWNER}:repo-b`,
name: "repo-b",
repoAddress: `30617:${REPO_OWNER}:repo-b`,
});
const input = makeAccessInput({ memberChannelIds: [BOUND_CHANNEL] });
assert.equal(
isProjectAccessibleToViewer(
{ owner: REPO_OWNER, repositories: [restricted, accessible] },
input,
),
true,
);
assert.equal(
isProjectAccessibleToViewer(
{ owner: REPO_OWNER, repositories: [restricted] },
input,
),
false,
);
assert.equal(
isProjectAccessibleToViewer(
{ owner: VIEWER, repositories: [restricted] },
input,
),
true,
);
});
test("relativeTime switches to an absolute date at seven days", () => {
const now = localSeconds(2025, 5, 15);

View File

@@ -1,7 +1,10 @@
import type {
Project,
ProjectActivitySummary,
Repository,
} from "@/features/projects/hooks";
import { hasLocalRepositoryCheckout } from "@/features/projects/lib/projectLocalRepos";
import { projectRepoHostForRepository } from "@/features/projects/lib/projectRepoHost";
import { selectProjectRepository } from "@/features/projects/projectModels";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { normalizePubkey } from "@/shared/lib/pubkey";
@@ -9,6 +12,7 @@ import { normalizePubkey } from "@/shared/lib/pubkey";
export type ProjectsViewMode = "grid" | "list";
export type ProjectsRepositoryScope =
| "all"
| "accessible"
| "mine"
| "local"
| "buzz"
@@ -85,6 +89,7 @@ export function readStoredRepositoryScope(): ProjectsRepositoryScope {
PROJECTS_REPOSITORY_SCOPE_STORAGE_KEY,
);
if (
value === "accessible" ||
value === "mine" ||
value === "local" ||
value === "buzz" ||
@@ -360,6 +365,63 @@ export function isProjectOwnedByCurrentUser(
: false;
}
export type RepositoryAccessInput = {
currentPubkey: string | undefined;
localRepoNames: Set<string>;
/** `null` while channel memberships are still loading. */
memberChannelIds: readonly string[] | null;
relayOrigin: string | null | undefined;
};
/**
* Whether the viewer can actually read a repository's git data. The relay
* gates git reads on membership in the repository's bound `buzz-channel`,
* so a repository is considered accessible when the viewer owns it (owners
* can repair a missing binding), has a local checkout, the code is hosted
* externally (no relay ACL applies), or the viewer is a member of the bound
* channel. While memberships are still loading (`memberChannelIds === null`)
* channel-bound repositories are kept visible rather than flashing out.
*/
export function isRepositoryAccessibleToViewer(
repository: Repository,
input: RepositoryAccessInput,
) {
if (
input.currentPubkey &&
normalizePubkey(repository.owner) === normalizePubkey(input.currentPubkey)
) {
return true;
}
if (hasLocalRepositoryCheckout(repository, input.localRepoNames)) {
return true;
}
if (
projectRepoHostForRepository(repository, input.relayOrigin).kind ===
"external"
) {
return true;
}
if (!repository.channelId) return false;
if (input.memberChannelIds === null) return true;
return input.memberChannelIds.includes(repository.channelId);
}
/**
* A project is accessible when the viewer owns it or can read at least one
* of its repositories.
*/
export function isProjectAccessibleToViewer(
project: Project,
input: RepositoryAccessInput,
) {
return (
isProjectOwnedByCurrentUser(project, input.currentPubkey) ||
project.repositories.some((repository) =>
isRepositoryAccessibleToViewer(repository, input),
)
);
}
export function projectHasAgent(
project: Project,
people: string[],

View File

@@ -142,3 +142,106 @@ test("buildProjectsFromFetcher does not throw when tombstone enumeration succeed
"unclaimed repo must appear as legacy project",
);
});
// ── Tombstone scoping ────────────────────────────────────────────────────────
//
// Kind:5 is the app-wide NIP-09 deletion kind (every deleted chat message is
// one), so the tombstone fetch must be scoped server-side with `#a` filters
// on the announcement coordinates rather than crawling the relay's entire
// deletion history.
test("buildProjectsFromFetcher scopes the tombstone fetch to announcement coordinates", async () => {
const OWNER = "a".repeat(64);
const projectEvent = {
id: "p".repeat(64),
kind: 30621,
pubkey: OWNER,
created_at: 200,
content: "",
tags: [["d", "proj"]],
};
const repoEvent = {
id: "r".repeat(64),
kind: 30617,
pubkey: OWNER,
created_at: 100,
content: "",
tags: [
["d", "repo"],
["name", "repo"],
],
};
const deletionCalls = [];
const fetchExhaustively = async (kinds, extraFilter) => {
if (kinds.includes(5)) {
deletionCalls.push(extraFilter);
return [];
}
if (kinds.includes(30621)) return [projectEvent];
if (kinds.includes(30617)) return [repoEvent];
return [];
};
await buildProjectsFromFetcher(fetchExhaustively);
assert.equal(deletionCalls.length, 1, "one scoped tombstone query");
assert.deepEqual(deletionCalls[0], {
"#a": [`30621:${OWNER}:proj`, `30617:${OWNER}:repo`],
});
});
test("buildProjectsFromFetcher skips the tombstone fetch when there are no announcements", async () => {
let deletionQueried = false;
const fetchExhaustively = async (kinds) => {
if (kinds.includes(5)) deletionQueried = true;
return [];
};
const projects = await buildProjectsFromFetcher(fetchExhaustively);
assert.deepEqual(projects, []);
assert.equal(
deletionQueried,
false,
"no coordinates means no tombstone query at all",
);
});
test("buildProjectsFromFetcher still suppresses deleted heads via the scoped fetch", async () => {
const OWNER = "a".repeat(64);
const repoEvent = {
id: "r".repeat(64),
kind: 30617,
pubkey: OWNER,
created_at: 100,
content: "",
tags: [
["d", "repo"],
["name", "repo"],
],
};
const deletionEvent = {
id: "5".repeat(64),
kind: 5,
pubkey: OWNER,
created_at: 150,
content: "",
tags: [["a", `30617:${OWNER}:repo`]],
};
const fetchExhaustively = async (kinds, extraFilter) => {
if (kinds.includes(5)) {
return extraFilter?.["#a"]?.includes(`30617:${OWNER}:repo`)
? [deletionEvent]
: [];
}
if (kinds.includes(30617)) return [repoEvent];
return [];
};
const projects = await buildProjectsFromFetcher(fetchExhaustively);
assert.deepEqual(projects, [], "deleted repo must not surface as a project");
});

View File

@@ -9,13 +9,27 @@ import { buildProjectReadModels, type Project } from "./projectModels";
const PROJECT_ENUMERATION_PAGE_SIZE = 500;
type ProjectEventFilter = {
// Relays commonly cap filter tag-value lists; chunk `#a` scoping well below
// any such cap.
const TOMBSTONE_COORDINATE_CHUNK_SIZE = 100;
/** Additional server-side scoping merged into every enumeration page. */
export type ProjectEventExtraFilter = {
"#a"?: string[];
};
type ProjectEventFilter = ProjectEventExtraFilter & {
kinds: number[];
limit: number;
since?: number;
until?: number;
};
export type FetchProjectEventsExhaustively = (
kinds: number[],
extraFilter?: ProjectEventExtraFilter,
) => Promise<RelayEvent[]>;
type FetchProjectEventPage = (
filter: ProjectEventFilter,
) => Promise<RelayEvent[]>;
@@ -29,6 +43,7 @@ export async function enumerateProjectEvents(
fetchPage: FetchProjectEventPage,
kinds: number[],
pageSize: number,
extraFilter?: ProjectEventExtraFilter,
): Promise<RelayEvent[]> {
if (!Number.isSafeInteger(pageSize) || pageSize <= 0) {
throw new Error(
@@ -41,6 +56,7 @@ export async function enumerateProjectEvents(
for (;;) {
const page = await fetchPage({
...extraFilter,
kinds,
limit: pageSize,
...(until === undefined ? {} : { until }),
@@ -50,6 +66,7 @@ export async function enumerateProjectEvents(
const oldest = Math.min(...page.map((event) => event.created_at));
const boundary = await fetchPage({
...extraFilter,
kinds,
limit: pageSize,
since: oldest,
@@ -75,15 +92,64 @@ export async function enumerateProjectEvents(
export function fetchProjectEventsExhaustively(
kinds: number[],
extraFilter?: ProjectEventExtraFilter,
pageSize = PROJECT_ENUMERATION_PAGE_SIZE,
): Promise<RelayEvent[]> {
return enumerateProjectEvents(
(filter) => relayClient.fetchEvents(filter),
kinds,
pageSize,
extraFilter,
);
}
/** `kind:owner:dtag` coordinate for an addressable announcement event. */
function eventCoordinate(event: RelayEvent): string | null {
const dtag = event.tags.find((tag) => tag[0] === "d")?.[1];
if (typeof dtag !== "string" || dtag.length === 0) return null;
return `${event.kind}:${event.pubkey.toLowerCase()}:${dtag}`;
}
/**
* Fetches the NIP-09 kind:5 tombstones relevant to the given announcement
* events, scoped server-side with `#a` filters on the announcements' own
* coordinates. Kind:5 is the app-wide deletion kind (every deleted chat
* message is one), so enumerating it unscoped crawls the entire community's
* deletion history — minutes on a large relay. Only tombstones addressing a
* currently visible project/repo coordinate can affect the read models, so
* scoping is semantically equivalent (see `buildDeletionThresholds`).
*/
async function fetchScopedDeletionEvents(
fetchExhaustively: FetchProjectEventsExhaustively,
announcementEvents: RelayEvent[],
): Promise<RelayEvent[]> {
const coordinates = [
...new Set(
announcementEvents.flatMap((event) => {
const coordinate = eventCoordinate(event);
return coordinate ? [coordinate] : [];
}),
),
];
if (coordinates.length === 0) return [];
const chunks: string[][] = [];
for (
let index = 0;
index < coordinates.length;
index += TOMBSTONE_COORDINATE_CHUNK_SIZE
) {
chunks.push(
coordinates.slice(index, index + TOMBSTONE_COORDINATE_CHUNK_SIZE),
);
}
const pages = await Promise.all(
chunks.map((chunk) => fetchExhaustively([KIND_DELETION], { "#a": chunk })),
);
return pages.flat();
}
/**
* Core fetch-and-build logic for `fetchProjects`, extracted for testability.
*
@@ -95,24 +161,31 @@ export function fetchProjectEventsExhaustively(
* returning an empty deletion set that would resurrect every deleted head.
*/
export async function buildProjectsFromFetcher(
fetchExhaustively: (kinds: number[]) => Promise<RelayEvent[]>,
fetchExhaustively: FetchProjectEventsExhaustively,
options: {
relayOrigin?: string | null;
hiddenAddresses?: ReadonlySet<string>;
} = {},
): Promise<Project[]> {
const [projectEvents, repositoryEvents, tombstoneResult] = await Promise.all([
const [projectEvents, repositoryEvents] = await Promise.all([
fetchExhaustively([KIND_PROJECT_ANNOUNCEMENT]),
fetchExhaustively([KIND_REPO_ANNOUNCEMENT]),
fetchExhaustively([KIND_DELETION]).then(
(events) => ({ ok: true as const, events }),
(error: unknown) => ({
ok: false as const,
message: error instanceof Error ? error.message : "Unknown error",
}),
),
]);
// Tombstones are fetched second (not in parallel) because the `#a` scoping
// needs the announcement coordinates; both announcement kinds are small,
// so this costs one extra round trip, not a full crawl.
const tombstoneResult = await fetchScopedDeletionEvents(fetchExhaustively, [
...projectEvents,
...repositoryEvents,
]).then(
(events) => ({ ok: true as const, events }),
(error: unknown) => ({
ok: false as const,
message: error instanceof Error ? error.message : "Unknown error",
}),
);
if (!tombstoneResult.ok) {
throw new Error(
`Could not fetch project deletion records: ${tombstoneResult.message} — refresh to retry.`,

View File

@@ -280,6 +280,16 @@ function RepositoryUnavailableIndicator({
description: "No git repository was found on the Buzz relay.",
label: "Uninitialized",
},
access: {
description:
"You’re not a member of the channel that grants access to this repository.",
label: "No access",
},
unbound: {
description:
"The repository has no access channel binding, so the relay cannot authorize reads.",
label: "No access channel",
},
network: {
description: "The Buzz git service could not be reached.",
label: "Unreachable",
@@ -292,13 +302,17 @@ function RepositoryUnavailableIndicator({
description: "Buzz could not load this repository.",
label: "Unavailable",
},
}[reason];
} satisfies Record<
ProjectRepoUnavailableReason,
{ description: string; label: string }
>;
const { description, label } = status[reason];
return (
<Tooltip>
<TooltipTrigger asChild>
<span
aria-label={`Repository ${status.label.toLowerCase()}`}
aria-label={`Repository ${label.toLowerCase()}`}
className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-amber-600 hover:bg-amber-500/10 dark:text-amber-300"
role="img"
>
@@ -306,8 +320,8 @@ function RepositoryUnavailableIndicator({
</span>
</TooltipTrigger>
<TooltipContent className="max-w-64">
<p className="font-medium">{status.label}</p>
<p className="text-muted-foreground">{status.description}</p>
<p className="font-medium">{label}</p>
<p className="text-muted-foreground">{description}</p>
</TooltipContent>
</Tooltip>
);

View File

@@ -29,6 +29,8 @@ import { ReadmePanel } from "./ProjectReadmePanel";
import type { RepoSourceHeaderControls } from "./ProjectRepositorySource";
type ProjectOverviewPanelProps = {
/** `buzz-channel` binding of the repository, for access-restricted copy. */
accessChannelId?: string | null;
contributors: ProjectRepoContributor[];
externalHost?: string;
externalUrl?: string | null;
@@ -141,6 +143,7 @@ export function OverviewRailSection({
}
export function ProjectOverviewPanel({
accessChannelId,
contributors,
externalHost,
externalUrl,
@@ -174,6 +177,7 @@ export function ProjectOverviewPanel({
{/* ReadmePanel renders its own "no README" fallback while keeping
the branch + source controls reachable. */}
<ReadmePanel
accessChannelId={accessChannelId}
externalHost={externalHost}
externalUrl={externalUrl}
file={readmeFile}

View File

@@ -11,6 +11,8 @@ import {
RefreshCw,
} from "lucide-react";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { useChannelsQuery } from "@/features/channels/hooks";
import type { ProjectRepoFile } from "@/features/projects/hooks";
import type { ProjectRepoUnavailableReason } from "@/features/projects/lib/projectRepoAvailability";
import { Button } from "@/shared/ui/button";
@@ -90,7 +92,50 @@ function normalizeReadmeMarkdown(content: string) {
.trim();
}
/**
* Description for the access-restricted state. Links to the bound channel
* when it is visible to the viewer (public channels appear in the channel
* list even before joining); private channels fall back to generic copy.
*/
function AccessRestrictedDescription({
accessChannelId,
}: {
accessChannelId: string;
}) {
const { goChannel } = useAppNavigation();
const channelsQuery = useChannelsQuery();
const channel = channelsQuery.data?.find(
(candidate) => candidate.id === accessChannelId,
);
if (!channel) {
return (
<>
Repository access is granted through a channel you can’t see. Ask the
repository owner for an invite.
</>
);
}
return (
<>
Repository access is granted through{" "}
<button
aria-label={`Open repository access channel #${channel.name}`}
className="font-medium text-foreground underline-offset-2 hover:underline"
onClick={() => void goChannel(channel.id)}
type="button"
>
#{channel.name}
</button>
, and you’re not a member. Join the channel or ask the repository owner
for an invite.
</>
);
}
export function ReadmePanel({
accessChannelId,
file,
gitDataState,
externalHost,
@@ -98,6 +143,8 @@ export function ReadmePanel({
sourceControls,
unavailableReason,
}: {
/** `buzz-channel` binding of the repository, for access-restricted copy. */
accessChannelId?: string | null;
file: ProjectRepoFile | null;
gitDataState: "checking" | "available" | "empty" | "unavailable";
externalHost?: string;
@@ -174,6 +221,18 @@ export function ReadmePanel({
icon: CircleAlert,
title: "Repository not initialized",
},
access: {
description:
"Repository access is granted through its channel, and you’re not a member of the channel bound to this repository. Ask the repository owner for an invite.",
icon: LockKeyhole,
title: "Repository access restricted",
},
unbound: {
description:
"This repository has no access channel binding, so the relay cannot authorize anyone to read it. The repository owner can bind a channel from the Access menu.",
icon: LockKeyhole,
title: "No access channel bound",
},
network: {
description:
"The Buzz git service could not be reached. Check your connection and try again.",
@@ -221,9 +280,13 @@ export function ReadmePanel({
: unavailable.title}
</h3>
<p className="mt-1 max-w-lg text-sm text-muted-foreground">
{externalHost
? "Clone this repository locally to explore its files, commits, and contributors in Buzz."
: unavailable.description}
{externalHost ? (
"Clone this repository locally to explore its files, commits, and contributors in Buzz."
) : reason === "access" && accessChannelId ? (
<AccessRestrictedDescription accessChannelId={accessChannelId} />
) : (
unavailable.description
)}
</p>
{externalUrl ? (
<a

View File

@@ -23,7 +23,11 @@ import {
type ViewerGitIdentity,
} from "@/features/projects/lib/projectContributorMatching";
import type { ProjectRepoHost } from "@/features/projects/lib/projectRepoHost";
import { projectRepoUnavailableReason } from "@/features/projects/lib/projectRepoAvailability";
import {
projectRepoUnavailableReason,
refineRepoUnavailableReason,
} from "@/features/projects/lib/projectRepoAvailability";
import { useMemberChannelIds } from "@/features/projects/useRepositoryAccess";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { Button } from "@/shared/ui/button";
import { Tabs, TabsContent } from "@/shared/ui/tabs";
@@ -216,9 +220,17 @@ export function WorkspaceTabs({
: files.length === 0
? "empty"
: "available";
// The relay masks channel-ACL denials as 404 (anti-enumeration), so a
// "missing" git result is re-classified with the repository's channel
// binding and the viewer's memberships before it reaches the UI copy.
const memberChannelIds = useMemberChannelIds();
const unavailableReason =
gitDataState === "unavailable" && !externalHost
? projectRepoUnavailableReason(displayedSnapshotError)
? refineRepoUnavailableReason({
reason: projectRepoUnavailableReason(displayedSnapshotError),
repositoryChannelId: project.channelId,
memberChannelIds,
})
: undefined;
const repositoryLoaded =
gitDataState === "available" || gitDataState === "empty";
@@ -411,6 +423,7 @@ export function WorkspaceTabs({
<TabsContent className="m-0" value="overview">
<ProjectOverviewPanel
accessChannelId={project.channelId}
contributors={displayedContributors}
externalHost={externalHost}
externalUrl={externalHost ? sourceControls?.externalUrl : null}

View File

@@ -252,18 +252,23 @@ function buildActivityItems({
function ActivityCard({
compact,
isFirst,
isLast,
item,
onOpen,
onOpenProject,
profiles,
}: {
compact: boolean;
isFirst: boolean;
isLast: boolean;
item: ProjectActivityItem;
onOpen: () => void;
onOpenProject: () => void;
profiles?: UserProfileLookup;
}) {
const visual = PROJECT_EVENT_VISUALS[item.kind];
const TypeIcon = visual.icon;
const profile = item.actorPubkey
? profiles?.[normalizePubkey(item.actorPubkey)]
: undefined;
@@ -274,42 +279,71 @@ function ActivityCard({
return (
<div
className={cn(
"relative block w-full rounded-xl border border-border/60 bg-transparent text-left transition-colors hover:bg-muted/20",
compact ? "p-3" : "p-4",
"relative block w-full rounded-xl bg-transparent text-left transition-colors hover:bg-muted/20",
compact ? "py-3 pr-3" : "py-4 pr-4",
)}
data-testid="projects-activity-card"
>
<button
aria-label={`Open ${item.title} in ${item.target.project.name}`}
className="absolute inset-0 rounded-xl focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
className="absolute inset-0 rounded-xl focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
onClick={onOpen}
type="button"
/>
<div className="pointer-events-none relative flex min-w-0 items-start gap-3">
{item.actorPubkey ? (
<UserProfilePopover pubkey={item.actorPubkey} triggerElement="span">
<button
aria-label={`View ${actorLabel}'s profile`}
className="pointer-events-auto relative z-10 shrink-0 rounded-full focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
type="button"
>
<UserAvatar
accent={profile?.isAgent === true}
avatarUrl={profile?.avatarUrl ?? null}
displayName={actorLabel}
size={compact ? "xs" : "md"}
/>
</button>
</UserProfilePopover>
) : (
<UserAvatar
accent={profile?.isAgent === true}
avatarUrl={profile?.avatarUrl ?? null}
className="shrink-0"
displayName={actorLabel}
size={compact ? "xs" : "md"}
/>
)}
{/* Avatar gutter: a vertical spine runs through the avatar centers
to connect consecutive cards. Segments extend into the card's
vertical padding so they meet the neighbouring card's segments;
the first card has no incoming line and the last no outgoing. */}
<div
className={cn(
"relative flex shrink-0 items-start justify-center self-stretch",
compact ? "w-5" : "w-9",
)}
>
{isFirst ? null : (
<span
aria-hidden="true"
className={cn(
"absolute left-1/2 w-px -translate-x-1/2 bg-border/80",
compact ? "-top-3 h-3" : "-top-4 h-4",
)}
/>
)}
{isLast ? null : (
<span
aria-hidden="true"
className={cn(
"absolute left-1/2 w-px -translate-x-1/2 bg-border/80",
compact ? "-bottom-3 top-5" : "-bottom-4 top-9",
)}
/>
)}
{item.actorPubkey ? (
<UserProfilePopover pubkey={item.actorPubkey} triggerElement="span">
<button
aria-label={`View ${actorLabel}'s profile`}
className="pointer-events-auto relative z-10 shrink-0 rounded-full focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
type="button"
>
<UserAvatar
accent={profile?.isAgent === true}
avatarUrl={profile?.avatarUrl ?? null}
displayName={actorLabel}
size={compact ? "xs" : "md"}
/>
</button>
</UserProfilePopover>
) : (
<UserAvatar
accent={profile?.isAgent === true}
avatarUrl={profile?.avatarUrl ?? null}
className="relative z-10 shrink-0"
displayName={actorLabel}
size={compact ? "xs" : "md"}
/>
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-start gap-2">
<div className="min-w-0 flex-1 text-xs text-muted-foreground/70">
@@ -320,7 +354,7 @@ function ActivityCard({
triggerElement="span"
>
<button
className="pointer-events-auto relative z-10 rounded-sm hover:underline focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"
className="pointer-events-auto relative z-10 rounded-sm font-semibold text-foreground hover:underline focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"
type="button"
>
{actorLabel}
@@ -331,7 +365,7 @@ function ActivityCard({
)}{" "}
{item.action}{" "}
<button
className="pointer-events-auto relative z-10 inline-block max-w-48 truncate rounded-sm align-bottom hover:underline focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring sm:max-w-64 2xl:max-w-none"
className="pointer-events-auto relative z-10 inline-block max-w-48 truncate rounded-sm align-bottom font-semibold text-foreground hover:underline focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring sm:max-w-64 2xl:max-w-none"
onClick={onOpenProject}
type="button"
>
@@ -365,9 +399,16 @@ function ActivityCard({
) : null}
</div>
<div className={compact ? "mt-2" : "mt-3"}>
<p className="min-w-0 truncate text-sm font-semibold leading-5 text-foreground">
{item.title}
</p>
{/* Bare event-type glyph beside the headline (no badge circle). */}
<div className="flex min-w-0 items-start gap-2">
<TypeIcon
aria-hidden="true"
className={cn("mt-0.5 h-4 w-4 shrink-0", visual.iconClassName)}
/>
<p className="min-w-0 flex-1 truncate text-sm font-semibold leading-5 text-foreground">
{item.title}
</p>
</div>
{item.body ? (
<p
className={cn(
@@ -420,13 +461,16 @@ export function ProjectsActivityFeed(props: ProjectsActivityFeedProps) {
return (
<div
className={cn("relative", props.compact ? "space-y-2.5" : "space-y-3")}
className="relative bg-transparent"
data-testid="projects-activity-timeline"
>
{items.map((item) => {
{items.map((item, index) => {
return (
<div className="relative" key={item.id}>
<ActivityCard
compact={props.compact === true}
isFirst={index === 0}
isLast={index === items.length - 1}
item={item}
onOpen={() => {
if (item.target.type === "project") {

View File

@@ -50,7 +50,7 @@ export function ProjectsCreateMenu({
return (
<nav
aria-label="Create project item"
className="relative shrink-0 pl-4"
className="relative shrink-0"
onBlurCapture={(event) => {
if (!event.currentTarget.contains(event.relatedTarget)) {
setOpen(false);

View File

@@ -12,6 +12,7 @@ import {
resolveUserLabel,
type UserProfileLookup,
} from "@/features/profile/lib/identity";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { Card } from "@/shared/ui/card";
import { DropdownMenuItem } from "@/shared/ui/dropdown-menu";
@@ -31,6 +32,8 @@ import {
} from "./projectListRowStyles";
type ProjectsIssuesListProps = {
/** Render without container chrome — a parent table container provides border and rounding. */
embedded?: boolean;
error: unknown;
failedSections: ProjectWorkItemSection[];
isLoading: boolean;
@@ -73,25 +76,40 @@ function IssueHeader({
<div className="flex min-w-0 items-center gap-1.5">
<p className={PROJECT_LIST_ROW_TITLE_CLASS}>{issue.title}</p>
</div>
<p className={`truncate ${PROJECT_LIST_ROW_SUBTEXT_CLASS}`}>
{project.name}
{includeDate ? ` · created ${relativeTime(issue.createdAt)}` : null} ·
by{" "}
<ProjectAuthorIdentity
label={authorLabel}
profiles={profiles}
pubkey={issue.author}
testId={authorTestId}
/>
{/* Flex (not inline flow) so the 20px author avatar cannot grow the
line box — keeps row heights identical to the PR list. */}
<div
className={`flex min-w-0 items-center gap-x-1.5 overflow-hidden whitespace-nowrap ${PROJECT_LIST_ROW_SUBTEXT_CLASS}`}
>
<span>{project.name}</span>
{includeDate ? (
` · ${issue.status}`
<>
<span>·</span>
<span>created {relativeTime(issue.createdAt)}</span>
</>
) : null}
<span>·</span>
<span className="inline-flex items-center gap-1">
<span>by</span>
<ProjectAuthorIdentity
label={authorLabel}
profiles={profiles}
pubkey={issue.author}
testId={authorTestId}
/>
</span>
{includeDate ? (
<>
<span>·</span>
<span>{issue.status}</span>
</>
) : (
<>
<span className="md:hidden"> · </span>
<span className="md:hidden">·</span>
<span className="md:hidden">{issue.status}</span>
</>
)}
</p>
</div>
</div>
);
}
@@ -223,6 +241,7 @@ function IssueListRow({
}
export function ProjectsIssuesList({
embedded,
error,
failedSections,
isLoading,
@@ -235,7 +254,12 @@ export function ProjectsIssuesList({
}: ProjectsIssuesListProps) {
if (isLoading) {
return (
<div className="border border-border/60 px-4 py-12 text-center text-sm text-muted-foreground">
<div
className={cn(
"px-4 py-12 text-center text-sm text-muted-foreground",
!embedded && "border border-border/60",
)}
>
Loading issues...
</div>
);
@@ -259,7 +283,12 @@ export function ProjectsIssuesList({
return (
<div className="space-y-3">
{loadNotice}
<div className="border border-dashed border-border/60 px-4 py-12 text-center text-sm text-muted-foreground">
<div
className={cn(
"px-4 py-12 text-center text-sm text-muted-foreground",
!embedded && "border border-dashed border-border/60",
)}
>
No issues yet.
</div>
</div>
@@ -291,7 +320,9 @@ export function ProjectsIssuesList({
<div className="space-y-3">
{loadNotice}
<div
className={PROJECT_LIST_CONTAINER_CLASS}
className={
embedded ? "divide-y divide-border/60" : PROJECT_LIST_CONTAINER_CLASS
}
data-testid="projects-list-container"
>
{issues.map(({ project, issue, repository }) => (

View File

@@ -0,0 +1,149 @@
import type {
ProjectsFilter,
ProjectsRepositoryScope,
ProjectsSort,
ProjectsViewMode,
ProjectsWorkItemScope,
} from "@/features/projects/lib/projectsViewHelpers";
import { ProjectsListScopeDropdown } from "@/features/projects/ui/ProjectsListScopeDropdown";
import { ProjectsViewModeToggle } from "@/features/projects/ui/ProjectsToolbar";
import { cn } from "@/shared/lib/cn";
const PROJECT_SCOPE_OPTIONS: Array<{
label: string;
value: ProjectsRepositoryScope;
}> = [
{ label: "All", value: "all" },
{ label: "Accessible", value: "accessible" },
{ label: "My Projects", value: "mine" },
{ label: "Local", value: "local" },
];
const REPOSITORY_SCOPE_OPTIONS: Array<{
label: string;
value: ProjectsRepositoryScope;
}> = [
{ label: "All", value: "all" },
{ label: "Accessible", value: "accessible" },
{ label: "My Repositories", value: "mine" },
{ label: "Local", value: "local" },
{ label: "Buzz-hosted", value: "buzz" },
{ label: "Linked", value: "linked" },
];
const PULL_REQUEST_SCOPE_OPTIONS: Array<{
label: string;
value: ProjectsWorkItemScope;
}> = [
{ label: "All", value: "all" },
{ label: "My Pull Requests", value: "mine" },
];
const ISSUE_SCOPE_OPTIONS: Array<{
label: string;
value: ProjectsWorkItemScope;
}> = [
{ label: "All", value: "all" },
{ label: "My Issues", value: "mine" },
];
type ProjectsListHeaderBarProps = {
filter: ProjectsFilter;
issueScope: ProjectsWorkItemScope;
onIssueScopeChange: (scope: ProjectsWorkItemScope) => void;
onPullRequestScopeChange: (scope: ProjectsWorkItemScope) => void;
onRepositoryScopeChange: (scope: ProjectsRepositoryScope) => void;
onSortChange: (sort: ProjectsSort) => void;
onViewModeChange: (viewMode: ProjectsViewMode) => void;
pullRequestScope: ProjectsWorkItemScope;
repositoryScope: ProjectsRepositoryScope;
sort: ProjectsSort;
/**
* "row" renders as the first row of the list table (no chrome of its own —
* the surrounding container provides border and rounding); "bar" renders as
* a standalone rounded bar with identical proportions for the card grid.
*/
variant: "bar" | "row";
viewMode: ProjectsViewMode;
};
/**
* Header for the Projects lists: scope selector on the left, sort + view
* toggle on the right.
*/
export function ProjectsListHeaderBar({
filter,
issueScope,
onIssueScopeChange,
onPullRequestScopeChange,
onRepositoryScopeChange,
onSortChange,
onViewModeChange,
pullRequestScope,
repositoryScope,
sort,
variant,
viewMode,
}: ProjectsListHeaderBarProps) {
const scopeDropdown =
filter === "prs" ? (
<ProjectsListScopeDropdown
label="Filter pull requests"
onChange={onPullRequestScopeChange}
options={PULL_REQUEST_SCOPE_OPTIONS}
value={pullRequestScope}
/>
) : filter === "issues" ? (
<ProjectsListScopeDropdown
label="Filter issues"
onChange={onIssueScopeChange}
options={ISSUE_SCOPE_OPTIONS}
value={issueScope}
/>
) : filter === "projects" ? (
<ProjectsListScopeDropdown
label="Filter projects"
onChange={onRepositoryScopeChange}
options={PROJECT_SCOPE_OPTIONS}
value={repositoryScope}
/>
) : (
<ProjectsListScopeDropdown
label="Filter repositories"
onChange={onRepositoryScopeChange}
options={REPOSITORY_SCOPE_OPTIONS}
value={repositoryScope}
/>
);
return (
<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",
)}
data-testid="projects-list-header"
>
{scopeDropdown}
<div className="flex flex-wrap items-center gap-2">
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="sr-only">Sort projects</span>
<select
className="h-8 rounded-md bg-transparent px-2 text-xs text-foreground outline-hidden hover:bg-muted/50 focus:ring-1 focus:ring-ring"
onChange={(event) =>
onSortChange(event.target.value as ProjectsSort)
}
value={sort}
>
<option value="updated">Recent activity</option>
<option value="created">Created date</option>
<option value="name">Name</option>
</select>
</label>
<ProjectsViewModeToggle
onViewModeChange={onViewModeChange}
viewMode={viewMode}
/>
</div>
</div>
);
}

View File

@@ -60,7 +60,7 @@ function StatPill({
</span>
<Icon className="h-3.5 w-3.5 text-muted-foreground/70" />
</span>
<span className="mt-auto pt-4 text-4xl font-semibold leading-none tracking-tight text-foreground">
<span className="mt-auto pt-3 text-2xl font-semibold leading-none tracking-tight text-foreground">
{count}
</span>
</button>
@@ -76,42 +76,51 @@ export function ProjectsOverviewPanel({
}: ProjectsOverviewPanelProps) {
const stats = overviewStats(projects, summaries);
// The feed owns the full left column; the stat counters live at the top
// of the side rail as compact cards, above People and Contribution
// Activity, instead of a full-width row over the feed. The feed column
// has no left inset so the timeline spine lines up with the section tabs.
return (
<section className="-mx-4 mb-4" data-testid="projects-overview-panel">
<section
className="-mr-[calc(1rem+10px)] mb-4"
data-testid="projects-overview-panel"
>
<div className="grid xl:grid-cols-[minmax(0,1fr)_18rem] 2xl:grid-cols-[minmax(0,1fr)_24rem]">
<div className="order-1 grid grid-cols-2 gap-2 p-4 pt-0 sm:gap-3 xl:order-none xl:col-start-1 xl:row-start-1 xl:grid-cols-4">
<StatPill
count={projects.length}
icon={Folders}
label="Projects"
onClick={() => onSelectSection("projects")}
/>
<StatPill
count={projects.reduce(
(count, project) => count + project.repositories.length,
0,
)}
icon={FolderGit2}
label="Repositories"
onClick={() => onSelectSection("repositories")}
/>
<StatPill
count={stats.prs}
icon={GitPullRequest}
label="Pull requests"
onClick={() => onSelectSection("prs")}
/>
<StatPill
count={stats.issues}
icon={CircleDot}
label="Issues"
onClick={() => onSelectSection("issues")}
/>
</div>
{metadata}
<div className="order-2 min-w-0 p-4 pt-2 xl:order-none xl:col-start-1 xl:row-start-2">
<div className="order-1 min-w-0 pb-4 pr-4 pt-2 xl:order-none xl:col-start-1 xl:row-start-1">
{children}
</div>
<div className="order-2 flex min-w-0 flex-col gap-3 px-4 pb-4 pt-2 xl:order-none xl:col-start-2 xl:row-start-1">
<div className="grid grid-cols-2 gap-2">
<StatPill
count={projects.length}
icon={Folders}
label="Projects"
onClick={() => onSelectSection("projects")}
/>
<StatPill
count={projects.reduce(
(count, project) => count + project.repositories.length,
0,
)}
icon={FolderGit2}
label="Repositories"
onClick={() => onSelectSection("repositories")}
/>
<StatPill
count={stats.prs}
icon={GitPullRequest}
label="Pull requests"
onClick={() => onSelectSection("prs")}
/>
<StatPill
count={stats.issues}
icon={CircleDot}
label="Issues"
onClick={() => onSelectSection("issues")}
/>
</div>
{metadata}
</div>
</div>
</section>
);

View File

@@ -62,9 +62,11 @@ export function ProjectsOverviewRail({
const people = overviewPeople(projects, summaries);
const activityByDay = overviewActivityByDay(projects, summaries);
// Plain stacked cards — the overview panel's rail column owns placement
// and spacing, so the sections can never drift apart or collide.
return (
<>
<div className="order-4 min-w-0 border-t border-border/40 px-4 py-4 xl:order-none xl:col-start-2 xl:row-start-1 xl:border-t-0 xl:pt-0">
<div className="rounded-lg border border-border/60 p-4">
<OverviewRailSection title="People" titleClassName="text-base">
{people.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
@@ -94,13 +96,13 @@ export function ProjectsOverviewRail({
</OverviewRailSection>
</div>
<div className="order-5 min-w-0 border-t border-border/40 px-4 pb-4 pt-3 xl:order-none xl:col-start-2 xl:row-start-2 xl:border-t-0">
<section className="space-y-2">
<h3 className="text-base font-semibold text-foreground">
Contribution Activity
</h3>
<div className="min-w-0 rounded-lg border border-border/60 p-4">
<OverviewRailSection
title="Contribution Activity"
titleClassName="text-base"
>
<ProjectsContributionGraph activityByDay={activityByDay} compact />
</section>
</OverviewRailSection>
</div>
</>
);

View File

@@ -8,6 +8,7 @@ import type {
} from "@/features/projects/hooks";
import { relativeTime } from "@/features/projects/lib/projectsViewHelpers";
import type { ProjectWorkItemSection } from "@/features/projects/projectWorkItems";
import { cn } from "@/shared/lib/cn";
import {
resolveUserLabel,
type UserProfileLookup,
@@ -31,6 +32,8 @@ import {
} from "./projectListRowStyles";
type ProjectsPullRequestsListProps = {
/** Render without container chrome — a parent table container provides border and rounding. */
embedded?: boolean;
error: unknown;
failedSections: ProjectWorkItemSection[];
isLoading: boolean;
@@ -231,6 +234,7 @@ function PullRequestListRow({
}
export function ProjectsPullRequestsList({
embedded,
error,
failedSections,
isLoading,
@@ -243,7 +247,12 @@ export function ProjectsPullRequestsList({
}: ProjectsPullRequestsListProps) {
if (isLoading) {
return (
<div className="border border-border/60 px-4 py-12 text-center text-sm text-muted-foreground">
<div
className={cn(
"px-4 py-12 text-center text-sm text-muted-foreground",
!embedded && "border border-border/60",
)}
>
Loading pull requests...
</div>
);
@@ -267,7 +276,12 @@ export function ProjectsPullRequestsList({
return (
<div className="space-y-3">
{loadNotice}
<div className="border border-dashed border-border/60 px-4 py-12 text-center text-sm text-muted-foreground">
<div
className={cn(
"px-4 py-12 text-center text-sm text-muted-foreground",
!embedded && "border border-dashed border-border/60",
)}
>
No pull requests yet.
</div>
</div>
@@ -299,7 +313,9 @@ export function ProjectsPullRequestsList({
<div className="space-y-3">
{loadNotice}
<div
className={PROJECT_LIST_CONTAINER_CLASS}
className={
embedded ? "divide-y divide-border/60" : PROJECT_LIST_CONTAINER_CLASS
}
data-testid="projects-list-container"
>
{pullRequests.map(({ project, pullRequest, repository }) => (

View File

@@ -1,4 +1,5 @@
import { LayoutGrid, List } from "lucide-react";
import * as React from "react";
import type {
ProjectsFilter,
@@ -10,6 +11,17 @@ import { Button } from "@/shared/ui/button";
const SELECTED_MENU_ITEM_CLASSES =
"font-semibold text-foreground after:opacity-100 hover:text-foreground";
// Fade the clipped edge(s) of the scrollable tab row so a cut-off label
// reads as "scroll for more" instead of a rendering bug. Masking the row
// itself (rather than overlaying a gradient) keeps the effect correct over
// the translucent sticky header backdrop.
const MASK_BOTH =
"[mask-image:linear-gradient(to_right,transparent,black_1.5rem,black_calc(100%-1.5rem),transparent)]";
const MASK_LEFT =
"[mask-image:linear-gradient(to_right,transparent,black_1.5rem)]";
const MASK_RIGHT =
"[mask-image:linear-gradient(to_left,transparent,black_1.5rem)]";
type ProjectsToolbarProps = {
filter: ProjectsFilter;
onFilterChange: (filter: ProjectsFilter) => void;
@@ -51,10 +63,58 @@ export function ProjectsViewModeToggle({
);
}
/** Tracks which edges of a horizontal scroller are currently clipped. */
function useHorizontalOverflow(ref: React.RefObject<HTMLElement | null>) {
const [overflow, setOverflow] = React.useState({
left: false,
right: false,
});
React.useEffect(() => {
const element = ref.current;
if (!element) return;
const update = () => {
const maxScrollLeft = element.scrollWidth - element.clientWidth;
setOverflow((previous) => {
const next = {
left: element.scrollLeft > 1,
right: element.scrollLeft < maxScrollLeft - 1,
};
return previous.left === next.left && previous.right === next.right
? previous
: next;
});
};
update();
element.addEventListener("scroll", update, { passive: true });
const observer = new ResizeObserver(update);
observer.observe(element);
return () => {
element.removeEventListener("scroll", update);
observer.disconnect();
};
}, [ref]);
return overflow;
}
export function ProjectsToolbar({
filter,
onFilterChange,
}: ProjectsToolbarProps) {
const scrollRef = React.useRef<HTMLFieldSetElement>(null);
const overflow = useHorizontalOverflow(scrollRef);
// Keep the active tab visible when it changes (e.g. selected while
// partially scrolled out of view, or restored from storage on mount).
React.useEffect(() => {
scrollRef.current
?.querySelector<HTMLElement>(`[data-testid="projects-section-${filter}"]`)
?.scrollIntoView({ block: "nearest", inline: "nearest" });
}, [filter]);
const filterOptions: Array<{
label: string;
value: ProjectsFilter;
@@ -72,7 +132,19 @@ export function ProjectsToolbar({
data-tauri-drag-region
>
<div className="flex h-full min-w-0 flex-1 items-center gap-0.5 overflow-hidden">
<fieldset className="flex h-full min-w-0 flex-1 flex-nowrap items-stretch gap-1 overflow-x-auto scrollbar-none [&::-webkit-scrollbar]:hidden">
<fieldset
className={cn(
"flex h-full min-w-0 flex-1 flex-nowrap items-stretch gap-1 overflow-x-auto scrollbar-none [&::-webkit-scrollbar]:hidden",
overflow.left && overflow.right
? MASK_BOTH
: overflow.left
? MASK_LEFT
: overflow.right
? MASK_RIGHT
: undefined,
)}
ref={scrollRef}
>
<legend className="sr-only">Project owner filter</legend>
{filterOptions.map((option) => (
<Button

View File

@@ -18,6 +18,10 @@ import { useRepositoryActivitySummariesQuery } from "@/features/projects/reposit
import { useCreateProjectMutation } from "@/features/projects/useCreateProject";
import { selectProjectRepository } from "@/features/projects/projectModels";
import { useProjectsRepoSnapshotsQuery } from "@/features/projects/useProjectsRepoSnapshots";
import {
useMemberChannelIds,
useRepositoryUnavailableReasonFor,
} from "@/features/projects/useRepositoryAccess";
import {
projectRepoHostForProject,
projectRepoHostForRepository,
@@ -38,12 +42,8 @@ import { ProjectsOverviewPanel } from "@/features/projects/ui/ProjectsOverviewPa
import { ProjectsOverviewRail } from "@/features/projects/ui/ProjectsOverviewRail";
import { ProjectsPullRequestsList } from "@/features/projects/ui/ProjectsPullRequestsList";
import { ProjectsWorkItemsLoadNotice } from "@/features/projects/ui/ProjectsWorkItemsLoadNotice";
import { ProjectsListScopeDropdown } from "@/features/projects/ui/ProjectsListScopeDropdown";
import { PROJECT_LIST_CONTAINER_CLASS } from "@/features/projects/ui/projectListRowStyles";
import {
ProjectsToolbar,
ProjectsViewModeToggle,
} from "@/features/projects/ui/ProjectsToolbar";
import { ProjectsListHeaderBar } from "@/features/projects/ui/ProjectsListHeaderBar";
import { ProjectsToolbar } from "@/features/projects/ui/ProjectsToolbar";
import {
hasLocalCheckout,
hasLocalRepositoryCheckout,
@@ -54,8 +54,10 @@ import {
} from "@/features/projects/ui/RepositoryCards";
import {
getProjectUpdatedAt,
isProjectAccessibleToViewer,
isProjectMine,
isProjectOwnedByCurrentUser,
isRepositoryAccessibleToViewer,
projectHasAgent,
projectOwnerIsUser,
projectPeople,
@@ -89,38 +91,6 @@ import { Button } from "@/shared/ui/button";
import { PageHeader } from "@/shared/ui/PageHeader";
const MANY_PROJECTS_THRESHOLD = 12;
const PROJECT_SCOPE_OPTIONS: Array<{
label: string;
value: ProjectsRepositoryScope;
}> = [
{ label: "All", value: "all" },
{ label: "My Projects", value: "mine" },
{ label: "Local", value: "local" },
];
const REPOSITORY_SCOPE_OPTIONS: Array<{
label: string;
value: ProjectsRepositoryScope;
}> = [
{ label: "All", value: "all" },
{ label: "My Repositories", value: "mine" },
{ label: "Local", value: "local" },
{ label: "Buzz-hosted", value: "buzz" },
{ label: "Linked", value: "linked" },
];
const PULL_REQUEST_SCOPE_OPTIONS: Array<{
label: string;
value: ProjectsWorkItemScope;
}> = [
{ label: "All", value: "all" },
{ label: "My Pull Requests", value: "mine" },
];
const ISSUE_SCOPE_OPTIONS: Array<{
label: string;
value: ProjectsWorkItemScope;
}> = [
{ label: "All", value: "all" },
{ label: "My Issues", value: "mine" },
];
export function ProjectsView() {
const { goProject } = useAppNavigation();
@@ -217,6 +187,11 @@ export function ProjectsView() {
snapshotProjects,
activeCommunity?.reposDir,
);
const memberChannelIds = useMemberChannelIds();
const repositoryUnavailableReasonFor = useRepositoryUnavailableReasonFor(
repoSnapshotsQuery.data?.unavailable,
memberChannelIds,
);
const [createProjectOpen, setCreateProjectOpen] = React.useState(false);
const [createIssueOpen, setCreateIssueOpen] = React.useState(false);
const [createPullRequestOpen, setCreatePullRequestOpen] =
@@ -325,6 +300,16 @@ export function ProjectsView() {
[localRepositoriesQuery.data],
);
const repositoryAccessInput = React.useMemo(
() => ({
currentPubkey,
localRepoNames,
memberChannelIds,
relayOrigin,
}),
[currentPubkey, localRepoNames, memberChannelIds, relayOrigin],
);
const visibleProjects = React.useMemo(() => {
if (filter !== "projects" && filter !== "agents" && filter !== "users") {
return [];
@@ -334,6 +319,8 @@ export function ProjectsView() {
.filter((project) => {
const summary = activitySummariesQuery.data?.[project.id];
const people = projectPeople(project, summary);
if (repositoryScope === "accessible")
return isProjectAccessibleToViewer(project, repositoryAccessInput);
if (repositoryScope === "mine")
return isProjectMine(project, currentPubkey);
if (repositoryScope === "local")
@@ -376,6 +363,7 @@ export function ProjectsView() {
profiles,
projects,
relayOrigin,
repositoryAccessInput,
repositoryScope,
sort,
]);
@@ -396,6 +384,12 @@ export function ProjectsView() {
];
return repositories
.filter(({ repository }) => {
if (repositoryScope === "accessible") {
return isRepositoryAccessibleToViewer(
repository,
repositoryAccessInput,
);
}
if (repositoryScope === "mine") {
if (!currentPubkey) return false;
const normalizedCurrentPubkey = normalizePubkey(currentPubkey);
@@ -444,6 +438,7 @@ export function ProjectsView() {
localRepoNames,
projects,
relayOrigin,
repositoryAccessInput,
repositoryActivitySummariesQuery.data,
repositoryScope,
sort,
@@ -625,9 +620,9 @@ export function ProjectsView() {
people={projectPeople(project, summary)}
profiles={profiles}
project={project}
repositoryUnavailableReason={
repoSnapshotsQuery.data?.unavailable[project.id]
}
repositoryUnavailableReason={repositoryUnavailableReasonFor(
project,
)}
summary={summary}
/>
);
@@ -635,7 +630,7 @@ export function ProjectsView() {
</div>
) : (
<div
className={PROJECT_LIST_CONTAINER_CLASS}
className="divide-y divide-border/60"
data-testid="projects-list-container"
>
{visibleProjects.map((project) => {
@@ -652,9 +647,9 @@ export function ProjectsView() {
people={projectPeople(project, summary)}
profiles={profiles}
project={project}
repositoryUnavailableReason={
repoSnapshotsQuery.data?.unavailable[project.id]
}
repositoryUnavailableReason={repositoryUnavailableReasonFor(
project,
)}
summary={summary}
/>
);
@@ -683,7 +678,7 @@ export function ProjectsView() {
))}
</div>
) : (
<div className={PROJECT_LIST_CONTAINER_CLASS}>
<div className="divide-y divide-border/60">
{visibleRepositories.map(({ project, repository }) => (
<RepositoryListRow
hasLocal={hasLocalRepositoryCheckout(repository, localRepoNames)}
@@ -701,27 +696,21 @@ export function ProjectsView() {
</div>
);
const listControls = (
<div className="flex flex-wrap items-center gap-2 sm:justify-end">
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="sr-only">Sort projects</span>
<select
className="h-8 rounded-md bg-transparent px-2 text-xs text-foreground outline-hidden hover:bg-muted/50 focus:ring-1 focus:ring-ring"
onChange={(event) =>
handleSortChange(event.target.value as ProjectsSort)
}
value={sort}
>
<option value="updated">Recent activity</option>
<option value="created">Created date</option>
<option value="name">Name</option>
</select>
</label>
<ProjectsViewModeToggle
onViewModeChange={handleViewModeChange}
viewMode={viewMode}
/>
</div>
const listHeaderBar = (
<ProjectsListHeaderBar
filter={filter}
variant={viewMode === "list" ? "row" : "bar"}
issueScope={issueScope}
onIssueScopeChange={handleIssueScopeChange}
onPullRequestScopeChange={handlePullRequestScopeChange}
onRepositoryScopeChange={handleRepositoryScopeChange}
onSortChange={handleSortChange}
onViewModeChange={handleViewModeChange}
pullRequestScope={pullRequestScope}
repositoryScope={repositoryScope}
sort={sort}
viewMode={viewMode}
/>
);
const workItemFailedSections = [
@@ -776,10 +765,9 @@ export function ProjectsView() {
const projectsNavigation = (
<div className="flex h-[3.25rem] min-w-0 items-center">
<div className="h-full min-w-0 flex-1">
<div className="h-full min-w-0 flex-1 overflow-hidden">
<ProjectsToolbar filter={filter} onFilterChange={handleFilterChange} />
</div>
{createMenu}
</div>
);
@@ -797,6 +785,9 @@ export function ProjectsView() {
className="pointer-events-none absolute right-[3px] top-0 z-50 w-1 rounded-full bg-border/80 opacity-0 transition-opacity duration-200"
ref={scrollIndicatorRef}
/>
{/* Create button pinned to the pane's top-right corner: it never
scrolls with the page, it just stays put. */}
<div className="absolute right-4 top-4 z-40">{createMenu}</div>
<CreateProjectDialog
isCreating={createProjectMutation.isPending}
onCreate={async (input) => {
@@ -877,79 +868,62 @@ export function ProjectsView() {
<section className="space-y-3">{activityFeed}</section>
</ProjectsOverviewPanel>
) : (
<section className="space-y-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<section>
{/* In list view the header is the table's first row inside
the bordered container; in card view it is a standalone
bar with the cards flowing below. */}
<div
className={
viewMode === "list"
? "overflow-hidden rounded-xl border border-border/60"
: "space-y-3"
}
>
{listHeaderBar}
{filter === "prs" ? (
<ProjectsListScopeDropdown
label="Filter pull requests"
onChange={handlePullRequestScopeChange}
options={PULL_REQUEST_SCOPE_OPTIONS}
value={pullRequestScope}
<ProjectsPullRequestsList
embedded={viewMode === "list"}
error={projectsWorkItemsQuery.error}
failedSections={
projectsWorkItemsQuery.data?.pullRequests
.failedSections ?? []
}
isLoading={projectsWorkItemsQuery.isLoading}
isRetrying={
projectsWorkItemsQuery.isFetching &&
!projectsWorkItemsQuery.isLoading
}
onOpen={handleOpenPullRequest}
onRetry={() => void projectsWorkItemsQuery.refetch()}
profiles={profiles}
pullRequests={visiblePullRequests}
viewMode={viewMode}
/>
) : filter === "issues" ? (
<ProjectsListScopeDropdown
label="Filter issues"
onChange={handleIssueScopeChange}
options={ISSUE_SCOPE_OPTIONS}
value={issueScope}
<ProjectsIssuesList
embedded={viewMode === "list"}
error={projectsWorkItemsQuery.error}
failedSections={
projectsWorkItemsQuery.data?.issues.failedSections ??
[]
}
isLoading={projectsWorkItemsQuery.isLoading}
isRetrying={
projectsWorkItemsQuery.isFetching &&
!projectsWorkItemsQuery.isLoading
}
issues={visibleIssues}
onOpen={handleOpenIssue}
onRetry={() => void projectsWorkItemsQuery.refetch()}
profiles={profiles}
viewMode={viewMode}
/>
) : filter === "projects" ? (
<ProjectsListScopeDropdown
label="Filter projects"
onChange={handleRepositoryScopeChange}
options={PROJECT_SCOPE_OPTIONS}
value={repositoryScope}
/>
projectItems
) : (
<ProjectsListScopeDropdown
label="Filter repositories"
onChange={handleRepositoryScopeChange}
options={REPOSITORY_SCOPE_OPTIONS}
value={repositoryScope}
/>
repositoryItems
)}
{listControls}
</div>
{filter === "prs" ? (
<ProjectsPullRequestsList
error={projectsWorkItemsQuery.error}
failedSections={
projectsWorkItemsQuery.data?.pullRequests
.failedSections ?? []
}
isLoading={projectsWorkItemsQuery.isLoading}
isRetrying={
projectsWorkItemsQuery.isFetching &&
!projectsWorkItemsQuery.isLoading
}
onOpen={handleOpenPullRequest}
onRetry={() => void projectsWorkItemsQuery.refetch()}
profiles={profiles}
pullRequests={visiblePullRequests}
viewMode={viewMode}
/>
) : filter === "issues" ? (
<ProjectsIssuesList
error={projectsWorkItemsQuery.error}
failedSections={
projectsWorkItemsQuery.data?.issues.failedSections ?? []
}
isLoading={projectsWorkItemsQuery.isLoading}
isRetrying={
projectsWorkItemsQuery.isFetching &&
!projectsWorkItemsQuery.isLoading
}
issues={visibleIssues}
onOpen={handleOpenIssue}
onRetry={() => void projectsWorkItemsQuery.refetch()}
profiles={profiles}
viewMode={viewMode}
/>
) : filter === "projects" ? (
projectItems
) : (
repositoryItems
)}
</section>
)}
</div>

View File

@@ -5,8 +5,14 @@ import type {
ProjectActivitySummary,
Repository,
} from "@/features/projects/hooks";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { projectRepoHostForRepository } from "@/features/projects/lib/projectRepoHost";
import {
resolveUserLabel,
type UserProfileLookup,
} from "@/features/profile/lib/identity";
import {
projectRepoHostForRepository,
repositoryDisplayPath,
} from "@/features/projects/lib/projectRepoHost";
import {
formatExactTimestamp,
relativeTime,
@@ -99,11 +105,20 @@ function RepositoryOpenButton({
function RepositoryIdentity({
inlineBranch = false,
profiles,
project,
repository,
}: Pick<RepositoryItemProps, "project" | "repository"> & {
}: Pick<RepositoryItemProps, "profiles" | "project" | "repository"> & {
inlineBranch?: boolean;
}) {
// Where the git data lives beats repeating the (often identical) project
// name — "github.com/block/buzz" for external repos, "owner/repo" for
// Buzz-hosted ones.
const displayPath = repositoryDisplayPath(
repository,
useRelayOrigin(),
resolveUserLabel({ pubkey: repository.owner, profiles }),
);
return (
<>
<RepositoryHostIcon repository={repository} />
@@ -114,7 +129,7 @@ function RepositoryIdentity({
</span>
</div>
<p className={PROJECT_LIST_ROW_PREVIEW_CLASS}>
{project.name}
{displayPath ?? project.name}
{inlineBranch ? ` · ${repository.defaultBranch}` : ""}
</p>
</div>
@@ -203,6 +218,7 @@ export function RepositoryGridCard(props: RepositoryItemProps) {
<div className="flex min-w-0 items-start gap-2.5">
<RepositoryIdentity
inlineBranch
profiles={profiles}
project={project}
repository={repository}
/>
@@ -258,7 +274,11 @@ export function RepositoryListRow(props: RepositoryItemProps) {
/>
<div className="pointer-events-none relative z-10 flex min-w-0 flex-1 items-center gap-2.5">
<div className="flex min-w-0 flex-1 items-center gap-2.5">
<RepositoryIdentity project={project} repository={repository} />
<RepositoryIdentity
profiles={profiles}
project={project}
repository={repository}
/>
</div>
<div
className={cn(PROJECT_LIST_ROW_TRAILING_CLASS, "pointer-events-auto")}

View File

@@ -0,0 +1,47 @@
import * as React from "react";
import { useChannelsQuery } from "@/features/channels/hooks";
import type { Project } from "@/features/projects/hooks";
import {
type ProjectRepoUnavailableReason,
refineRepoUnavailableReason,
} from "@/features/projects/lib/projectRepoAvailability";
import { selectProjectRepository } from "@/features/projects/projectModels";
/** Channel ids the viewer is a member of, or `null` while they load. */
export function useMemberChannelIds(): readonly string[] | null {
const channelsQuery = useChannelsQuery();
return React.useMemo(
() =>
channelsQuery.data
? channelsQuery.data
.filter((channel) => channel.isMember)
.map((channel) => channel.id)
: null,
[channelsQuery.data],
);
}
/**
* Channel-ACL denials arrive as the same 404 as a missing repository
* (anti-enumeration), so a project's raw snapshot reason is re-classified
* with the repository's channel binding and the viewer's memberships
* before it reaches the status indicator.
*/
export function useRepositoryUnavailableReasonFor(
unavailable: Record<string, ProjectRepoUnavailableReason> | undefined,
memberChannelIds: readonly string[] | null,
): (project: Project) => ProjectRepoUnavailableReason | undefined {
return React.useCallback(
(project: Project) => {
const reason = unavailable?.[project.id];
if (!reason) return undefined;
return refineRepoUnavailableReason({
reason,
repositoryChannelId: selectProjectRepository(project, null)?.channelId,
memberChannelIds,
});
},
[memberChannelIds, unavailable],
);
}

View File

@@ -93,7 +93,8 @@ test("top-level project lists align dates and overflow actions", async ({
await expect(
repositoryRow.getByTestId("repositories-row-branch"),
).toContainText("main");
await expect(repositoryRow.locator("p")).toHaveText("buzz");
// Subtitle is the repository location (owner/repo for Buzz-hosted repos).
await expect(repositoryRow.locator("p")).toHaveText(/\/buzz$/);
const repositoryPositions = await trailingPositions(repositoryRow, {
actionName: /More options for/,
dateTestId: "repositories-row-date",