feat(desktop): make the Projects overview follow the selected section (#6335)

## Summary

Part 1 of 4 stacked PRs continuing the Projects work from #6003.

- The Projects overview now follows the selected section: the right-hand
context pod stays visible across the Projects / Repositories / Reviews /
Tasks / Channels tabs and shows section-specific people, stats, and
contribution graphs, so it reads as live context rather than a detached
summary.
- Entity list rows are consolidated onto one compact line (title first,
icons after, aligned dates/counts), shared across projects,
repositories, PRs, issues, and channel lists via `ProjectEntityListRow`.
- New `projectRelatedChannels` helper resolves the channels a project is
discussed in for the overview.

Follow-ups in this stack: part 2 (selectable workspaces), part 3
(context-aware collaboration), part 4 (navigation & detail-page polish).

## Test plan

- [x] Desktop unit tests (`pnpm test`) — pass
- [x] TypeScript (`tsc --noEmit`), Biome, clippy — clean via pre-push
gate
- [x] Playwright specs updated for the new overview behavior
(`projects-v3-screenshots.spec.ts`, `sidebar.spec.ts`) — pass locally in
the smoke project

---------

Signed-off-by: Thomas Petersen <thomasp@squareup.com>
This commit is contained in:
thomaspblock
2026-08-19 21:55:47 -04:00
committed by GitHub
parent 09718fbb60
commit f88cda9eb8
47 changed files with 4013 additions and 1186 deletions

View File

@@ -0,0 +1,185 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
collectProjectRelatedChannelRows,
projectRelatedChannelRowKey,
uniqueProjectRelatedChannelCount,
} from "./projectRelatedChannels.ts";
const CHANNEL_A = "11111111-1111-4111-8111-111111111111";
const CHANNEL_B = "22222222-2222-4222-8222-222222222222";
function makeProject(overrides = {}) {
return {
id: "project-buzz",
name: "buzz",
projectChannelId: null,
repositories: [],
...overrides,
};
}
function makeRepository(overrides = {}) {
return {
id: "repo-buzz",
name: "buzz",
channelId: CHANNEL_A,
...overrides,
};
}
test("collects one row per repository channel binding", () => {
const rows = collectProjectRelatedChannelRows([
makeProject({
repositories: [
makeRepository(),
makeRepository({
id: "repo-relay",
name: "relay-tools",
channelId: CHANNEL_A,
}),
],
}),
makeProject({
id: "project-design",
name: "design-system",
repositories: [
makeRepository({
id: "repo-design",
name: "design-system",
channelId: CHANNEL_A,
}),
],
}),
]);
assert.deepEqual(rows, [
{
channelId: CHANNEL_A,
projectId: "project-buzz",
projectName: "buzz",
repositoryId: "repo-buzz",
repositoryName: "buzz",
},
{
channelId: CHANNEL_A,
projectId: "project-buzz",
projectName: "buzz",
repositoryId: "repo-relay",
repositoryName: "relay-tools",
},
{
channelId: CHANNEL_A,
projectId: "project-design",
projectName: "design-system",
repositoryId: "repo-design",
repositoryName: "design-system",
},
]);
assert.equal(uniqueProjectRelatedChannelCount([]), 0);
assert.equal(
uniqueProjectRelatedChannelCount([
makeProject({
repositories: [
makeRepository(),
makeRepository({ id: "repo-relay", channelId: CHANNEL_A }),
],
}),
makeProject({
id: "project-design",
repositories: [makeRepository({ id: "repo-design" })],
}),
]),
1,
);
assert.equal(
uniqueProjectRelatedChannelCount([
makeProject({
projectChannelId: CHANNEL_B,
repositories: [makeRepository()],
}),
]),
2,
);
});
test("keeps a project channel only when no repository in that project shares it", () => {
assert.deepEqual(
collectProjectRelatedChannelRows([
makeProject({
projectChannelId: CHANNEL_A,
repositories: [makeRepository({ channelId: CHANNEL_A })],
}),
]),
[
{
channelId: CHANNEL_A,
projectId: "project-buzz",
projectName: "buzz",
repositoryId: "repo-buzz",
repositoryName: "buzz",
},
],
);
assert.deepEqual(
collectProjectRelatedChannelRows([
makeProject({
projectChannelId: CHANNEL_B,
repositories: [makeRepository({ channelId: CHANNEL_A })],
}),
]),
[
{
channelId: CHANNEL_A,
projectId: "project-buzz",
projectName: "buzz",
repositoryId: "repo-buzz",
repositoryName: "buzz",
},
{
channelId: CHANNEL_B,
projectId: "project-buzz",
projectName: "buzz",
repositoryId: null,
repositoryName: null,
},
],
);
});
test("skips blank channel ids", () => {
assert.deepEqual(
collectProjectRelatedChannelRows([
makeProject({
projectChannelId: " ",
repositories: [makeRepository({ channelId: "" })],
}),
]),
[],
);
});
test("row keys distinguish project-level bindings from repository bindings", () => {
assert.equal(
projectRelatedChannelRowKey({
channelId: CHANNEL_A,
projectId: "project-buzz",
projectName: "buzz",
repositoryId: null,
repositoryName: null,
}),
`${CHANNEL_A}:project-buzz:project`,
);
assert.equal(
projectRelatedChannelRowKey({
channelId: CHANNEL_A,
projectId: "project-buzz",
projectName: "buzz",
repositoryId: "repo-buzz",
repositoryName: "buzz",
}),
`${CHANNEL_A}:project-buzz:repo-buzz`,
);
});

View File

@@ -0,0 +1,75 @@
/** Bound project and repository channels shown on the Projects overview. */
export type ProjectRelatedChannelSource = {
id: string;
name: string;
projectChannelId: string | null;
repositories: Array<{
id: string;
name: string;
channelId?: string | null;
}>;
};
export type ProjectRelatedChannelRow = {
channelId: string;
projectId: string;
projectName: string;
repositoryId: string | null;
repositoryName: string | null;
};
function trimmedChannelId(value: string | null | undefined) {
const channelId = value?.trim() ?? "";
return channelId.length > 0 ? channelId : null;
}
/**
* One row per project or repository binding so the overview list can show
* which project and repository a channel belongs to. When a project channel
* is also bound to a repository in that project, only the repository row is
* kept.
*/
export function collectProjectRelatedChannelRows(
projects: readonly ProjectRelatedChannelSource[],
): ProjectRelatedChannelRow[] {
const rows: ProjectRelatedChannelRow[] = [];
for (const project of projects) {
const repositoryChannelIds = new Set<string>();
for (const repository of project.repositories) {
const channelId = trimmedChannelId(repository.channelId);
if (!channelId) continue;
repositoryChannelIds.add(channelId);
rows.push({
channelId,
projectId: project.id,
projectName: project.name,
repositoryId: repository.id,
repositoryName: repository.name,
});
}
const projectChannelId = trimmedChannelId(project.projectChannelId);
if (projectChannelId && !repositoryChannelIds.has(projectChannelId)) {
rows.push({
channelId: projectChannelId,
projectId: project.id,
projectName: project.name,
repositoryId: null,
repositoryName: null,
});
}
}
return rows;
}
export function uniqueProjectRelatedChannelCount(
projects: readonly ProjectRelatedChannelSource[],
) {
return new Set(
collectProjectRelatedChannelRows(projects).map((row) => row.channelId),
).size;
}
export function projectRelatedChannelRowKey(row: ProjectRelatedChannelRow) {
return `${row.channelId}:${row.projectId}:${row.repositoryId ?? "project"}`;
}

View File

@@ -1,30 +1,37 @@
import assert from "node:assert/strict";
import { beforeEach, test } from "node:test";
import test, { beforeEach } from "node:test";
import {
__resetProjectSidebarMembershipForTests,
addProjectToSidebar,
mergeProjectSidebarMembershipStores,
parseProjectSidebarMembershipPayload,
PROJECT_SIDEBAR_MEMBERSHIP_EVENT,
readProjectSidebarMembership,
removeProjectFromSidebar,
selectedProjectAddressesFromStore,
} from "./projectSidebarMembership.ts";
function store(projects = {}) {
return { version: 1, projects };
}
const RELAY = "wss://relay.example.com";
const PUBKEY = "a".repeat(64);
const store = new Map();
const backingStore = new Map();
let failWrites = false;
let failReads = false;
globalThis.localStorage = {
getItem: (key) => {
if (failReads) throw new Error("storage read unavailable");
return store.get(key) ?? null;
return backingStore.get(key) ?? null;
},
setItem: (key, value) => {
if (failWrites) throw new Error("storage write unavailable");
store.set(key, String(value));
backingStore.set(key, String(value));
},
removeItem: (key) => store.delete(key),
removeItem: (key) => backingStore.delete(key),
};
/** Captures the membership dispatched with each mutation. */
@@ -46,12 +53,91 @@ function captureDispatches() {
}
beforeEach(() => {
store.clear();
backingStore.clear();
failWrites = false;
failReads = false;
__resetProjectSidebarMembershipForTests();
});
test("migrates the legacy selected-address array", () => {
const parsed = parseProjectSidebarMembershipPayload([
"30621:alice:buzz",
"30621:alice:buzz",
"",
42,
]);
assert.deepEqual(parsed, {
version: 1,
projects: {
"30621:alice:buzz": { selected: true, updatedAt: 0 },
},
});
});
test("merge preserves independent selections from two clients", () => {
const merged = mergeProjectSidebarMembershipStores(
store({
"30621:alice:one": { selected: true, updatedAt: 100 },
}),
store({
"30621:alice:two": { selected: true, updatedAt: 200 },
}),
);
assert.deepEqual(selectedProjectAddressesFromStore(merged).sort(), [
"30621:alice:one",
"30621:alice:two",
]);
});
test("newer removal wins over an older selection", () => {
const merged = mergeProjectSidebarMembershipStores(
store({
"30621:alice:buzz": { selected: true, updatedAt: 100 },
}),
store({
"30621:alice:buzz": { selected: false, updatedAt: 200 },
}),
);
assert.deepEqual(selectedProjectAddressesFromStore(merged), []);
});
test("equal-timestamp conflicts converge with removal winning", () => {
const selected = store({
"30621:alice:buzz": { selected: true, updatedAt: 100 },
});
const removed = store({
"30621:alice:buzz": { selected: false, updatedAt: 100 },
});
assert.deepEqual(
mergeProjectSidebarMembershipStores(selected, removed),
mergeProjectSidebarMembershipStores(removed, selected),
);
assert.deepEqual(
selectedProjectAddressesFromStore(
mergeProjectSidebarMembershipStores(selected, removed),
),
[],
);
});
test("drops malformed entries and rejects unknown versions", () => {
assert.deepEqual(
parseProjectSidebarMembershipPayload({
version: 1,
projects: { project: { selected: "yes", updatedAt: 1 } },
}),
store(),
);
assert.equal(
parseProjectSidebarMembershipPayload({ version: 2, projects: {} }),
null,
);
});
test("membership round-trips through storage", () => {
addProjectToSidebar("30617:owner:alpha", RELAY, PUBKEY);
addProjectToSidebar("30617:owner:beta", RELAY, PUBKEY);

View File

@@ -3,90 +3,244 @@ export const PROJECT_SIDEBAR_MEMBERSHIP_EVENT =
"buzz:project-sidebar-membership-change";
/** Detail carried by {@link PROJECT_SIDEBAR_MEMBERSHIP_EVENT}: the computed
* membership for one relay/pubkey scope. Listeners must consume this rather
* than re-reading localStorage — when persistence fails the write never
* lands, and re-reading would silently revert the user's add/remove. */
* membership for one relay/pubkey scope. Listeners may consume this instead of
* re-reading storage — when persistence fails the write never lands, but the
* in-session scope below stays authoritative either way. */
export type ProjectSidebarMembershipChange = {
relayOrigin: string;
pubkey: string;
addresses: string[];
};
function membershipKey(relayOrigin: string, pubkey: string) {
export type ProjectSidebarMembershipEntry = {
selected: boolean;
updatedAt: number;
};
export type ProjectSidebarMembershipStore = {
version: 1;
projects: Record<string, ProjectSidebarMembershipEntry>;
};
export const EMPTY_PROJECT_SIDEBAR_MEMBERSHIP_STORE: ProjectSidebarMembershipStore =
Object.freeze({
version: 1,
projects: {},
});
export function projectSidebarMembershipStorageKey(
relayOrigin: string,
pubkey: string,
) {
return `${PROJECT_SIDEBAR_MEMBERSHIP_PREFIX}.${encodeURIComponent(relayOrigin)}.${pubkey.toLowerCase()}`;
}
export function parseProjectSidebarMembershipPayload(
value: unknown,
): ProjectSidebarMembershipStore | null {
if (Array.isArray(value)) {
return {
version: 1,
projects: Object.fromEntries(
value
.filter(
(address): address is string =>
typeof address === "string" && address.length > 0,
)
.map((address) => [
address,
{
selected: true,
updatedAt: 0,
} satisfies ProjectSidebarMembershipEntry,
]),
),
};
}
if (!value || typeof value !== "object") return null;
const candidate = value as Record<string, unknown>;
if (candidate.version !== 1) return null;
if (
!candidate.projects ||
typeof candidate.projects !== "object" ||
Array.isArray(candidate.projects)
) {
return null;
}
const projects = Object.fromEntries(
Object.entries(candidate.projects).filter(
(entry): entry is [string, ProjectSidebarMembershipEntry] => {
const membership = entry[1];
return (
entry[0].length > 0 &&
typeof membership === "object" &&
membership !== null &&
typeof (membership as Record<string, unknown>).selected ===
"boolean" &&
typeof (membership as Record<string, unknown>).updatedAt ===
"number" &&
Number.isFinite(
(membership as Record<string, unknown>).updatedAt as number,
) &&
((membership as Record<string, unknown>).updatedAt as number) >= 0
);
},
),
);
return { version: 1, projects };
}
/**
* Scope-keyed authoritative membership. localStorage is only the durable
* mirror: once a scope is seeded here, every read and mutation goes through
* this map, so `add(A) → add(B) → remove(A)` accumulates correctly even when
* every storage write fails — recomputing each mutation from storage would
* silently drop all but the latest unpersisted change.
* silently drop all but the latest unpersisted change. Scoping by
* relay origin and pubkey keeps entries from crossing tenant boundaries.
*/
const membershipByScope = new Map<string, string[]>();
const membershipStoreByScope = new Map<string, ProjectSidebarMembershipStore>();
/** Clears the in-memory authoritative scopes between test cases. */
export function __resetProjectSidebarMembershipForTests(): void {
membershipByScope.clear();
membershipStoreByScope.clear();
}
function dedupe(addresses: readonly unknown[]): string[] {
return [
...new Set(
addresses.filter(
(address): address is string =>
typeof address === "string" && address.length > 0,
),
),
];
}
function readStoredMembership(key: string): string[] {
function readStoredMembershipStore(key: string): ProjectSidebarMembershipStore {
try {
const parsed = JSON.parse(globalThis.localStorage?.getItem(key) ?? "[]");
return Array.isArray(parsed) ? dedupe(parsed) : [];
const raw = globalThis.localStorage?.getItem(key);
if (!raw) return EMPTY_PROJECT_SIDEBAR_MEMBERSHIP_STORE;
return (
parseProjectSidebarMembershipPayload(JSON.parse(raw)) ??
EMPTY_PROJECT_SIDEBAR_MEMBERSHIP_STORE
);
} catch {
return [];
return EMPTY_PROJECT_SIDEBAR_MEMBERSHIP_STORE;
}
}
export function readProjectSidebarMembershipStore(
relayOrigin: string | null | undefined,
pubkey: string | null | undefined,
): ProjectSidebarMembershipStore {
if (!relayOrigin || !pubkey) return EMPTY_PROJECT_SIDEBAR_MEMBERSHIP_STORE;
const key = projectSidebarMembershipStorageKey(relayOrigin, pubkey);
const cached = membershipStoreByScope.get(key);
const stored = readStoredMembershipStore(key);
if (!cached) {
membershipStoreByScope.set(key, stored);
return stored;
}
// Merge instead of preferring either side: the cache holds mutations whose
// persistence failed, while storage may hold newer cross-tab writes. The
// per-entry LWW merge keeps both.
if (projectSidebarMembershipStoresEqual(cached, stored)) return cached;
const merged = mergeProjectSidebarMembershipStores(cached, stored);
membershipStoreByScope.set(key, merged);
return merged;
}
export function selectedProjectAddressesFromStore(
store: ProjectSidebarMembershipStore,
): string[] {
return Object.entries(store.projects)
.filter(([, membership]) => membership.selected)
.map(([address]) => address);
}
export function readProjectSidebarMembership(
relayOrigin: string | null | undefined,
pubkey: string | null | undefined,
): string[] {
if (!relayOrigin || !pubkey) return [];
const key = membershipKey(relayOrigin, pubkey);
let addresses = membershipByScope.get(key);
if (!addresses) {
addresses = readStoredMembership(key);
membershipByScope.set(key, addresses);
}
return [...addresses];
return selectedProjectAddressesFromStore(
readProjectSidebarMembershipStore(relayOrigin, pubkey),
);
}
function writeProjectSidebarMembership(
export function mergeProjectSidebarMembershipStores(
local: ProjectSidebarMembershipStore,
remote: ProjectSidebarMembershipStore,
): ProjectSidebarMembershipStore {
const addresses = new Set([
...Object.keys(local.projects),
...Object.keys(remote.projects),
]);
const projects: Record<string, ProjectSidebarMembershipEntry> = {};
for (const address of addresses) {
const localEntry = local.projects[address];
const remoteEntry = remote.projects[address];
if (localEntry && remoteEntry) {
if (localEntry.updatedAt > remoteEntry.updatedAt) {
projects[address] = localEntry;
} else if (remoteEntry.updatedAt > localEntry.updatedAt) {
projects[address] = remoteEntry;
} else {
projects[address] =
localEntry.selected === remoteEntry.selected
? localEntry
: { selected: false, updatedAt: localEntry.updatedAt };
}
} else {
projects[address] = (localEntry ??
remoteEntry) as ProjectSidebarMembershipEntry;
}
}
return { version: 1, projects };
}
export function projectSidebarMembershipStoresEqual(
left: ProjectSidebarMembershipStore,
right: ProjectSidebarMembershipStore,
): boolean {
const leftKeys = Object.keys(left.projects);
const rightKeys = Object.keys(right.projects);
if (leftKeys.length !== rightKeys.length) return false;
return leftKeys.every((address) => {
const leftEntry = left.projects[address];
const rightEntry = right.projects[address];
return (
rightEntry !== undefined &&
leftEntry.selected === rightEntry.selected &&
leftEntry.updatedAt === rightEntry.updatedAt
);
});
}
/**
* Records `store` as the authoritative in-session state for the scope, then
* mirrors it to localStorage. Persistence is best-effort: on failure the
* in-memory scope stays authoritative, so sequential mutations never lose
* earlier unpersisted changes and the next successful write persists the
* accumulated state. Returns whether the mirror write landed.
*/
export function writeProjectSidebarMembershipStore(
relayOrigin: string,
pubkey: string,
addresses: readonly string[],
) {
const deduped = dedupe(addresses);
membershipByScope.set(membershipKey(relayOrigin, pubkey), deduped);
store: ProjectSidebarMembershipStore,
notify = true,
): boolean {
const key = projectSidebarMembershipStorageKey(relayOrigin, pubkey);
membershipStoreByScope.set(key, store);
let persisted = true;
try {
globalThis.localStorage?.setItem(
membershipKey(relayOrigin, pubkey),
JSON.stringify(deduped),
);
globalThis.localStorage?.setItem(key, JSON.stringify(store));
} catch {
// Persistence is best-effort; the in-memory scope above stays
// authoritative, so sequential add/remove never loses earlier
// unpersisted changes, and the event below updates every mounted view.
persisted = false;
}
globalThis.dispatchEvent?.(
new CustomEvent<ProjectSidebarMembershipChange>(
PROJECT_SIDEBAR_MEMBERSHIP_EVENT,
{ detail: { addresses: deduped, pubkey, relayOrigin } },
),
);
if (notify) {
globalThis.dispatchEvent?.(
new CustomEvent<ProjectSidebarMembershipChange>(
PROJECT_SIDEBAR_MEMBERSHIP_EVENT,
{
detail: {
addresses: selectedProjectAddressesFromStore(store),
pubkey,
relayOrigin,
},
},
),
);
}
return persisted;
}
export function addProjectToSidebar(
@@ -95,10 +249,14 @@ export function addProjectToSidebar(
pubkey: string | null | undefined,
) {
if (!relayOrigin || !pubkey) return;
writeProjectSidebarMembership(relayOrigin, pubkey, [
...readProjectSidebarMembership(relayOrigin, pubkey),
projectAddress,
]);
const current = readProjectSidebarMembershipStore(relayOrigin, pubkey);
writeProjectSidebarMembershipStore(relayOrigin, pubkey, {
version: 1,
projects: {
...current.projects,
[projectAddress]: { selected: true, updatedAt: Date.now() },
},
});
}
export function removeProjectFromSidebar(
@@ -107,11 +265,12 @@ export function removeProjectFromSidebar(
pubkey: string | null | undefined,
) {
if (!relayOrigin || !pubkey) return;
writeProjectSidebarMembership(
relayOrigin,
pubkey,
readProjectSidebarMembership(relayOrigin, pubkey).filter(
(address) => address !== projectAddress,
),
);
const current = readProjectSidebarMembershipStore(relayOrigin, pubkey);
writeProjectSidebarMembershipStore(relayOrigin, pubkey, {
version: 1,
projects: {
...current.projects,
[projectAddress]: { selected: false, updatedAt: Date.now() },
},
});
}

View File

@@ -0,0 +1,79 @@
import assert from "node:assert/strict";
import test, { mock } from "node:test";
import { relayClient } from "@/shared/api/relayClient";
import {
installFakeWindow,
makeFakeWindow,
} from "../../sidebar/lib/sidebarSyncTestHelpers.mjs";
import { ProjectSidebarMembershipSyncManager } from "./projectSidebarMembershipSync.ts";
const RELAY = "wss://projects.test";
function store(projects = {}) {
return { version: 1, projects };
}
test("first sync seeds non-empty local membership when remote is absent", async () => {
mock.method(relayClient, "fetchEvents", () => Promise.resolve([]));
const fakeWindow = makeFakeWindow();
const restore = installFakeWindow(fakeWindow);
try {
const manager = new ProjectSidebarMembershipSyncManager("pubkey", RELAY);
const result = await manager.bootstrap(
store({
"30621:alice:buzz": { selected: true, updatedAt: 1 },
}),
);
assert.equal(result.action, "hold");
assert.notEqual(manager.getPendingStore(), null);
manager.destroy();
} finally {
restore();
mock.reset();
}
});
test("failed fetch never seeds local membership", async () => {
mock.method(relayClient, "fetchEvents", () =>
Promise.reject(new Error("offline")),
);
const fakeWindow = makeFakeWindow();
const restore = installFakeWindow(fakeWindow);
try {
const manager = new ProjectSidebarMembershipSyncManager("pubkey", RELAY);
const result = await manager.bootstrap(
store({
"30621:alice:buzz": { selected: true, updatedAt: 1 },
}),
);
assert.equal(result.action, "hold");
assert.equal(manager.getPendingStore(), null);
manager.destroy();
} finally {
restore();
mock.reset();
}
});
test("destroy cancels a pending membership publish", () => {
mock.method(relayClient, "fetchEvents", () => Promise.resolve([]));
const fakeWindow = makeFakeWindow();
const restore = installFakeWindow(fakeWindow);
try {
const manager = new ProjectSidebarMembershipSyncManager("pubkey", RELAY);
manager.publishMembership(
store({
"30621:alice:buzz": { selected: true, updatedAt: 1 },
}),
);
manager.destroy();
assert.equal(manager.getPendingStore(), null);
} finally {
restore();
mock.reset();
}
});

View File

@@ -0,0 +1,221 @@
import { relayClient } from "@/shared/api/relayClient";
import {
nip44DecryptFromSelf,
nip44EncryptToSelf,
signRelayEvent,
} from "@/shared/api/tauri";
import type { RelayEvent } from "@/shared/api/types";
import { KIND_PROJECT_SIDEBAR_MEMBERSHIP } from "@/shared/constants/kinds";
import {
mergeProjectSidebarMembershipStores,
parseProjectSidebarMembershipPayload,
projectSidebarMembershipStoresEqual,
type ProjectSidebarMembershipStore,
} from "./projectSidebarMembership";
import {
advanceWatermark,
readWatermark,
runBootstrap,
type FetchResult,
} from "@/features/sidebar/lib/sidebarSyncWatermark";
const D_TAG = "project-sidebar-membership";
const BLOB_TYPE = D_TAG;
const DEBOUNCE_MS = 2_000;
export type RemoteProjectSidebarMembership = {
store: ProjectSidebarMembershipStore;
createdAt: number;
eventId: string;
};
async function decryptAndParse(
event: RelayEvent,
): Promise<RemoteProjectSidebarMembership | null> {
try {
const plaintext = await nip44DecryptFromSelf(event.content);
const store = parseProjectSidebarMembershipPayload(JSON.parse(plaintext));
if (!store) return null;
return { store, createdAt: event.created_at, eventId: event.id };
} catch {
return null;
}
}
export class ProjectSidebarMembershipSyncManager {
private pubkey: string;
private relayUrl: string;
private debounceTimer: number | null = null;
private lastRemoteCreatedAt: number;
private pendingStore: ProjectSidebarMembershipStore | null = null;
private lastPublishedStore: ProjectSidebarMembershipStore | null = null;
private destroyed = false;
constructor(pubkey: string, relayUrl: string) {
this.pubkey = pubkey;
this.relayUrl = relayUrl;
this.lastRemoteCreatedAt = readWatermark(pubkey, BLOB_TYPE, relayUrl);
}
async fetchRemoteMembership(): Promise<
FetchResult<RemoteProjectSidebarMembership>
> {
try {
const events = await relayClient.fetchEvents({
kinds: [KIND_PROJECT_SIDEBAR_MEMBERSHIP],
authors: [this.pubkey],
"#d": [D_TAG],
limit: 1,
});
if (events.length === 0 || events[0].pubkey !== this.pubkey) {
return { status: "absent" };
}
const event = events[0];
this.recordRemoteHead(event.created_at);
const result = await decryptAndParse(event);
if (!result) {
return { status: "failed", createdAt: event.created_at };
}
return {
status: "found",
data: result,
createdAt: result.createdAt,
eventId: result.eventId,
};
} catch {
return { status: "failed" };
}
}
private recordRemoteHead(createdAt: number): void {
if (createdAt > this.lastRemoteCreatedAt) {
this.lastRemoteCreatedAt = createdAt;
}
advanceWatermark(this.pubkey, BLOB_TYPE, this.relayUrl, createdAt);
}
cancelPendingPublish(): void {
if (this.debounceTimer !== null) {
window.clearTimeout(this.debounceTimer);
this.debounceTimer = null;
}
}
getPendingStore(): ProjectSidebarMembershipStore | null {
return this.pendingStore;
}
publishMembership(store: ProjectSidebarMembershipStore): void {
this.pendingStore = store;
if (this.debounceTimer !== null) {
window.clearTimeout(this.debounceTimer);
}
this.debounceTimer = window.setTimeout(() => {
this.debounceTimer = null;
void this.doPublish(store);
}, DEBOUNCE_MS);
}
private async fetchOwnBlobBeforePublish(
store: ProjectSidebarMembershipStore,
): Promise<ProjectSidebarMembershipStore> {
try {
const events = await relayClient.fetchEvents({
kinds: [KIND_PROJECT_SIDEBAR_MEMBERSHIP],
authors: [this.pubkey],
"#d": [D_TAG],
limit: 1,
});
if (events.length === 0 || events[0].pubkey !== this.pubkey) return store;
const event = events[0];
this.recordRemoteHead(event.created_at);
const remote = await decryptAndParse(event);
if (!remote) return store;
return mergeProjectSidebarMembershipStores(store, remote.store);
} catch {
return store;
}
}
private isIdenticalToLastPublished(
store: ProjectSidebarMembershipStore,
): boolean {
return (
this.lastPublishedStore !== null &&
projectSidebarMembershipStoresEqual(this.lastPublishedStore, store)
);
}
private async doPublish(store: ProjectSidebarMembershipStore): Promise<void> {
try {
const merged = await this.fetchOwnBlobBeforePublish(store);
if (this.destroyed) return;
if (this.isIdenticalToLastPublished(merged)) {
this.pendingStore = null;
return;
}
const ciphertext = await nip44EncryptToSelf(JSON.stringify(merged));
const createdAt = Math.max(
Math.floor(Date.now() / 1_000),
this.lastRemoteCreatedAt + 1,
);
const event = await signRelayEvent({
kind: KIND_PROJECT_SIDEBAR_MEMBERSHIP,
content: ciphertext,
createdAt,
tags: [
["d", D_TAG],
["t", D_TAG],
],
});
if (this.destroyed) return;
await relayClient.publishEvent(
event,
"Timed out publishing project sidebar membership.",
"Failed to publish project sidebar membership.",
);
this.recordRemoteHead(event.created_at);
this.lastPublishedStore = merged;
this.pendingStore = null;
} catch (error) {
console.warn("[projectSidebarMembershipSync] publish failed:", error);
}
}
async subscribe(
onUpdate: (remote: RemoteProjectSidebarMembership) => void,
): Promise<() => Promise<void>> {
return relayClient.subscribeLive(
{
kinds: [KIND_PROJECT_SIDEBAR_MEMBERSHIP],
authors: [this.pubkey],
"#d": [D_TAG],
limit: 0,
},
(event: RelayEvent) => {
if (event.pubkey !== this.pubkey) return;
this.recordRemoteHead(event.created_at);
void decryptAndParse(event).then((result) => {
if (result) onUpdate(result);
});
},
);
}
async bootstrap(localStore: ProjectSidebarMembershipStore) {
const fetchResult = await this.fetchRemoteMembership();
return runBootstrap({
fetchResult,
lastHead: this.lastRemoteCreatedAt,
localStore,
isLocalNonEmpty: (store) => Object.keys(store.projects).length > 0,
publishFn: (store) => this.publishMembership(store),
});
}
destroy(): void {
this.destroyed = true;
this.cancelPendingPublish();
this.pendingStore = null;
}
}

View File

@@ -5,6 +5,7 @@ import {
isProjectAccessibleToViewer,
isProjectMine,
isRepositoryAccessibleToViewer,
listRowDescription,
nextRepositoryEntryLimit,
relativeTime,
} from "./projectsViewHelpers.ts";
@@ -226,3 +227,21 @@ test("repository entry pagination advances and clamps to the total", () => {
assert.equal(nextRepositoryEntryLimit(400, 450), 450);
assert.equal(nextRepositoryEntryLimit(450, 450), 450);
});
test("listRowDescription keeps real copy and drops empty or title-duplicate text", () => {
assert.equal(
listRowDescription("The complete Buzz community platform."),
"The complete Buzz community platform.",
);
assert.equal(listRowDescription(" "), undefined);
assert.equal(listRowDescription(""), undefined);
assert.equal(listRowDescription(null), undefined);
assert.equal(
listRowDescription(
"Fix reconnect backoff jitter",
"Fix reconnect backoff jitter",
),
undefined,
);
assert.equal(listRowDescription("**Hello** world"), "Hello world");
});

View File

@@ -24,6 +24,7 @@ export type ProjectsFilter =
| "local"
| "projects"
| "repositories"
| "channels"
| "prs"
| "issues"
| "agents"
@@ -87,6 +88,7 @@ export function readStoredFilter(): ProjectsFilter {
value === "local" ||
value === "projects" ||
value === "repositories" ||
value === "channels" ||
value === "prs" ||
value === "issues" ||
value === "agents" ||
@@ -236,6 +238,24 @@ export function markdownToPlainText(input: string): string {
);
}
/** One-line list subtitle. Empty, whitespace-only, and title-duplicate bodies stay hidden. */
export function listRowDescription(
value: string | null | undefined,
title?: string,
): string | undefined {
const text = markdownToPlainText(value ?? "")
.replace(/\s+/g, " ")
.trim();
if (text.length === 0) return undefined;
if (
title &&
text.localeCompare(title.trim(), undefined, { sensitivity: "accent" }) === 0
) {
return undefined;
}
return text;
}
export function formatCreatedDate(createdAt: number) {
return new Date(createdAt * 1_000).toLocaleDateString(undefined, {
month: "short",

View File

@@ -0,0 +1,173 @@
import * as React from "react";
import { relayClient } from "@/shared/api/relayClient";
import {
EMPTY_PROJECT_SIDEBAR_MEMBERSHIP_STORE,
mergeProjectSidebarMembershipStores,
PROJECT_SIDEBAR_MEMBERSHIP_EVENT,
projectSidebarMembershipStoresEqual,
projectSidebarMembershipStorageKey,
readProjectSidebarMembershipStore,
selectedProjectAddressesFromStore,
writeProjectSidebarMembershipStore,
type ProjectSidebarMembershipStore,
} from "./projectSidebarMembership";
import {
ProjectSidebarMembershipSyncManager,
type RemoteProjectSidebarMembership,
} from "./projectSidebarMembershipSync";
export function useProjectSidebarMembership(
relayUrl: string | null,
pubkey: string | undefined,
): string[] {
const [store, setStore] = React.useState<ProjectSidebarMembershipStore>(
EMPTY_PROJECT_SIDEBAR_MEMBERSHIP_STORE,
);
const managerRef = React.useRef<ProjectSidebarMembershipSyncManager | null>(
null,
);
const lastAppliedRemoteTs = React.useRef(0);
const lastAppliedEventId = React.useRef("");
React.useEffect(() => {
if (!relayUrl || !pubkey) {
setStore(EMPTY_PROJECT_SIDEBAR_MEMBERSHIP_STORE);
lastAppliedRemoteTs.current = 0;
lastAppliedEventId.current = "";
return;
}
setStore(readProjectSidebarMembershipStore(relayUrl, pubkey));
lastAppliedRemoteTs.current = 0;
lastAppliedEventId.current = "";
managerRef.current = new ProjectSidebarMembershipSyncManager(
pubkey,
relayUrl,
);
return () => {
managerRef.current?.destroy();
managerRef.current = null;
};
}, [pubkey, relayUrl]);
React.useEffect(() => {
if (!relayUrl || !pubkey) return;
const refreshAndPublish = () => {
const next = readProjectSidebarMembershipStore(relayUrl, pubkey);
setStore(next);
managerRef.current?.publishMembership(next);
};
globalThis.addEventListener(
PROJECT_SIDEBAR_MEMBERSHIP_EVENT,
refreshAndPublish,
);
return () => {
globalThis.removeEventListener(
PROJECT_SIDEBAR_MEMBERSHIP_EVENT,
refreshAndPublish,
);
};
}, [pubkey, relayUrl]);
React.useEffect(() => {
if (!relayUrl || !pubkey) return;
const key = projectSidebarMembershipStorageKey(relayUrl, pubkey);
const handleStorage = (event: StorageEvent) => {
if (event.key === key) {
setStore(readProjectSidebarMembershipStore(relayUrl, pubkey));
}
};
globalThis.addEventListener("storage", handleStorage);
return () => globalThis.removeEventListener("storage", handleStorage);
}, [pubkey, relayUrl]);
const applyRemote = React.useCallback(
(
remote: RemoteProjectSidebarMembership,
): ((
current: ProjectSidebarMembershipStore,
) => ProjectSidebarMembershipStore) =>
(current) => {
if (!relayUrl || !pubkey) return current;
if (remote.createdAt < lastAppliedRemoteTs.current) return current;
if (
remote.createdAt === lastAppliedRemoteTs.current &&
remote.eventId <= lastAppliedEventId.current
) {
return current;
}
lastAppliedRemoteTs.current = remote.createdAt;
lastAppliedEventId.current = remote.eventId;
managerRef.current?.cancelPendingPublish();
const merged = mergeProjectSidebarMembershipStores(
current,
remote.store,
);
// The write records merged as the authoritative in-session state even
// when the localStorage mirror fails, so the merge always applies.
writeProjectSidebarMembershipStore(relayUrl, pubkey, merged, false);
if (!projectSidebarMembershipStoresEqual(merged, remote.store)) {
managerRef.current?.publishMembership(merged);
}
return merged;
},
[pubkey, relayUrl],
);
React.useEffect(() => {
if (!relayUrl || !pubkey) return;
let cancelled = false;
const local = readProjectSidebarMembershipStore(relayUrl, pubkey);
void managerRef.current?.bootstrap(local).then((result) => {
if (cancelled) return;
if (result.action === "apply-remote") {
setStore(applyRemote(result.data));
}
});
return () => {
cancelled = true;
};
}, [applyRemote, pubkey, relayUrl]);
React.useEffect(() => {
if (!relayUrl || !pubkey) return;
let unsubscribe: (() => Promise<void>) | null = null;
let cancelled = false;
void managerRef.current
?.subscribe((remote) => {
if (!cancelled) setStore(applyRemote(remote));
})
.then((dispose) => {
if (cancelled) {
void dispose();
} else {
unsubscribe = dispose;
}
});
return () => {
cancelled = true;
if (unsubscribe) void unsubscribe();
};
}, [applyRemote, pubkey, relayUrl]);
React.useEffect(() => {
if (!relayUrl || !pubkey) return;
let cancelled = false;
const unsubscribe = relayClient.subscribeToReconnects(() => {
void managerRef.current?.fetchRemoteMembership().then((result) => {
if (cancelled) return;
if (result.status === "found") {
setStore(applyRemote(result.data));
}
const pending = managerRef.current?.getPendingStore();
if (pending) managerRef.current?.publishMembership(pending);
});
});
return () => {
cancelled = true;
unsubscribe();
};
}, [applyRemote, pubkey, relayUrl]);
return React.useMemo(() => selectedProjectAddressesFromStore(store), [store]);
}

View File

@@ -1,4 +1,4 @@
import { Hash, MessageSquare } from "lucide-react";
import { Hash } from "lucide-react";
import * as React from "react";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
@@ -22,7 +22,10 @@ import { KIND_FORUM_COMMENT, KIND_FORUM_POST } from "@/shared/constants/kinds";
import { cn } from "@/shared/lib/cn";
import { BuzzLoadingState } from "@/shared/ui/BuzzLoadingState";
import { Markdown } from "@/shared/ui/markdown";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import {
ProjectEntityFacepile,
ProjectEntityListRow,
} from "./ProjectEntityListRow";
import { useProjectConversationPanel } from "./ProjectConversationPanelContext";
// Relay search caps a page at 500. Use the full page and surface a lower-bound
@@ -202,7 +205,7 @@ export function DiscussedInChannels({
type="button"
/>
<span className="relative z-10 pt-0.5">
<ParticipantFacepile
<ProjectEntityFacepile
interactive
participants={channel.participants}
profiles={profiles}
@@ -325,69 +328,6 @@ function DiscussionNameList({
);
}
function ParticipantFacepile({
interactive = false,
participants,
profiles,
}: {
/** Wrap each avatar in a profile popover. Leave off when the facepile is
* nested inside another button (nested interactive elements are invalid). */
interactive?: boolean;
participants: string[];
profiles: UserProfileLookup | undefined;
}) {
const shown = participants.slice(0, 4);
const overflow = participants.length - shown.length;
return (
<span className="flex shrink-0 items-center">
{shown.map((pubkey, index) => {
const label = resolveUserLabel({ profiles, pubkey });
if (!interactive) {
return (
<span
className={cn(index > 0 && "-ml-1.5")}
key={pubkey}
title={label}
>
<UserAvatar
avatarUrl={profiles?.[pubkey]?.avatarUrl ?? null}
className="rounded-full ring-2 ring-background"
displayName={label}
size="xs"
/>
</span>
);
}
return (
<UserProfilePopover
key={pubkey}
pubkey={pubkey}
triggerElement="span"
>
<button
className={cn("rounded-full", index > 0 && "-ml-1.5")}
title={label}
type="button"
>
<UserAvatar
avatarUrl={profiles?.[pubkey]?.avatarUrl ?? null}
className="rounded-full ring-2 ring-background"
displayName={label}
size="xs"
/>
</button>
</UserProfilePopover>
);
})}
{overflow > 0 ? (
<span className="-ml-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-muted text-3xs text-muted-foreground ring-2 ring-background">
+{overflow}
</span>
) : null}
</span>
);
}
/**
* Full-width channel list for the workspace "Channels" tab: every channel
* where the repository (or its PRs/issues) is linked in chat, with the
@@ -428,56 +368,26 @@ export function DiscussionChannelsPanel({
const name = channelName(channel.id, channel.name);
return (
<li className="relative" key={channel.id}>
<button
className="flex min-h-9 w-full min-w-0 items-center gap-2 px-4 py-1.5 text-left transition-colors hover:bg-muted/30"
data-testid="project-channel-row"
<ProjectEntityListRow
affiliation={repositoryName}
affiliationTestId="project-channel-repository"
count={channel.messageCount}
countSuffix={isTruncated ? "+" : undefined}
countTestId="project-channel-message-count"
countTitle={`${channel.messageCount}${isTruncated ? "+" : ""} ${
channel.messageCount === 1 ? "message" : "messages"
}`}
dateSeconds={channel.lastActivityAt}
dateTestId="project-channel-row-date"
icon={<Hash className="h-3.5 w-3.5 text-muted-foreground/70" />}
onClick={() => void goChannel(channel.id)}
title={`Open #${name}`}
type="button"
>
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
<Hash className="h-3.5 w-3.5 text-muted-foreground/70" />
</span>
<span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
#{name}
</span>
<span
className="hidden w-28 shrink-0 truncate text-right text-xs text-muted-foreground md:block"
data-testid="project-channel-repository"
title={repositoryName}
>
{repositoryName}
</span>
<span
className="flex w-20 shrink-0 justify-end"
data-testid="project-channel-participants"
>
<ParticipantFacepile
participants={channel.participants}
profiles={profiles}
/>
</span>
<span
className="flex w-12 shrink-0 items-center justify-end gap-1 text-xs text-muted-foreground"
data-testid="project-channel-message-count"
title={`${channel.messageCount}${isTruncated ? "+" : ""} ${
channel.messageCount === 1 ? "message" : "messages"
}`}
>
<MessageSquare className="h-3.5 w-3.5" />
{channel.messageCount}
{isTruncated ? "+" : ""}
</span>
<span
className="hidden w-20 shrink-0 whitespace-nowrap text-right text-xs text-muted-foreground/70 sm:block"
data-testid="project-channel-row-date"
title={new Date(
channel.lastActivityAt * 1_000,
).toLocaleString()}
>
{relativeTime(channel.lastActivityAt)}
</span>
</button>
people={channel.participants}
peopleTestId="project-channel-participants"
profiles={profiles}
testId="project-channel-row"
title={`#${name}`}
titleAttr={`Open #${name}`}
/>
</li>
);
})}

View File

@@ -7,11 +7,13 @@ import { UserAvatar } from "@/shared/ui/UserAvatar";
/** Compact work-item author identity with a minimal hover summary. */
export function ProjectAuthorIdentity({
label,
labelClassName,
profiles,
pubkey,
testId,
}: {
label: string;
labelClassName?: string;
profiles?: UserProfileLookup;
pubkey: string;
testId?: string;
@@ -29,6 +31,7 @@ export function ProjectAuthorIdentity({
<Tooltip>
<TooltipTrigger asChild>
<button
aria-label={label}
className="relative z-10 inline-flex items-center gap-1 rounded-sm hover:underline focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"
data-testid={testId}
type="button"
@@ -41,7 +44,10 @@ export function ProjectAuthorIdentity({
size="xs"
testId={testId ? `${testId}-avatar` : undefined}
/>
<span data-testid={testId ? `${testId}-label` : undefined}>
<span
className={labelClassName}
data-testid={testId ? `${testId}-label` : undefined}
>
{label}
</span>
</button>

View File

@@ -21,19 +21,13 @@ import type {
import {
formatExactTimestamp,
getProjectUpdatedAt,
listRowDescription,
relativeTime,
} from "@/features/projects/lib/projectsViewHelpers";
import type { ProjectRepoUnavailableReason } from "@/features/projects/lib/projectRepoAvailability";
import { projectShareLink } from "@/features/projects/lib/projectShareLinks";
import { projectTerminalLabel } from "@/features/projects/ui/useOpenProjectTerminal";
import {
PROJECT_LIST_ROW_CLASS,
PROJECT_LIST_ROW_DATE_CLASS,
PROJECT_LIST_ROW_META_TEXT_CLASS,
PROJECT_LIST_ROW_PREVIEW_CLASS,
PROJECT_LIST_ROW_TITLE_CLASS,
PROJECT_LIST_ROW_TRAILING_CLASS,
} from "@/features/projects/ui/projectListRowStyles";
import { PROJECT_LIST_ROW_META_TEXT_CLASS } from "@/features/projects/ui/projectListRowStyles";
import { cn } from "@/shared/lib/cn";
import { normalizePubkey } from "@/shared/lib/pubkey";
import {
@@ -52,6 +46,7 @@ import { DropdownMenuItem } from "@/shared/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import { CopyShareLinkMenuItem } from "./CopyShareLinkMenuItem";
import { ProjectEntityListRow } from "./ProjectEntityListRow";
import { ProjectListRowMenu } from "./ProjectListRowMenu";
function ProjectUpdatedLabel({
@@ -575,89 +570,46 @@ export function ProjectListRow({
onOpen,
onOpenTerminal,
}: ProjectItemProps) {
const repositoryCount = project.repositoryAddresses.length;
return (
<div
className={cn(PROJECT_LIST_ROW_CLASS, "py-3")}
data-testid={`project-row-${project.dtag}`}
>
<ProjectCardButton onOpen={onOpen} project={project} />
<div className="pointer-events-none relative z-10 flex min-w-0 items-center gap-2.5">
<div className="flex min-w-0 flex-1 items-center gap-2.5">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-border/60 bg-muted/40">
<Folders className="h-4.5 w-4.5 text-muted-foreground" />
</span>
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<span className={PROJECT_LIST_ROW_TITLE_CLASS}>
{project.name}
</span>
<StatusPill status={project.status} />
</div>
<p className={PROJECT_LIST_ROW_PREVIEW_CLASS}>
{project.description || "A shared space for internal git work."}
</p>
</div>
</div>
<div
className={cn(PROJECT_LIST_ROW_TRAILING_CLASS, "pointer-events-auto")}
>
<div
className="hidden w-24 shrink-0 text-xs text-muted-foreground md:block"
data-testid="projects-row-context"
>
{project.repositoryAddresses.length}{" "}
{project.repositoryAddresses.length === 1
? "repository"
: "repositories"}
</div>
<div
className="flex w-6 shrink-0 justify-center"
data-testid="projects-row-repository-status"
>
<ProjectEntityListRow
affiliation={`${repositoryCount} ${
repositoryCount === 1 ? "repository" : "repositories"
}`}
affiliationTestId="projects-row-context"
dateSeconds={getProjectUpdatedAt(project, summary)}
dateTestId="projects-row-date"
description={listRowDescription(project.description, project.name)}
descriptionTestId="projects-row-description"
icon={<Folders className="h-3.5 w-3.5 text-muted-foreground/70" />}
onClick={() => onOpen(project)}
people={people}
peopleTestId="projects-row-people"
profiles={profiles}
testId={`project-row-${project.dtag}`}
title={
<span className="flex min-w-0 items-center gap-2">
<span className="truncate">{project.name}</span>
<StatusPill status={project.status} />
<span data-testid="projects-row-repository-status">
<RepositoryUnavailableIndicator
reason={repositoryUnavailableReason}
/>
</div>
<div
className="hidden items-center xl:flex"
data-testid="projects-row-summary"
>
<div className="w-32 shrink-0">
<ProjectActivityBar summary={summary} />
</div>
</div>
<div
className="hidden w-24 shrink-0 justify-end lg:flex"
data-testid="projects-row-people"
>
<ProjectPeopleStack
profiles={profiles}
pubkeys={people}
workOwnerPubkey={project.owner}
/>
</div>
<div
className={PROJECT_LIST_ROW_DATE_CLASS}
data-testid="projects-row-date"
>
<ProjectUpdatedLabel
profiles={profiles}
project={project}
summary={summary}
/>
</div>
<ProjectActionsMenu
canDelete={canDelete}
disabled={deleteDisabled}
hasLocal={hasLocal}
onDelete={onDelete}
onOpenTerminal={onOpenTerminal}
project={project}
/>
</div>
</div>
</div>
</span>
</span>
}
titleAttr={project.name}
trailing={
<ProjectActionsMenu
canDelete={canDelete}
disabled={deleteDisabled}
hasLocal={hasLocal}
onDelete={onDelete}
onOpenTerminal={onOpenTerminal}
project={project}
/>
}
/>
);
}

View File

@@ -132,3 +132,62 @@ export function ProjectDetailChrome({
</AppTopChromePortal>
);
}
const BREADCRUMB_BUTTON_CLASS =
"flex shrink-0 items-center gap-1.5 rounded-md px-1 py-1 font-medium transition-colors hover:text-sidebar-accent-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring";
export function ProjectsWorkspaceChrome({
actions,
onGoActivity,
section,
}: {
actions: React.ReactNode;
onGoActivity: () => void;
section: string;
}) {
const onActivity = section === "Activity";
return (
<AppTopChromePortal>
<div
className="flex min-w-0 flex-1 items-center justify-between gap-3 pl-2"
data-tauri-drag-region
data-testid="projects-workspace-chrome"
>
<nav
aria-label="Projects breadcrumb"
className="absolute flex max-w-[50%] min-w-0 -translate-x-1/2 -translate-y-px items-center gap-0.5 text-xs text-sidebar-foreground/65"
style={{
left: "calc(50% + var(--app-top-chrome-center-offset, 0rem))",
}}
>
{onActivity ? (
<span className="flex min-w-0 items-center gap-1.5 px-1 py-1 font-medium">
<Folders className="h-3.5 w-3.5 shrink-0" />
Projects
</span>
) : (
<button
className={BREADCRUMB_BUTTON_CLASS}
onClick={onGoActivity}
type="button"
>
<Folders className="h-3.5 w-3.5 shrink-0" />
Projects
</button>
)}
<ChevronRight className="h-3 w-3 shrink-0 opacity-60" />
<span
aria-current="page"
className="min-w-0 truncate px-0.5 font-medium opacity-60"
>
{section}
</span>
</nav>
<div className="ml-auto flex shrink-0 items-center gap-0.5">
{actions}
</div>
</div>
</AppTopChromePortal>
);
}

View File

@@ -0,0 +1,256 @@
import { MessageSquare } from "lucide-react";
import type * as React from "react";
import {
resolveUserLabel,
type UserProfileLookup,
} from "@/features/profile/lib/identity";
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
import { relativeTime } from "@/features/projects/lib/projectsViewHelpers";
import { cn } from "@/shared/lib/cn";
import { UserAvatar } from "@/shared/ui/UserAvatar";
/** One-line list row: title, optional description column, then trailing metadata. */
export const PROJECT_ENTITY_LIST_ROW_CLASS =
"flex min-h-9 w-full min-w-0 items-center gap-3 px-4 py-1.5 text-left transition-colors hover:bg-muted/30";
export function ProjectEntityFacepile({
interactive = false,
participants,
profiles,
}: {
/** Wrap each avatar in a profile popover. Leave off when the facepile is
* nested inside another button (nested interactive elements are invalid). */
interactive?: boolean;
participants: string[];
profiles: UserProfileLookup | undefined;
}) {
const shown = participants.slice(0, 4);
const overflow = participants.length - shown.length;
if (shown.length === 0) return null;
return (
<span className="flex shrink-0 items-center">
{shown.map((pubkey, index) => {
const label = resolveUserLabel({ profiles, pubkey });
if (!interactive) {
return (
<span
className={cn(index > 0 && "-ml-1.5")}
key={pubkey}
title={label}
>
<UserAvatar
avatarUrl={profiles?.[pubkey]?.avatarUrl ?? null}
className="rounded-full ring-2 ring-background"
displayName={label}
size="xs"
/>
</span>
);
}
return (
<UserProfilePopover
key={pubkey}
pubkey={pubkey}
triggerElement="span"
>
<button
className={cn("rounded-full", index > 0 && "-ml-1.5")}
title={label}
type="button"
>
<UserAvatar
avatarUrl={profiles?.[pubkey]?.avatarUrl ?? null}
className="rounded-full ring-2 ring-background"
displayName={label}
size="xs"
/>
</button>
</UserProfilePopover>
);
})}
{overflow > 0 ? (
<span className="-ml-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-muted text-3xs text-muted-foreground ring-2 ring-background">
+{overflow}
</span>
) : null}
</span>
);
}
export function ProjectEntityListRow({
affiliation,
affiliationTestId,
affiliationTitle,
count,
countSuffix,
countTestId,
countTitle,
dateSeconds,
dateTestId,
description,
descriptionTestId,
icon,
onClick,
people,
peopleSlot,
peopleTestId,
profiles,
testId,
title,
titleAttr,
trailing,
}: {
affiliation?: React.ReactNode;
affiliationTestId?: string;
affiliationTitle?: string;
count?: number | null;
countSuffix?: string;
countTestId?: string;
countTitle?: string;
dateSeconds?: number | null;
dateTestId?: string;
description?: string;
descriptionTestId?: string;
icon: React.ReactNode;
onClick?: () => void;
people?: string[];
peopleSlot?: React.ReactNode;
peopleTestId?: string;
profiles?: UserProfileLookup;
testId?: string;
title: React.ReactNode;
titleAttr?: string;
trailing?: React.ReactNode;
}) {
const peopleContent =
peopleSlot ??
(people && people.length > 0 ? (
<ProjectEntityFacepile
interactive={Boolean(trailing)}
participants={people}
profiles={profiles}
/>
) : null);
const interactiveSlotClass = trailing ? "pointer-events-auto" : undefined;
const body = (
<>
<span
className={cn(
"flex h-4 w-4 shrink-0 items-center justify-center",
interactiveSlotClass,
)}
>
{icon}
</span>
<span
className={cn(
"min-w-0 truncate text-sm font-medium text-foreground",
description ? "flex-1 lg:w-44 lg:flex-none lg:shrink-0" : "flex-1",
)}
>
{title}
</span>
{description ? (
<span
className="hidden min-w-0 flex-1 truncate text-xs text-muted-foreground lg:block"
data-testid={descriptionTestId}
title={description}
>
{description}
</span>
) : null}
{affiliation ? (
<span
className="hidden w-36 shrink-0 truncate text-right text-xs text-muted-foreground md:block"
data-testid={affiliationTestId}
title={
affiliationTitle ??
(typeof affiliation === "string" ? affiliation : undefined)
}
>
{affiliation}
</span>
) : null}
<span
className={cn("flex w-24 shrink-0 justify-end", interactiveSlotClass)}
data-testid={peopleTestId}
>
{peopleContent}
</span>
{count != null ? (
<span
className="flex w-12 shrink-0 items-center justify-end gap-1 text-xs text-muted-foreground"
data-testid={countTestId}
title={countTitle}
>
<MessageSquare className="h-3.5 w-3.5" />
{count}
{countSuffix}
</span>
) : null}
{dateSeconds ? (
<span
className="hidden w-24 shrink-0 whitespace-nowrap text-right text-xs text-muted-foreground/70 sm:block"
data-testid={dateTestId}
title={new Date(dateSeconds * 1_000).toLocaleString()}
>
{relativeTime(dateSeconds)}
</span>
) : (
<span className="hidden w-24 shrink-0 sm:block" />
)}
{trailing ? (
<span className="pointer-events-auto relative z-10 shrink-0">
{trailing}
</span>
) : null}
</>
);
if (trailing) {
return (
<div className="group relative" data-testid={testId}>
{onClick ? (
<button
className="absolute inset-0"
onClick={onClick}
title={titleAttr}
type="button"
>
<span className="sr-only">{titleAttr}</span>
</button>
) : null}
<div
className={cn(
PROJECT_ENTITY_LIST_ROW_CLASS,
"pointer-events-none relative z-10 group-hover:bg-muted/30",
)}
>
{body}
</div>
</div>
);
}
if (onClick) {
return (
<button
className={PROJECT_ENTITY_LIST_ROW_CLASS}
data-testid={testId}
onClick={onClick}
title={titleAttr}
type="button"
>
{body}
</button>
);
}
return (
<div className={PROJECT_ENTITY_LIST_ROW_CLASS} data-testid={testId}>
{body}
</div>
);
}

View File

@@ -411,7 +411,7 @@ export function ProjectRepositoryActionsPanel({
<RepositoryPeople people={people} profiles={profiles} />
<dl className="text-sm [&>div]:h-7 [&_dd]:ml-auto [&_dd]:tabular-nums [&_dt]:gap-3 [&_dt]:text-foreground [&_dt_svg]:h-4 [&_dt_svg]:w-4 [&_dt_svg]:shrink-0 [&_dt_svg]:text-muted-foreground">
<div className="flex items-center justify-between gap-3">
<dt className="flex items-center gap-1.5 text-muted-foreground">
<dt className="flex items-center gap-3 text-muted-foreground">
<GitCommitHorizontal className="h-3.5 w-3.5" />
Latest
</dt>
@@ -420,7 +420,7 @@ export function ProjectRepositoryActionsPanel({
</dd>
</div>
<div className="flex items-center justify-between gap-3">
<dt className="flex items-center gap-1.5 text-muted-foreground">
<dt className="flex items-center gap-3 text-muted-foreground">
<FileCode2 className="h-3.5 w-3.5" />
Files
</dt>
@@ -432,7 +432,7 @@ export function ProjectRepositoryActionsPanel({
</dd>
</div>
<div className="flex items-center justify-between gap-3">
<dt className="flex items-center gap-1.5 text-muted-foreground">
<dt className="flex items-center gap-3 text-muted-foreground">
<Users className="h-3.5 w-3.5" />
Contributors
</dt>
@@ -463,7 +463,7 @@ export function ProjectRepositoryActionsPanel({
{activeTab !== "prs" ? (
<>
<div className="flex items-center justify-between gap-3">
<dt className="flex items-center gap-1.5 text-muted-foreground">
<dt className="flex items-center gap-3 text-muted-foreground">
<CircleDot className="h-3.5 w-3.5" />
{activeTab === "issues" ? "Tasks" : "Active tasks"}
</dt>
@@ -476,7 +476,7 @@ export function ProjectRepositoryActionsPanel({
{activeTab === "issues" ? (
<>
<div className="flex items-center justify-between gap-3">
<dt className="flex items-center gap-1.5 text-muted-foreground">
<dt className="flex items-center gap-3 text-muted-foreground">
<CircleDot className="h-3.5 w-3.5" />
Active
</dt>
@@ -485,7 +485,7 @@ export function ProjectRepositoryActionsPanel({
</dd>
</div>
<div className="flex items-center justify-between gap-3">
<dt className="flex items-center gap-1.5 text-muted-foreground">
<dt className="flex items-center gap-3 text-muted-foreground">
<CheckCircle2 className="h-3.5 w-3.5" />
Completed
</dt>
@@ -500,7 +500,7 @@ export function ProjectRepositoryActionsPanel({
{activeTab !== "issues" ? (
<>
<div className="flex items-center justify-between gap-3">
<dt className="flex items-center gap-1.5 text-muted-foreground">
<dt className="flex items-center gap-3 text-muted-foreground">
<GitPullRequest className="h-3.5 w-3.5" />
{activeTab === "prs" ? "Reviews" : "Open reviews"}
</dt>
@@ -513,7 +513,7 @@ export function ProjectRepositoryActionsPanel({
{activeTab === "prs" ? (
<>
<div className="flex items-center justify-between gap-3">
<dt className="flex items-center gap-1.5 text-muted-foreground">
<dt className="flex items-center gap-3 text-muted-foreground">
<GitPullRequest className="h-3.5 w-3.5" />
Open
</dt>
@@ -522,7 +522,7 @@ export function ProjectRepositoryActionsPanel({
</dd>
</div>
<div className="flex items-center justify-between gap-3">
<dt className="flex items-center gap-1.5 text-muted-foreground">
<dt className="flex items-center gap-3 text-muted-foreground">
<GitMerge className="h-3.5 w-3.5" />
Merged
</dt>

View File

@@ -6,7 +6,7 @@ import {
} from "@/features/terminal/terminalPanelStore";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import terminalIcon from "@/shared/ui/assets/terminal.svg";
import { TerminalPanelIcon } from "@/shared/ui/TerminalPanelIcon";
export type ProjectRightPanelMode = "chat" | "repository";
@@ -95,16 +95,10 @@ export function ProjectRightPanelControls({
type="button"
variant="ghost"
>
<span
aria-hidden="true"
className="h-4 w-[1.1rem] bg-current [mask-position:center] [mask-repeat:no-repeat]"
<TerminalPanelIcon
className="w-[1.1rem]"
data-testid="project-terminal-icon"
style={{
maskImage: `url("${terminalIcon}")`,
maskSize: "calc(100% - 2px) calc(100% - 2px)",
WebkitMaskImage: `url("${terminalIcon}")`,
WebkitMaskSize: "calc(100% - 2px) calc(100% - 2px)",
}}
open={terminalOpen}
/>
</Button>
</div>

View File

@@ -1,10 +1,13 @@
import { type LucideIcon, Plus } from "lucide-react";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
export function ProjectSectionHeader({
action,
className,
icon: Icon,
testId = "project-section-header",
title,
}: {
action?: {
@@ -13,13 +16,15 @@ export function ProjectSectionHeader({
onClick: () => void;
title?: string;
};
className?: string;
icon: LucideIcon;
testId?: string;
title: string;
}) {
return (
<header
className="flex min-h-12 items-center gap-3 px-4 py-2"
data-testid="project-section-header"
className={cn("flex min-h-12 items-center gap-3 px-4 py-2", className)}
data-testid={testId}
>
<Icon
className="h-4 w-4 shrink-0 text-muted-foreground"

View File

@@ -3,10 +3,10 @@ import { Glasses } from "lucide-react";
import { cn } from "@/shared/lib/cn";
import { TabsList, TabsTrigger } from "@/shared/ui/tabs";
const PROJECT_TAB_TRIGGER_CLASS =
export const PROJECT_TAB_TRIGGER_CLASS =
"h-7 shrink-0 rounded-full bg-muted/30 px-3 text-xs font-medium leading-5 tracking-tight text-muted-foreground shadow-none transition-colors hover:bg-muted/55 hover:text-foreground data-[state=active]:bg-muted data-[state=active]:text-foreground data-[state=active]:shadow-none";
const PROJECT_TAB_SELECTED_CLASS = "bg-muted text-foreground";
export const PROJECT_TAB_SELECTED_CLASS = "bg-muted text-foreground";
const PROJECT_OVERVIEW_TAB_CLASS =
"h-7 w-7 shrink-0 rounded-full bg-muted/30 p-1.5 text-muted-foreground shadow-none transition-colors hover:bg-muted/55 hover:text-foreground data-[state=active]:bg-muted data-[state=active]:text-foreground data-[state=active]:shadow-none";

View File

@@ -62,6 +62,12 @@ type ProjectActivityItem = {
target: ActivityTarget;
};
type ProjectActivityGroup = {
key: string;
label: string;
items: ProjectActivityItem[];
};
type ProjectsActivityFeedProps = {
compact?: boolean;
isLoading: boolean;
@@ -85,6 +91,7 @@ type ProjectsActivityFeedProps = {
};
const ACTIVITY_LIMIT = 30;
const WEEK_SECONDS = 7 * 24 * 60 * 60;
function contentPreview(content: string) {
return markdownToPlainText(content).replace(/\s+/g, " ").trim().slice(0, 280);
@@ -251,6 +258,36 @@ function buildActivityItems({
.slice(0, ACTIVITY_LIMIT);
}
function startOfWeek(timestamp: number) {
const date = new Date(timestamp * 1_000);
date.setHours(0, 0, 0, 0);
const daysSinceMonday = (date.getDay() + 6) % 7;
date.setDate(date.getDate() - daysSinceMonday);
return Math.floor(date.getTime() / 1_000);
}
function groupActivityItems(items: ProjectActivityItem[]) {
const thisWeek = startOfWeek(Math.floor(Date.now() / 1_000));
const lastWeek = thisWeek - WEEK_SECONDS;
const groups: ProjectActivityGroup[] = [
{ key: "this-week", label: "This week", items: [] },
{ key: "last-week", label: "Last week", items: [] },
{ key: "earlier", label: "Earlier", items: [] },
];
for (const item of items) {
if (item.createdAt >= thisWeek) {
groups[0].items.push(item);
} else if (item.createdAt >= lastWeek) {
groups[1].items.push(item);
} else {
groups[2].items.push(item);
}
}
return groups.filter((group) => group.items.length > 0);
}
function ActivityCard({
compact,
isFirst,
@@ -430,6 +467,7 @@ function ActivityCard({
/** Mixed GitHub-style workspace activity shown beneath the overview callouts. */
export function ProjectsActivityFeed(props: ProjectsActivityFeedProps) {
const items = buildActivityItems(props);
const groups = groupActivityItems(items);
if (props.isLoading && items.length === 0) {
return <BuzzLoadingState label="Loading project activity" />;
@@ -450,45 +488,59 @@ export function ProjectsActivityFeed(props: ProjectsActivityFeedProps) {
return (
<div
className="relative bg-transparent"
className="relative space-y-7 bg-transparent"
data-testid="projects-activity-timeline"
>
{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") {
props.onOpenProject(item.target.project);
} else if (item.target.type === "commit") {
props.onOpenCommit(
item.target.project,
item.target.commitHash,
);
} else if (item.target.type === "pull-request") {
props.onOpenPullRequest(
item.target.project,
item.target.repository,
item.target.pullRequest,
);
} else {
props.onOpenIssue(
item.target.project,
item.target.repository,
item.target.issue,
);
}
}}
onOpenProject={() => props.onOpenProject(item.target.project)}
profiles={props.profiles}
/>
{groups.map((group) => (
<section data-testid="projects-activity-group" key={group.key}>
<div className="mb-1 flex items-center gap-3">
<h3 className="shrink-0 text-xs font-medium text-muted-foreground">
{group.label}
</h3>
<span aria-hidden="true" className="h-px flex-1 bg-border/70" />
</div>
);
})}
<div>
{group.items.map((item, index) => {
return (
<div className="relative" key={item.id}>
<ActivityCard
compact={props.compact === true}
isFirst={index === 0}
isLast={index === group.items.length - 1}
item={item}
onOpen={() => {
if (item.target.type === "project") {
props.onOpenProject(item.target.project);
} else if (item.target.type === "commit") {
props.onOpenCommit(
item.target.project,
item.target.commitHash,
);
} else if (item.target.type === "pull-request") {
props.onOpenPullRequest(
item.target.project,
item.target.repository,
item.target.pullRequest,
);
} else {
props.onOpenIssue(
item.target.project,
item.target.repository,
item.target.issue,
);
}
}}
onOpenProject={() =>
props.onOpenProject(item.target.project)
}
profiles={props.profiles}
/>
</div>
);
})}
</div>
</section>
))}
</div>
);
}

View File

@@ -0,0 +1,135 @@
import { Hash } from "lucide-react";
import * as React from "react";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { useChannelsQuery } from "@/features/channels/hooks";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import type { Project } from "@/features/projects/hooks";
import {
collectProjectRelatedChannelRows,
projectRelatedChannelRowKey,
} from "@/features/projects/lib/projectRelatedChannels";
import { listRowDescription } from "@/features/projects/lib/projectsViewHelpers";
import { BuzzLoadingState } from "@/shared/ui/BuzzLoadingState";
import { ProjectEntityListRow } from "./ProjectEntityListRow";
function lastMessageAtSeconds(value: string | null | undefined) {
if (!value) return null;
const ms = Date.parse(value);
return Number.isFinite(ms) ? Math.floor(ms / 1_000) : null;
}
function affiliationLabel(projectName: string, repositoryName: string | null) {
if (!repositoryName || repositoryName === projectName) return projectName;
return `${projectName} · ${repositoryName}`;
}
export function ProjectsChannelsList({ projects }: { projects: Project[] }) {
const { goChannel } = useAppNavigation();
const channelsQuery = useChannelsQuery({ enabled: projects.length > 0 });
const channelsById = React.useMemo(() => {
return new Map(
(channelsQuery.data ?? []).map((channel) => [channel.id, channel]),
);
}, [channelsQuery.data]);
const rows = React.useMemo(() => {
const collected = collectProjectRelatedChannelRows(projects);
return [...collected].sort((left, right) => {
const leftChannel = channelsById.get(left.channelId);
const rightChannel = channelsById.get(right.channelId);
const leftName = leftChannel?.name ?? left.channelId;
const rightName = rightChannel?.name ?? right.channelId;
const leftActivity =
lastMessageAtSeconds(leftChannel?.lastMessageAt) ?? 0;
const rightActivity =
lastMessageAtSeconds(rightChannel?.lastMessageAt) ?? 0;
return (
rightActivity - leftActivity ||
leftName.localeCompare(rightName) ||
left.projectName.localeCompare(right.projectName) ||
(left.repositoryName ?? "").localeCompare(right.repositoryName ?? "") ||
left.channelId.localeCompare(right.channelId)
);
});
}, [channelsById, projects]);
const participantPubkeys = React.useMemo(
() => [
...new Set(
rows.flatMap((row) => {
const channel = channelsById.get(row.channelId);
return channel?.participantPubkeys ?? channel?.participants ?? [];
}),
),
],
[channelsById, rows],
);
const profilesQuery = useUsersBatchQuery(participantPubkeys, {
enabled: participantPubkeys.length > 0,
});
const profiles = profilesQuery.data?.profiles;
if (channelsQuery.isLoading && rows.length === 0) {
return <BuzzLoadingState label="Loading project channels" />;
}
if (rows.length === 0) {
return (
<p className="px-4 py-6 text-sm text-muted-foreground">
No channels are bound to these projects yet. Link a discussion channel
to a project or repository and it will show up here.
</p>
);
}
return (
<ul
className="divide-y divide-border/60"
data-testid="projects-channels-list"
>
{rows.map((row) => {
const channel = channelsById.get(row.channelId);
const name = channel?.name ?? row.channelId.slice(0, 8);
const lastActivityAt = lastMessageAtSeconds(channel?.lastMessageAt);
const affiliation = affiliationLabel(
row.projectName,
row.repositoryName,
);
const people =
channel?.participantPubkeys ?? channel?.participants ?? [];
return (
<li key={projectRelatedChannelRowKey(row)}>
<ProjectEntityListRow
affiliation={
<span data-testid="project-channel-project">
<span data-testid="project-channel-repository">
{affiliation}
</span>
</span>
}
affiliationTitle={affiliation}
count={channel?.memberCount}
countTestId="project-channel-message-count"
countTitle={
channel
? `${channel.memberCount} ${
channel.memberCount === 1 ? "member" : "members"
}`
: undefined
}
dateSeconds={lastActivityAt}
dateTestId="project-channel-row-date"
description={listRowDescription(channel?.description, name)}
icon={<Hash className="h-3.5 w-3.5 text-muted-foreground/70" />}
onClick={() => void goChannel(row.channelId)}
people={people}
peopleTestId="project-channel-participants"
profiles={profiles}
testId="project-channel-row"
title={`#${name}`}
titleAttr={`Open #${name}`}
/>
</li>
);
})}
</ul>
);
}

View File

@@ -11,10 +11,12 @@ const MENU_ITEM_CLASS =
"flex min-h-9 w-full items-center gap-2 rounded-lg py-2 pl-2 pr-4 text-left text-sm outline-hidden transition-colors hover:bg-muted/50 focus:bg-muted/50 focus:text-foreground focus-visible:ring-1 focus-visible:ring-ring [&_svg]:size-4 [&_svg]:shrink-0";
export function ProjectsCreateMenu({
compact = false,
onCreateIssue,
onCreateProject,
onCreatePullRequest,
}: {
compact?: boolean;
onCreateIssue: () => void;
onCreateProject: () => void;
onCreatePullRequest: () => void;
@@ -70,12 +72,16 @@ export function ProjectsCreateMenu({
aria-expanded={open}
aria-haspopup="menu"
aria-label="Create"
className="h-8 w-8 rounded-full"
className={
compact
? "h-6 w-6 shrink-0 rounded-md text-muted-foreground hover:bg-muted/70 hover:text-foreground"
: "h-8 w-8 rounded-full"
}
data-testid="projects-create-menu"
onClick={() => setOpen(true)}
size="icon"
type="button"
variant="default"
variant={compact ? "ghost" : "default"}
>
<Plus className="h-4 w-4" />
</Button>

View File

@@ -7,7 +7,10 @@ import type {
Repository,
} from "@/features/projects/hooks";
import { issueShareLink } from "@/features/projects/lib/projectShareLinks";
import { relativeTime } from "@/features/projects/lib/projectsViewHelpers";
import {
listRowDescription,
relativeTime,
} from "@/features/projects/lib/projectsViewHelpers";
import {
projectTaskCategoryLabel,
projectTaskUserLabels,
@@ -23,19 +26,15 @@ import { BuzzLoadingState } from "@/shared/ui/BuzzLoadingState";
import { Card } from "@/shared/ui/card";
import { DropdownMenuItem } from "@/shared/ui/dropdown-menu";
import { CopyShareLinkMenuItem } from "./CopyShareLinkMenuItem";
import { IssueAssigneeFacepile } from "./IssueAssigneesRow";
import { ProjectAuthorIdentity } from "./ProjectAuthorIdentity";
import { ProjectEntityListRow } from "./ProjectEntityListRow";
import { ProjectEventTypeIcon } from "./ProjectEventTypeIcon";
import { ProjectListRowMenu } from "./ProjectListRowMenu";
import { ProjectsWorkItemsLoadNotice } from "./ProjectsWorkItemsLoadNotice";
import {
PROJECT_LIST_ROW_SUBTEXT_CLASS,
PROJECT_LIST_ROW_TITLE_CLASS,
} from "./projectListRowStyles";
import {
ProjectsWorkItemTableHeader,
WORK_ITEM_TABLE_GRID_CLASS,
} from "./ProjectsWorkItemTable";
import { ProjectsWorkItemsLoadNotice } from "./ProjectsWorkItemsLoadNotice";
type ProjectsIssuesListProps = {
/** Render without container chrome — a parent table container provides border and rounding. */
@@ -207,101 +206,42 @@ function IssueListRow({
repository: Repository;
}) {
const authorLabel = resolveUserLabel({ profiles, pubkey: issue.author });
const labelsSummary = issueLabelsSummary(issue);
return (
<tr
className={cn(
WORK_ITEM_TABLE_GRID_CLASS,
"group relative cursor-pointer px-3 py-2.5 transition-colors duration-150 hover:bg-muted/20 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring",
)}
aria-label={`Open task ${issue.title}`}
data-testid={`projects-issue-row-${issue.id}`}
onClick={(event) => {
if ((event.target as Element).closest("button, a, [role='menuitem']")) {
return;
}
onOpen(project, issue);
}}
onKeyDown={(event) => {
if (
event.target === event.currentTarget &&
(event.key === "Enter" || event.key === " ")
) {
event.preventDefault();
onOpen(project, issue);
}
}}
tabIndex={0}
>
<td className="min-w-0">
<div className="flex min-w-0 items-start gap-2 text-left">
<ProjectEventTypeIcon className="h-5 w-5" kind="issue" />
<div className="-mt-0.5 min-w-0 flex-1">
<p className={PROJECT_LIST_ROW_TITLE_CLASS}>{issue.title}</p>
<div
className={`flex min-w-0 items-center gap-x-1 overflow-hidden whitespace-nowrap ${PROJECT_LIST_ROW_SUBTEXT_CLASS}`}
>
<ProjectAuthorIdentity
label={authorLabel}
profiles={profiles}
pubkey={issue.author}
testId="projects-issue-author"
/>
<span>opened this in</span>
<span className="truncate">{repository.name}</span>
{labelsSummary ? (
<>
<span>and tagged it</span>
<span className="truncate">{labelsSummary}</span>
</>
) : null}
<span className="-ml-1">.</span>
</div>
</div>
<IssueAssigneeFacepile
assignees={issue.assignees}
profiles={profiles}
<ProjectEntityListRow
affiliation={repository.name}
count={issue.comments.length}
dateSeconds={issue.updatedAt}
dateTestId="projects-row-date"
description={listRowDescription(issue.content, issue.title)}
icon={<ProjectEventTypeIcon className="h-4 w-4" kind="issue" />}
onClick={() => onOpen(project, issue)}
peopleSlot={
<ProjectAuthorIdentity
label={authorLabel}
labelClassName="sr-only"
profiles={profiles}
pubkey={issue.author}
testId="projects-issue-author"
/>
}
testId={`projects-issue-row-${issue.id}`}
title={issue.title}
titleAttr={`Open task ${issue.title}`}
trailing={
<ProjectListRowMenu label={`More options for ${issue.title}`}>
<DropdownMenuItem onSelect={() => onOpen(project, issue)}>
<Eye className="h-4 w-4" />
{nextStepLabel(issue.status)}
</DropdownMenuItem>
<CopyShareLinkMenuItem
link={issueShareLink(issue)}
label="Copy task link"
testId={`projects-issue-copy-link-${issue.id}`}
/>
</div>
</td>
<td className="min-w-0">
<span className="inline-flex max-w-full truncate rounded-full border border-border/60 px-1.5 py-0.5 text-2xs font-medium text-muted-foreground">
{projectTaskCategoryLabel(issue.category)}
</span>
</td>
<td className="truncate text-2xs text-muted-foreground">
{issue.status}
</td>
<td className="text-2xs text-muted-foreground">
<span className="flex items-center justify-end gap-1">
<MessageSquare className="h-3.5 w-3.5" />
{issue.comments.length}
</span>
</td>
<td
className="truncate text-right text-xs text-muted-foreground/70"
data-testid="projects-row-date"
title={new Date(issue.updatedAt * 1_000).toLocaleString()}
>
{relativeTime(issue.updatedAt)}
</td>
<td className="relative z-10">
<div className="flex justify-end">
<ProjectListRowMenu label={`More options for ${issue.title}`}>
<DropdownMenuItem onSelect={() => onOpen(project, issue)}>
<Eye className="h-4 w-4" />
{nextStepLabel(issue.status)}
</DropdownMenuItem>
<CopyShareLinkMenuItem
link={issueShareLink(issue)}
label="Copy task link"
testId={`projects-issue-copy-link-${issue.id}`}
/>
</ProjectListRowMenu>
</div>
</td>
</tr>
</ProjectListRowMenu>
}
/>
);
}
@@ -377,19 +317,14 @@ export function ProjectsIssuesList({
return (
<div className="space-y-3">
{loadNotice}
<table
className={cn(
"block w-full overflow-x-auto bg-transparent",
!embedded && "rounded-xl border border-border/60",
)}
<ul
className="divide-y divide-border/60 bg-transparent"
data-testid="projects-list-container"
>
<ProjectsWorkItemTableHeader itemLabel="Task" typeLabel="Type" />
<tbody className="block divide-y divide-border/60">
{issues.map(({ project, issue, repository }) => (
{issues.map(({ project, issue, repository }) => (
<li key={`${repository.id}:${issue.id}`}>
<IssueListRow
issue={issue}
key={`${repository.id}:${issue.id}`}
onOpen={(selectedProject, selectedIssue) =>
onOpen(selectedProject, repository, selectedIssue)
}
@@ -397,9 +332,9 @@ export function ProjectsIssuesList({
project={project}
repository={repository}
/>
))}
</tbody>
</table>
</li>
))}
</ul>
</div>
);
}

View File

@@ -0,0 +1,36 @@
import type * as React from "react";
import { Sheet, SheetContent, SheetTitle } from "@/shared/ui/sheet";
/**
* Narrow-layout fallback for the Projects context rail: below the detached
* breakpoint the rail cannot dock beside the content, so the same
* section-following context panel opens as a dismissible right-hand sheet.
* Radix supplies the focus trap, Escape handling, and close button.
*/
export function ProjectsOverviewContextSheet({
children,
onCloseAutoFocus,
onOpenChange,
open,
}: {
children: React.ReactNode;
onCloseAutoFocus?: (event: Event) => void;
onOpenChange: (open: boolean) => void;
open: boolean;
}) {
return (
<Sheet onOpenChange={onOpenChange} open={open}>
<SheetContent
aria-describedby={undefined}
className="w-80 overflow-y-auto p-0 pt-10 sm:max-w-none"
data-testid="projects-overview-context-sheet"
onCloseAutoFocus={onCloseAutoFocus}
side="right"
>
<SheetTitle className="sr-only">Project context</SheetTitle>
{children}
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,180 @@
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type {
Project,
ProjectActivitySummary,
Repository,
} from "@/features/projects/hooks";
import {
hasLocalCheckout,
hasLocalRepositoryCheckout,
} from "@/features/projects/lib/projectLocalRepos";
import type { ProjectRepoUnavailableReason } from "@/features/projects/lib/projectRepoAvailability";
import {
isProjectOwnedByCurrentUser,
projectPeople,
type ProjectsFilter,
type ProjectsViewMode,
} from "@/features/projects/lib/projectsViewHelpers";
import {
EmptyFilteredState,
ProjectGridCard,
ProjectListRow,
} from "@/features/projects/ui/ProjectCards";
import {
RepositoryGridCard,
RepositoryListRow,
} from "@/features/projects/ui/RepositoryCards";
import { cn } from "@/shared/lib/cn";
export function ProjectsOverviewProjectItems({
currentPubkey,
deleteDisabled,
filter,
localRepoNames,
onDelete,
onOpen,
onOpenTerminal,
profiles,
repositoryUnavailableReasonFor,
summaries,
viewMode,
visibleProjects,
}: {
currentPubkey: string | undefined;
deleteDisabled: boolean;
filter: ProjectsFilter;
localRepoNames: Set<string>;
onDelete: (project: Project) => void;
onOpen: (project: Project) => void;
onOpenTerminal: (project: Project) => void;
profiles?: UserProfileLookup;
repositoryUnavailableReasonFor: (
project: Project,
) => ProjectRepoUnavailableReason | undefined;
summaries?: Record<string, ProjectActivitySummary>;
viewMode: ProjectsViewMode;
visibleProjects: Project[];
}) {
if (visibleProjects.length === 0) {
return <EmptyFilteredState />;
}
if (viewMode === "grid") {
return (
<div
className={cn(
"grid gap-3 md:grid-cols-2",
filter !== "all" && "xl:grid-cols-3",
)}
>
{visibleProjects.map((project) => {
const summary = summaries?.[project.id];
return (
<ProjectGridCard
canDelete={isProjectOwnedByCurrentUser(project, currentPubkey)}
deleteDisabled={deleteDisabled}
hasLocal={hasLocalCheckout(project, localRepoNames)}
key={project.id}
onDelete={onDelete}
onOpen={onOpen}
onOpenTerminal={onOpenTerminal}
people={projectPeople(project, summary)}
profiles={profiles}
project={project}
repositoryUnavailableReason={repositoryUnavailableReasonFor(
project,
)}
summary={summary}
/>
);
})}
</div>
);
}
return (
<div
className="divide-y divide-border/60"
data-testid="projects-list-container"
>
{visibleProjects.map((project) => {
const summary = summaries?.[project.id];
return (
<ProjectListRow
canDelete={isProjectOwnedByCurrentUser(project, currentPubkey)}
deleteDisabled={deleteDisabled}
hasLocal={hasLocalCheckout(project, localRepoNames)}
key={project.id}
onDelete={onDelete}
onOpen={onOpen}
onOpenTerminal={onOpenTerminal}
people={projectPeople(project, summary)}
profiles={profiles}
project={project}
repositoryUnavailableReason={repositoryUnavailableReasonFor(
project,
)}
summary={summary}
/>
);
})}
</div>
);
}
export function ProjectsOverviewRepositoryItems({
localRepoNames,
onOpen,
onOpenTerminal,
profiles,
summaries,
viewMode,
visibleRepositories,
}: {
localRepoNames: Set<string>;
onOpen: (project: Project, repository: Repository) => void;
onOpenTerminal: (repository: Repository) => void;
profiles?: UserProfileLookup;
summaries?: Record<string, ProjectActivitySummary>;
viewMode: ProjectsViewMode;
visibleRepositories: Array<{ project: Project; repository: Repository }>;
}) {
if (visibleRepositories.length === 0) {
return <EmptyFilteredState />;
}
if (viewMode === "grid") {
return (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{visibleRepositories.map(({ project, repository }) => (
<RepositoryGridCard
hasLocal={hasLocalRepositoryCheckout(repository, localRepoNames)}
key={repository.repoAddress}
onOpen={onOpen}
onOpenTerminal={onOpenTerminal}
profiles={profiles}
project={project}
repository={repository}
summary={summaries?.[repository.repoAddress]}
/>
))}
</div>
);
}
return (
<div
className="divide-y divide-border/60"
data-testid="projects-list-container"
>
{visibleRepositories.map(({ project, repository }) => (
<RepositoryListRow
hasLocal={hasLocalRepositoryCheckout(repository, localRepoNames)}
key={repository.repoAddress}
onOpen={onOpen}
onOpenTerminal={onOpenTerminal}
profiles={profiles}
project={project}
repository={repository}
summary={summaries?.[repository.repoAddress]}
/>
))}
</div>
);
}

View File

@@ -1,42 +1,91 @@
import { CircleDot, FolderGit2, Folders, GitPullRequest } from "lucide-react";
import {
CheckCircle2,
CircleDot,
FolderGit2,
Folders,
GitMerge,
GitPullRequest,
Hash,
Plus,
} from "lucide-react";
import type * as React from "react";
import type {
Project,
ProjectActivitySummary,
ProjectIssue,
ProjectPullRequest,
} from "@/features/projects/hooks";
import type { ProjectsFilter } from "@/features/projects/lib/projectsViewHelpers";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { Button } from "@/shared/ui/button";
import { ProjectsCreateMenu } from "./ProjectsCreateMenu";
import {
type OverviewContextStatIcon,
type ProjectsOverviewSection,
projectsOverviewContext,
} from "./projectsOverviewContext";
import {
ProjectsOverviewActivityGraph,
ProjectsOverviewPeople,
} from "./ProjectsOverviewRail";
export type ProjectsOverviewSection =
| "projects"
| "repositories"
| "prs"
| "issues";
export type { ProjectsOverviewSection };
const STAT_ICONS: Record<
OverviewContextStatIcon,
React.ComponentType<{ className?: string }>
> = {
channels: Hash,
completed: CheckCircle2,
merged: GitMerge,
projects: Folders,
repositories: FolderGit2,
reviews: GitPullRequest,
tasks: CircleDot,
};
type ProjectsOverviewPanelProps = {
children: React.ReactNode;
metadata: React.ReactNode;
};
type ProjectsOverviewContextPanelProps = {
filter: ProjectsFilter;
issues: ProjectIssue[];
onCreateIssue: () => void;
onCreateProject: () => void;
onCreatePullRequest: () => void;
onSelectSection: (section: ProjectsOverviewSection) => void;
profiles?: UserProfileLookup;
projects: Project[];
pullRequests: ProjectPullRequest[];
summaries?: Record<string, ProjectActivitySummary>;
};
function overviewStats(
projects: Project[],
summaries: Record<string, ProjectActivitySummary> | undefined,
) {
return projects.reduce(
(stats, project) => {
const summary = summaries?.[project.id];
return {
issues: stats.issues + (summary?.issueCount ?? 0),
prs: stats.prs + (summary?.prCount ?? 0),
};
},
{ issues: 0, prs: 0 },
function OverviewActionButton({
children,
onClick,
testId,
}: {
children: React.ReactNode;
onClick: () => void;
testId?: string;
}) {
return (
<Button
className="-mx-2 h-7 w-[calc(100%+1rem)] justify-start gap-3 rounded-md px-2 text-left text-sm font-normal hover:bg-muted/70 [&_svg]:h-4 [&_svg]:w-4 [&_svg]:shrink-0 [&_svg]:text-muted-foreground"
data-testid={testId}
onClick={onClick}
size="sm"
type="button"
variant="ghost"
>
{children}
</Button>
);
}
function StatPill({
function OverviewStatRow({
count,
icon: Icon,
label,
@@ -49,79 +98,136 @@ function StatPill({
}) {
return (
<button
className="flex flex-col rounded-lg border border-border/60 bg-transparent px-3.5 py-3 text-left transition-colors hover:bg-muted/30"
className="-mx-2 flex h-7 w-[calc(100%+1rem)] items-center justify-between gap-3 rounded-md px-2 text-left text-sm hover:bg-muted/70 [&_svg]:h-4 [&_svg]:w-4 [&_svg]:shrink-0 [&_svg]:text-muted-foreground"
data-testid="projects-overview-stat"
onClick={onClick}
type="button"
>
<span className="flex w-full items-center justify-between gap-2">
<span className="text-xs font-medium text-muted-foreground">
{label}
</span>
<Icon className="h-3.5 w-3.5 text-muted-foreground/70" />
</span>
<span className="mt-auto pt-3 text-2xl font-semibold leading-none tracking-tight text-foreground">
{count}
<span className="flex min-w-0 items-center gap-3 text-muted-foreground">
<Icon className="h-3.5 w-3.5 shrink-0" />
{label}
</span>
<span className="font-medium tabular-nums text-foreground">{count}</span>
</button>
);
}
export function ProjectsOverviewPanel({
children,
metadata,
onSelectSection,
projects,
summaries,
}: 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="-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 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.issues}
icon={CircleDot}
label="Tasks"
onClick={() => onSelectSection("issues")}
/>
<StatPill
count={stats.prs}
icon={GitPullRequest}
label="Reviews"
onClick={() => onSelectSection("prs")}
/>
</div>
{metadata}
</div>
</div>
<section data-testid="projects-overview-panel">
<div className="min-w-0">{children}</div>
</section>
);
}
export function ProjectsActivityIntro() {
return (
<section
className="pb-8 pt-5 text-center"
data-testid="projects-activity-intro"
>
<h2
className="text-xl font-semibold tracking-tight text-foreground"
data-testid="projects-page-header"
>
Welcome to Activity
</h2>
<p className="mx-auto mt-2 max-w-md text-sm text-muted-foreground">
Keep up with commits, reviews, and tasks across your projects.
</p>
</section>
);
}
export function ProjectsOverviewContextPanel({
filter,
issues,
onCreateIssue,
onCreateProject,
onCreatePullRequest,
onSelectSection,
profiles,
projects,
pullRequests,
summaries,
}: ProjectsOverviewContextPanelProps) {
const context = projectsOverviewContext({
filter,
issues,
projects,
pullRequests,
summaries,
});
const actionHandler =
context.action?.kind === "issue"
? onCreateIssue
: context.action?.kind === "pullRequest"
? onCreatePullRequest
: onCreateProject;
return (
<div
className="min-w-0 overflow-hidden rounded-2xl bg-background"
data-testid="projects-overview-context-panel"
>
<div className="px-4 pb-3 pt-3">
<div className="flex min-w-0 items-center justify-between gap-2">
<h2
className="min-w-0 truncate text-sm font-normal text-muted-foreground/70"
data-testid="projects-overview-context-title"
>
{context.title}
</h2>
<ProjectsCreateMenu
compact
onCreateIssue={onCreateIssue}
onCreateProject={onCreateProject}
onCreatePullRequest={onCreatePullRequest}
/>
</div>
<div className="space-y-0.5 pt-2">
{context.action ? (
<OverviewActionButton
onClick={actionHandler}
testId={context.action.testId}
>
<Plus className="h-3.5 w-3.5" />
{context.action.label}
</OverviewActionButton>
) : null}
<section
className="text-sm"
data-testid="projects-overview-stats-pod"
>
{context.stats.map((stat) => (
<OverviewStatRow
count={stat.count}
icon={STAT_ICONS[stat.icon]}
key={`${stat.section}:${stat.label}`}
label={stat.label}
onClick={() => onSelectSection(stat.section)}
/>
))}
</section>
</div>
{context.people.length > 0 || context.activityByDay ? (
<div className="mt-3 space-y-3 border-border/50 border-t py-3">
<ProjectsOverviewPeople
people={context.people}
profiles={profiles}
/>
{context.activityByDay ? (
<div data-testid="projects-overview-activity">
<ProjectsOverviewActivityGraph
activityByDay={context.activityByDay}
/>
</div>
) : null}
</div>
) : null}
</div>
</div>
);
}

View File

@@ -0,0 +1,40 @@
import assert from "node:assert/strict";
import test from "node:test";
import { visibleStackedAvatarCount } from "./ProjectsOverviewRail.tsx";
test("shows every stacked avatar when they fit in two rows", () => {
assert.deepEqual(
visibleStackedAvatarCount({
avatarSize: 24,
containerWidth: 288,
overlap: 6,
total: 30,
}),
{ columns: 15, remaining: 0, visible: 30 },
);
});
test("reserves the last stacked slot for a +N chip when avatars overflow two rows", () => {
assert.deepEqual(
visibleStackedAvatarCount({
avatarSize: 24,
containerWidth: 288,
overlap: 6,
total: 31,
}),
{ columns: 15, remaining: 2, visible: 29 },
);
});
test("waits for a measured width before clamping", () => {
assert.deepEqual(
visibleStackedAvatarCount({
avatarSize: 24,
containerWidth: 0,
overlap: 6,
total: 40,
}),
{ columns: 40, remaining: 0, visible: 40 },
);
});

View File

@@ -1,109 +1,190 @@
import * as React from "react";
import {
resolveUserLabel,
type UserProfileLookup,
} from "@/features/profile/lib/identity";
import type {
Project,
ProjectActivitySummary,
} from "@/features/projects/hooks";
import { cn } from "@/shared/lib/cn";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import { OverviewRailSection } from "./ProjectOverviewPanel";
import { ProjectsContributionGraph } from "./ProjectsContributionGraph";
type ProjectsOverviewRailProps = {
profiles?: UserProfileLookup;
projects: Project[];
summaries?: Record<string, ProjectActivitySummary>;
};
/** Matches Tailwind `-space-x-1.5` so stacked rows stay zoom-safe. */
const STACK_OVERLAP_REM = 0.375;
function overviewPeople(
projects: Project[],
summaries: Record<string, ProjectActivitySummary> | undefined,
) {
return [
...new Set(
projects.flatMap((project) =>
[
project.owner,
...project.repositories.flatMap((repository) => [
repository.owner,
...repository.contributors,
]),
...(summaries?.[project.id]?.participantPubkeys ?? []),
].map(normalizePubkey),
),
),
];
}
function overviewActivityByDay(
projects: Project[],
summaries: Record<string, ProjectActivitySummary> | undefined,
) {
const merged: Record<string, number> = {};
for (const project of projects) {
const byDay = summaries?.[project.id]?.activityByDay;
if (!byDay) continue;
for (const [day, count] of Object.entries(byDay)) {
merged[day] = (merged[day] ?? 0) + count;
}
/** How many overlapping avatars fit in a wrapping stack before the +N chip. */
export function visibleStackedAvatarCount({
avatarSize,
containerWidth,
maxRows = 2,
overlap,
total,
}: {
avatarSize: number;
containerWidth: number;
maxRows?: number;
overlap: number;
total: number;
}): { columns: number; remaining: number; visible: number } {
if (total <= 0) return { columns: 1, remaining: 0, visible: 0 };
if (containerWidth <= 0 || avatarSize <= 0) {
return { columns: total, remaining: 0, visible: total };
}
return merged;
const stride = Math.max(1, avatarSize - overlap);
const columns = Math.max(
1,
1 + Math.floor(Math.max(0, containerWidth - avatarSize) / stride),
);
const slots = columns * maxRows;
if (total <= slots) return { columns, remaining: 0, visible: total };
const visible = Math.max(1, slots - 1);
return { columns, remaining: total - visible, visible };
}
/** Workspace people and contribution activity for the overview side rail. */
export function ProjectsOverviewRail({
function OverviewPerson({
index,
profiles,
projects,
summaries,
}: ProjectsOverviewRailProps) {
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.
pubkey,
stackSize,
}: {
index: number;
profiles?: UserProfileLookup;
pubkey: string;
stackSize: number;
}) {
const profile = profiles?.[normalizePubkey(pubkey)];
const label = resolveUserLabel({ profiles, pubkey });
return (
<>
<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">
{people.slice(0, 18).map((pubkey) => {
const profile = profiles?.[normalizePubkey(pubkey)];
const label = resolveUserLabel({ profiles, pubkey });
return (
<Tooltip key={pubkey}>
<TooltipTrigger asChild>
<span className="inline-flex">
<UserAvatar
accent={profile?.isAgent === true}
avatarUrl={profile?.avatarUrl ?? null}
displayName={label}
size="sm"
/>
</span>
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
);
})}
</div>
) : (
<p className="text-sm text-muted-foreground">No people yet.</p>
)}
</OverviewRailSection>
</div>
<div className="min-w-0 rounded-lg border border-border/60 p-4">
<OverviewRailSection
title="Contribution Activity"
titleClassName="text-base"
<Tooltip>
<TooltipTrigger asChild>
<span
className="relative inline-flex rounded-full ring-2 ring-background"
data-overview-person=""
style={{ zIndex: stackSize - index }}
>
<ProjectsContributionGraph activityByDay={activityByDay} compact />
</OverviewRailSection>
</div>
</>
<UserAvatar
accent={profile?.isAgent === true}
avatarUrl={profile?.avatarUrl ?? null}
displayName={label}
size="sm"
/>
</span>
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
);
}
export function ProjectsOverviewPeople({
className,
people,
profiles,
}: {
className?: string;
people: string[];
profiles?: UserProfileLookup;
}) {
const containerRef = React.useRef<HTMLDivElement>(null);
const [layout, setLayout] = React.useState({
columns: Math.min(people.length, 48) || 1,
visible: Math.min(people.length, 48),
});
React.useLayoutEffect(() => {
const container = containerRef.current;
if (!container || people.length === 0) return;
function measure() {
const node = containerRef.current;
if (!node) return;
const probe = node.querySelector(
"[data-overview-person]",
) as HTMLElement | null;
const avatarSize = probe?.offsetWidth ?? 24;
const rootSize = Number.parseFloat(
getComputedStyle(document.documentElement).fontSize,
);
const next = visibleStackedAvatarCount({
avatarSize,
containerWidth: node.clientWidth,
overlap: STACK_OVERLAP_REM * rootSize,
total: people.length,
});
setLayout((current) =>
current.columns === next.columns && current.visible === next.visible
? current
: { columns: next.columns, visible: next.visible },
);
}
measure();
const observer = new ResizeObserver(measure);
observer.observe(container);
return () => observer.disconnect();
}, [people.length]);
if (people.length === 0) return null;
const visible = people.slice(0, layout.visible);
const remaining = people.length - visible.length;
const remainingLabels = people
.slice(visible.length)
.map((pubkey) => resolveUserLabel({ profiles, pubkey }));
const rows: string[][] = [];
for (let index = 0; index < visible.length; index += layout.columns) {
rows.push(visible.slice(index, index + layout.columns));
}
return (
<div
className={cn("flex flex-col gap-1", className)}
data-testid="projects-overview-people"
ref={containerRef}
>
{rows.map((row, rowIndex) => {
const isLastRow = rowIndex === rows.length - 1;
const showOverflow = isLastRow && remaining > 0;
const stackSize = row.length + (showOverflow ? 1 : 0);
return (
<div className="flex -space-x-1.5" key={row[0] ?? rowIndex}>
{row.map((pubkey, index) => (
<OverviewPerson
index={index}
key={pubkey}
profiles={profiles}
pubkey={pubkey}
stackSize={stackSize}
/>
))}
{showOverflow ? (
<Tooltip>
<TooltipTrigger asChild>
<span
className="relative z-0 inline-flex h-6 min-w-6 items-center justify-center rounded-full bg-muted px-1 text-3xs font-semibold tabular-nums text-muted-foreground ring-2 ring-background"
data-testid="projects-overview-people-overflow"
>
+{remaining}
</span>
</TooltipTrigger>
<TooltipContent>
{remainingLabels.length === 1
? remainingLabels[0]
: `${remaining} more`}
</TooltipContent>
</Tooltip>
) : null}
</div>
);
})}
</div>
);
}
export function ProjectsOverviewActivityGraph({
activityByDay,
}: {
activityByDay: Record<string, number>;
}) {
return <ProjectsContributionGraph activityByDay={activityByDay} compact />;
}

View File

@@ -7,7 +7,10 @@ import type {
Repository,
} from "@/features/projects/hooks";
import { pullRequestShareLink } from "@/features/projects/lib/projectShareLinks";
import { relativeTime } from "@/features/projects/lib/projectsViewHelpers";
import {
listRowDescription,
relativeTime,
} from "@/features/projects/lib/projectsViewHelpers";
import type { ProjectWorkItemSection } from "@/features/projects/projectWorkItems";
import { cn } from "@/shared/lib/cn";
import {
@@ -20,17 +23,10 @@ import { Card } from "@/shared/ui/card";
import { DropdownMenuItem } from "@/shared/ui/dropdown-menu";
import { CopyShareLinkMenuItem } from "./CopyShareLinkMenuItem";
import { ProjectAuthorIdentity } from "./ProjectAuthorIdentity";
import { ProjectEntityListRow } from "./ProjectEntityListRow";
import { ProjectEventTypeIcon } from "./ProjectEventTypeIcon";
import { ProjectListRowMenu } from "./ProjectListRowMenu";
import { ProjectsWorkItemsLoadNotice } from "./ProjectsWorkItemsLoadNotice";
import {
PROJECT_LIST_ROW_SUBTEXT_CLASS,
PROJECT_LIST_ROW_TITLE_CLASS,
} from "./projectListRowStyles";
import {
ProjectsWorkItemTableHeader,
WORK_ITEM_TABLE_GRID_CLASS,
} from "./ProjectsWorkItemTable";
type ProjectsPullRequestsListProps = {
/** Render without container chrome — a parent table container provides border and rounding. */
@@ -224,84 +220,40 @@ function PullRequestListRow({
});
return (
<tr
className={cn(
WORK_ITEM_TABLE_GRID_CLASS,
"group relative cursor-pointer px-3 py-2.5 transition-colors duration-150 hover:bg-muted/20 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring",
)}
aria-label={`Open review ${pullRequest.title}`}
data-testid={`projects-pr-row-${pullRequest.id}`}
onClick={(event) => {
if ((event.target as Element).closest("button, a, [role='menuitem']")) {
return;
}
onOpen(project, pullRequest);
}}
onKeyDown={(event) => {
if (
event.target === event.currentTarget &&
(event.key === "Enter" || event.key === " ")
) {
event.preventDefault();
onOpen(project, pullRequest);
}
}}
tabIndex={0}
>
<td className="min-w-0">
<div className="flex min-w-0 items-start gap-2 text-left">
<ProjectEventTypeIcon className="h-5 w-5" kind="pull-request" />
<div className="-mt-0.5 min-w-0 flex-1">
<p className={PROJECT_LIST_ROW_TITLE_CLASS}>{pullRequest.title}</p>
<PullRequestContext
authorLabel={authorLabel}
authorTestId="projects-pr-author"
className={PROJECT_LIST_ROW_SUBTEXT_CLASS}
profiles={profiles}
pullRequest={pullRequest}
repository={repository}
showMobileStatus
/>
</div>
</div>
</td>
<td className="min-w-0">
<span className="inline-flex max-w-full truncate rounded-full border border-border/60 px-1.5 py-0.5 text-2xs font-medium text-muted-foreground">
Review
</span>
</td>
<td className="truncate text-2xs text-muted-foreground">
{pullRequest.status}
</td>
<td className="text-2xs text-muted-foreground">
<span className="flex items-center justify-end gap-1">
<MessageSquare className="h-3.5 w-3.5" />
{pullRequest.comments.length}
</span>
</td>
<td
className="truncate text-right text-xs text-muted-foreground/70"
data-testid="projects-row-date"
title={new Date(pullRequest.updatedAt * 1_000).toLocaleString()}
>
{relativeTime(pullRequest.updatedAt)}
</td>
<td className="relative z-10">
<div className="flex justify-end">
<ProjectListRowMenu label={`More options for ${pullRequest.title}`}>
<DropdownMenuItem onSelect={() => onOpen(project, pullRequest)}>
<GitPullRequest className="h-4 w-4" />
{nextStepLabel(pullRequest.status)}
</DropdownMenuItem>
<CopyShareLinkMenuItem
link={pullRequestShareLink(pullRequest)}
label="Copy review link"
testId={`projects-pull-request-copy-link-${pullRequest.id}`}
/>
</ProjectListRowMenu>
</div>
</td>
</tr>
<ProjectEntityListRow
affiliation={repository.name}
count={pullRequest.comments.length}
dateSeconds={pullRequest.updatedAt}
dateTestId="projects-row-date"
description={listRowDescription(pullRequest.content, pullRequest.title)}
icon={<ProjectEventTypeIcon className="h-4 w-4" kind="pull-request" />}
onClick={() => onOpen(project, pullRequest)}
peopleSlot={
<ProjectAuthorIdentity
label={authorLabel}
labelClassName="sr-only"
profiles={profiles}
pubkey={pullRequest.author}
testId="projects-pr-author"
/>
}
testId={`projects-pr-row-${pullRequest.id}`}
title={pullRequest.title}
titleAttr={`Open review ${pullRequest.title}`}
trailing={
<ProjectListRowMenu label={`More options for ${pullRequest.title}`}>
<DropdownMenuItem onSelect={() => onOpen(project, pullRequest)}>
<GitPullRequest className="h-4 w-4" />
{nextStepLabel(pullRequest.status)}
</DropdownMenuItem>
<CopyShareLinkMenuItem
link={pullRequestShareLink(pullRequest)}
label="Copy review link"
testId={`projects-pull-request-copy-link-${pullRequest.id}`}
/>
</ProjectListRowMenu>
}
/>
);
}
@@ -376,18 +328,13 @@ export function ProjectsPullRequestsList({
return (
<div className="space-y-3">
{loadNotice}
<table
className={cn(
"block w-full overflow-x-auto bg-transparent",
!embedded && "rounded-xl border border-border/60",
)}
<ul
className="divide-y divide-border/60 bg-transparent"
data-testid="projects-list-container"
>
<ProjectsWorkItemTableHeader itemLabel="Review" typeLabel="Type" />
<tbody className="block divide-y divide-border/60">
{pullRequests.map(({ project, pullRequest, repository }) => (
{pullRequests.map(({ project, pullRequest, repository }) => (
<li key={`${repository.id}:${pullRequest.id}`}>
<PullRequestListRow
key={`${repository.id}:${pullRequest.id}`}
onOpen={(selectedProject, selectedPullRequest) =>
onOpen(selectedProject, repository, selectedPullRequest)
}
@@ -396,9 +343,9 @@ export function ProjectsPullRequestsList({
pullRequest={pullRequest}
repository={repository}
/>
))}
</tbody>
</table>
</li>
))}
</ul>
</div>
);
}

View File

@@ -5,12 +5,13 @@ import type {
ProjectsFilter,
ProjectsViewMode,
} from "@/features/projects/lib/projectsViewHelpers";
import {
PROJECT_TAB_SELECTED_CLASS,
PROJECT_TAB_TRIGGER_CLASS,
} from "@/features/projects/ui/ProjectWorkspaceTabList";
import { cn } from "@/shared/lib/cn";
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
@@ -122,19 +123,20 @@ export function ProjectsToolbar({
{ label: "Activity", value: "all" },
{ label: "Projects", value: "projects" },
{ label: "Repositories", value: "repositories" },
{ label: "Channels", value: "channels" },
{ label: "Tasks", value: "issues" },
{ label: "Reviews", value: "prs" },
];
return (
<div
className="pointer-events-auto flex h-full min-w-0 items-center"
className="pointer-events-auto flex min-w-0 items-center"
data-tauri-drag-region
>
<div className="flex h-full min-w-0 flex-1 items-center gap-0.5 overflow-hidden">
<div className="flex min-w-0 flex-1 items-center overflow-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",
"flex min-w-0 flex-1 flex-nowrap items-center gap-1.5 overflow-x-auto scrollbar-none [&::-webkit-scrollbar]:hidden",
overflow.left && overflow.right
? MASK_BOTH
: overflow.left
@@ -151,9 +153,8 @@ export function ProjectsToolbar({
aria-label={option.label}
aria-pressed={filter === option.value}
className={cn(
"relative h-full shrink-0 gap-1.5 rounded-none px-2.5 text-base leading-5 tracking-tight text-muted-foreground after:absolute after:inset-x-2.5 after:bottom-0 after:h-0.5 after:bg-current after:opacity-0 after:transition-opacity after:content-[''] hover:bg-transparent hover:text-foreground hover:after:opacity-100",
option.value === "all" && "pl-0 after:left-0",
filter === option.value && SELECTED_MENU_ITEM_CLASSES,
PROJECT_TAB_TRIGGER_CLASS,
filter === option.value && PROJECT_TAB_SELECTED_CLASS,
)}
data-testid={`projects-section-${option.value}`}
key={option.value}

View File

@@ -1,3 +1,4 @@
import { Search } from "lucide-react";
import * as React from "react";
import { toast } from "sonner";
@@ -27,36 +28,43 @@ import {
projectRepoHostForRepository,
} from "@/features/projects/lib/projectRepoHost";
import { ProjectsActivityFeed } from "@/features/projects/ui/ProjectsActivityFeed";
import { ProjectsChannelsList } from "@/features/projects/ui/ProjectsChannelsList";
import { ProjectsOverviewContextSheet } from "@/features/projects/ui/ProjectsOverviewContextSheet";
import {
EmptyFilteredState,
EmptyState,
ProjectGridCard,
ProjectListRow,
} from "@/features/projects/ui/ProjectCards";
ProjectsActivityIntro,
ProjectsOverviewContextPanel,
ProjectsOverviewPanel,
} from "@/features/projects/ui/ProjectsOverviewPanel";
import {
openAppSearch,
projectsSectionIcon,
projectsSectionTitle,
} from "@/features/projects/ui/projectsSectionMeta";
import { EmptyState } from "@/features/projects/ui/ProjectCards";
import {
ProjectsOverviewProjectItems,
ProjectsOverviewRepositoryItems,
} from "@/features/projects/ui/ProjectsOverviewItems";
import { CreateProjectDialog } from "@/features/projects/ui/CreateProjectDialog";
import { CreateProjectIssueDialog } from "@/features/projects/ui/CreateProjectIssueDialog";
import { CreatePullRequestDialog } from "@/features/projects/ui/CreatePullRequestDialog";
import { ProjectsCreateMenu } from "@/features/projects/ui/ProjectsCreateMenu";
import { ProjectsIssuesList } from "@/features/projects/ui/ProjectsIssuesList";
import { ProjectsOverviewPanel } from "@/features/projects/ui/ProjectsOverviewPanel";
import { ProjectsOverviewRail } from "@/features/projects/ui/ProjectsOverviewRail";
import { ProjectsWorkspaceChrome } from "@/features/projects/ui/ProjectDetailChrome";
import { ProjectsPullRequestsList } from "@/features/projects/ui/ProjectsPullRequestsList";
import { ProjectsWorkItemsLoadNotice } from "@/features/projects/ui/ProjectsWorkItemsLoadNotice";
import { ProjectsListHeaderBar } from "@/features/projects/ui/ProjectsListHeaderBar";
import { ProjectSectionHeader } from "@/features/projects/ui/ProjectSectionHeader";
import { PROJECT_COLUMN_HEADER_BACKDROP_CLASS } from "@/features/projects/ui/projectPanelStyles";
import { ProjectsToolbar } from "@/features/projects/ui/ProjectsToolbar";
import {
hasLocalCheckout,
hasLocalRepositoryCheckout,
} from "@/features/projects/lib/projectLocalRepos";
import {
RepositoryGridCard,
RepositoryListRow,
} from "@/features/projects/ui/RepositoryCards";
import {
getProjectUpdatedAt,
isProjectAccessibleToViewer,
isProjectMine,
isProjectOwnedByCurrentUser,
isRepositoryAccessibleToViewer,
projectHasAgent,
projectOwnerIsUser,
@@ -80,6 +88,8 @@ import {
writeStoredViewMode,
} from "@/features/projects/lib/projectsViewHelpers";
import { useOpenProjectTerminal } from "@/features/projects/ui/useOpenProjectTerminal";
import { PROJECT_CONTEXT_PANEL_DEFAULT_WIDTH_PX } from "@/features/projects/ui/useProjectPanelWidths";
import { useMediaBreakpoint } from "@/shared/hooks/use-mobile";
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
import { useCommunities } from "@/features/communities/useCommunities";
import { useIdentityQuery } from "@/shared/api/hooks";
@@ -88,9 +98,10 @@ import { cn } from "@/shared/lib/cn";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { useRelayOrigin } from "@/shared/lib/useRelayOrigin";
import { Button } from "@/shared/ui/button";
import { PageHeader } from "@/shared/ui/PageHeader";
import { DrawerPanelIcon } from "@/shared/ui/DrawerPanelIcon";
const MANY_PROJECTS_THRESHOLD = 12;
const PROJECTS_CONTEXT_POD_MIN_VIEWPORT_PX = 1024;
export function ProjectsView() {
const { goProject } = useAppNavigation();
@@ -147,11 +158,15 @@ export function ProjectsView() {
? "repositories"
: storedFilter;
});
const activitySummariesQuery = useProjectActivitySummariesQuery(
filter === "prs" || filter === "issues" || filter === "repositories"
? []
: projects,
const [overviewPanelOpen, setOverviewPanelOpen] = React.useState(true);
// Narrow layouts present the same context as a dismissible sheet instead of
// the docked rail; the sheet starts closed so resizing never pops a modal.
const [narrowContextOpen, setNarrowContextOpen] = React.useState(false);
const contextToggleRef = React.useRef<HTMLButtonElement | null>(null);
const isNarrowProjectsLayout = useMediaBreakpoint(
PROJECTS_CONTEXT_POD_MIN_VIEWPORT_PX,
);
const activitySummariesQuery = useProjectActivitySummariesQuery(projects);
const repositoryActivitySummariesQuery = useRepositoryActivitySummariesQuery(
filter === "repositories" ? projects : [],
);
@@ -168,9 +183,7 @@ export function ProjectsView() {
const [issueScope, setIssueScope] = React.useState<ProjectsWorkItemScope>(
() => readStoredIssueScope(),
);
const projectsWorkItemsQuery = useProjectsWorkItemsQuery(
filter === "all" || filter === "prs" || filter === "issues" ? projects : [],
);
const projectsWorkItemsQuery = useProjectsWorkItemsQuery(projects);
// One blobless clone per primary Buzz repository, only while the overview
// header is visible.
const snapshotProjects = React.useMemo(
@@ -601,110 +614,39 @@ export function ProjectsView() {
return <EmptyState />;
}
const projectItems =
visibleProjects.length === 0 ? (
<EmptyFilteredState />
) : viewMode === "grid" ? (
<div
className={cn(
"grid gap-3 md:grid-cols-2",
filter !== "all" && "xl:grid-cols-3",
)}
>
{visibleProjects.map((project) => {
const summary = activitySummariesQuery.data?.[project.id];
return (
<ProjectGridCard
canDelete={isProjectOwnedByCurrentUser(project, currentPubkey)}
deleteDisabled={deleteProjectMutation.isPending}
hasLocal={hasLocalCheckout(project, localRepoNames)}
key={project.id}
onDelete={handleDeleteProject}
onOpen={handleOpenProject}
onOpenTerminal={handleOpenTerminal}
people={projectPeople(project, summary)}
profiles={profiles}
project={project}
repositoryUnavailableReason={repositoryUnavailableReasonFor(
project,
)}
summary={summary}
/>
);
})}
</div>
) : (
<div
className="divide-y divide-border/60"
data-testid="projects-list-container"
>
{visibleProjects.map((project) => {
const summary = activitySummariesQuery.data?.[project.id];
return (
<ProjectListRow
canDelete={isProjectOwnedByCurrentUser(project, currentPubkey)}
deleteDisabled={deleteProjectMutation.isPending}
hasLocal={hasLocalCheckout(project, localRepoNames)}
key={project.id}
onDelete={handleDeleteProject}
onOpen={handleOpenProject}
onOpenTerminal={handleOpenTerminal}
people={projectPeople(project, summary)}
profiles={profiles}
project={project}
repositoryUnavailableReason={repositoryUnavailableReasonFor(
project,
)}
summary={summary}
/>
);
})}
</div>
);
const projectItems = (
<ProjectsOverviewProjectItems
currentPubkey={currentPubkey}
deleteDisabled={deleteProjectMutation.isPending}
filter={filter}
localRepoNames={localRepoNames}
onDelete={handleDeleteProject}
onOpen={handleOpenProject}
onOpenTerminal={handleOpenTerminal}
profiles={profiles}
repositoryUnavailableReasonFor={repositoryUnavailableReasonFor}
summaries={activitySummariesQuery.data}
viewMode={viewMode}
visibleProjects={visibleProjects}
/>
);
const repositoryItems =
visibleRepositories.length === 0 ? (
<EmptyFilteredState />
) : viewMode === "grid" ? (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{visibleRepositories.map(({ project, repository }) => (
<RepositoryGridCard
hasLocal={hasLocalRepositoryCheckout(repository, localRepoNames)}
key={repository.repoAddress}
onOpen={handleOpenRepository}
onOpenTerminal={handleOpenRepositoryTerminal}
profiles={profiles}
project={project}
repository={repository}
summary={
repositoryActivitySummariesQuery.data?.[repository.repoAddress]
}
/>
))}
</div>
) : (
<div className="divide-y divide-border/60">
{visibleRepositories.map(({ project, repository }) => (
<RepositoryListRow
hasLocal={hasLocalRepositoryCheckout(repository, localRepoNames)}
key={repository.repoAddress}
onOpen={handleOpenRepository}
onOpenTerminal={handleOpenRepositoryTerminal}
profiles={profiles}
project={project}
repository={repository}
summary={
repositoryActivitySummariesQuery.data?.[repository.repoAddress]
}
/>
))}
</div>
);
const repositoryItems = (
<ProjectsOverviewRepositoryItems
localRepoNames={localRepoNames}
onOpen={handleOpenRepository}
onOpenTerminal={handleOpenRepositoryTerminal}
profiles={profiles}
summaries={repositoryActivitySummariesQuery.data}
viewMode={viewMode}
visibleRepositories={visibleRepositories}
/>
);
const listHeaderBar = (
<ProjectsListHeaderBar
filter={filter}
variant={viewMode === "list" ? "row" : "bar"}
variant="bar"
issueScope={issueScope}
onIssueScopeChange={handleIssueScopeChange}
onPullRequestScopeChange={handlePullRequestScopeChange}
@@ -760,181 +702,281 @@ export function ProjectsView() {
/>
);
const projectsHeader = (
<PageHeader
className="pointer-events-auto mb-4"
description="Set up and manage your projects."
title="Projects"
/>
);
const contextPanelProps = {
filter,
issues:
projectsWorkItemsQuery.data?.issues.items.map(({ issue }) => issue) ?? [],
onCreateIssue: () => setCreateIssueOpen(true),
onCreateProject: () => setCreateProjectOpen(true),
onCreatePullRequest: () => setCreatePullRequestOpen(true),
profiles,
projects,
pullRequests:
projectsWorkItemsQuery.data?.pullRequests.items.map(
({ pullRequest }) => pullRequest,
) ?? [],
summaries: activitySummariesQuery.data,
};
const projectsNavigation = (
<div className="flex h-[3.25rem] min-w-0 items-center">
<div className="h-full min-w-0 flex-1 overflow-hidden">
<ProjectsToolbar filter={filter} onFilterChange={handleFilterChange} />
</div>
</div>
const overviewDetached = overviewPanelOpen && !isNarrowProjectsLayout;
const contextOpen = isNarrowProjectsLayout
? narrowContextOpen
: overviewPanelOpen;
const chromeActions = (
<>
<Button
aria-label={
contextOpen ? "Hide project context" : "Show project context"
}
aria-pressed={contextOpen}
className="h-7 w-7 text-sidebar-foreground/65 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
data-testid="projects-overview-context-toggle"
onClick={() =>
isNarrowProjectsLayout
? setNarrowContextOpen((open) => !open)
: setOverviewPanelOpen((open) => !open)
}
ref={contextToggleRef}
size="icon"
title={contextOpen ? "Hide project context" : "Show project context"}
type="button"
variant="ghost"
>
<DrawerPanelIcon
className="-scale-x-100"
side={contextOpen ? "left" : "right"}
/>
</Button>
{overviewDetached ? null : createMenu}
</>
);
return (
<div
className={cn(
"relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-tl-xl",
topChromeInset.divider,
"relative flex min-h-0 min-w-0 flex-1 flex-row overflow-hidden",
overviewDetached && "gap-2 bg-sidebar pb-2 pr-2 pt-px",
)}
data-project-context-detached={overviewDetached ? "true" : undefined}
data-testid="projects-overview-layout"
>
{/* Scroll indicator painted over the scrollbar gutter; only visible
while scrolling (native thumb is transparent). */}
<ProjectsWorkspaceChrome
actions={chromeActions}
onGoActivity={() => handleFilterChange("all")}
section={projectsSectionTitle(filter)}
/>
<div
aria-hidden="true"
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) => {
const result = await createProjectMutation.mutateAsync(input);
if (result.compatibilityWarning) {
toast.warning("Created as a standalone project", {
description: result.compatibilityWarning,
});
} else {
toast.success(`Project "${result.project.name}" created.`);
}
// Land on the complete project list after creation.
handleRepositoryScopeChange("all");
handleFilterChange("projects");
}}
onOpenChange={setCreateProjectOpen}
open={createProjectOpen}
/>
{createPullRequestOpen ? (
<CreatePullRequestDialog
onCreated={async (
createdProject,
createdRepository,
pullRequestId,
) => {
await goProject(createdProject.id, {
className={cn(
"relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden",
overviewDetached
? "ml-px rounded-2xl bg-background"
: cn("rounded-tl-xl", topChromeInset.divider),
)}
data-testid={
overviewDetached ? "projects-overview-content-pod" : undefined
}
>
<CreateProjectDialog
isCreating={createProjectMutation.isPending}
onCreate={async (input) => {
const result = await createProjectMutation.mutateAsync(input);
if (result.compatibilityWarning) {
toast.warning("Created as a standalone project", {
description: result.compatibilityWarning,
});
} else {
toast.success(`Project "${result.project.name}" created.`);
}
handleRepositoryScopeChange("all");
handleFilterChange("projects");
}}
onOpenChange={setCreateProjectOpen}
open={createProjectOpen}
/>
{createPullRequestOpen ? (
<CreatePullRequestDialog
onCreated={async (
createdProject,
createdRepository,
pullRequestId,
) => {
await goProject(createdProject.id, {
pullRequestId,
repositoryId: createdRepository.id,
});
}}
onOpenChange={setCreatePullRequestOpen}
open
projects={projects}
reposDir={activeCommunity?.reposDir}
/>
) : null}
<CreateProjectIssueDialog
onCreated={async (createdProject, createdRepository, issueId) => {
await goProject(createdProject.id, {
issueId,
repositoryId: createdRepository.id,
});
}}
onOpenChange={setCreatePullRequestOpen}
open
onOpenChange={setCreateIssueOpen}
open={createIssueOpen}
projects={projects}
reposDir={activeCommunity?.reposDir}
/>
) : null}
<CreateProjectIssueDialog
onCreated={async (createdProject, createdRepository, issueId) => {
await goProject(createdProject.id, {
issueId,
repositoryId: createdRepository.id,
});
}}
onOpenChange={setCreateIssueOpen}
open={createIssueOpen}
projects={projects}
/>
<div
className="buzz-content-scrollbar min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-scroll"
onScroll={handleContentScroll}
>
<div className="px-4 pb-7 pt-7 sm:px-6 sm:pb-8 sm:pt-8">
<div className="mx-auto w-full max-w-6xl">{projectsHeader}</div>
<div className="sticky top-0 z-30 -mx-4 bg-background/80 backdrop-blur-xl supports-backdrop-filter:bg-background/65 dark:bg-background/75 dark:supports-backdrop-filter:bg-background/60 sm:-mx-6">
<div className="px-4 sm:px-6">
<div className="mx-auto w-full max-w-6xl">
{projectsNavigation}
</div>
</div>
</div>
<div className="mx-auto w-full max-w-6xl">
<div className="w-full min-w-0 pb-4 pt-4">
{filter === "all" ? (
<ProjectsOverviewPanel
metadata={
<ProjectsOverviewRail
profiles={profiles}
projects={projects}
summaries={activitySummariesQuery.data}
/>
}
onSelectSection={(section) => {
handleFilterChange(section);
}}
projects={projects}
summaries={activitySummariesQuery.data}
<div className="relative min-h-0 min-w-0 flex-1">
<div
aria-hidden="true"
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}
/>
<div
className="buzz-content-scrollbar h-full min-h-0 min-w-0 overflow-x-hidden overflow-y-scroll"
onScroll={handleContentScroll}
>
<div className="px-4 pb-4">
<div className="w-full space-y-3">
<div
className={cn(
"sticky top-0 z-30 -mx-4 flex h-13 min-w-0 items-center gap-1.5 px-4",
PROJECT_COLUMN_HEADER_BACKDROP_CLASS,
overviewDetached && "rounded-t-2xl",
)}
data-testid="projects-page-tabs"
>
<section className="space-y-3">{activityFeed}</section>
</ProjectsOverviewPanel>
) : (
<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" ? (
<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 === "all" ? (
<Button
aria-label="Search everything"
className="h-7 w-7 shrink-0 text-muted-foreground hover:text-foreground"
data-testid="projects-activity-search"
onClick={openAppSearch}
size="icon"
title="Search everything"
type="button"
variant="ghost"
>
<Search className="h-4 w-4" />
</Button>
) : null}
<ProjectsToolbar
filter={filter}
onFilterChange={handleFilterChange}
/>
</div>
<div
className={
filter === "all" ? "mx-auto w-full max-w-xl" : "w-full"
}
>
{filter === "all" ? (
<ProjectsOverviewPanel>
<ProjectsActivityIntro />
<section className="space-y-3">{activityFeed}</section>
</ProjectsOverviewPanel>
) : (
<>
<ProjectSectionHeader
className="-mx-4"
icon={projectsSectionIcon(filter)}
testId="projects-page-header"
title={projectsSectionTitle(filter)}
/>
) : filter === "issues" ? (
<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" ? (
projectItems
) : (
repositoryItems
)}
</div>
</section>
)}
<section>
<div className="space-y-3">
{filter === "channels" ? null : listHeaderBar}
{filter === "prs" ? (
<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" ? (
<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 === "channels" ? (
<ProjectsChannelsList projects={projects} />
) : filter === "projects" ? (
projectItems
) : (
repositoryItems
)}
</div>
</section>
</>
)}
</div>
</div>
</div>
</div>
</div>
</div>
{overviewDetached ? (
<aside
aria-label="Project context"
className="relative z-30 flex h-full shrink-0 flex-col overflow-hidden bg-transparent"
style={{ width: PROJECT_CONTEXT_PANEL_DEFAULT_WIDTH_PX }}
>
<div className="min-h-0 flex-1 overflow-y-auto">
<ProjectsOverviewContextPanel
{...contextPanelProps}
onSelectSection={(section) => {
handleFilterChange(section);
}}
/>
</div>
</aside>
) : null}
{isNarrowProjectsLayout ? (
<ProjectsOverviewContextSheet
onCloseAutoFocus={(event) => {
// Return focus to the chrome toggle so the keyboard journey can
// continue where it started.
event.preventDefault();
contextToggleRef.current?.focus();
}}
onOpenChange={setNarrowContextOpen}
open={narrowContextOpen}
>
<ProjectsOverviewContextPanel
{...contextPanelProps}
onSelectSection={(section) => {
handleFilterChange(section);
setNarrowContextOpen(false);
}}
/>
</ProjectsOverviewContextSheet>
) : null}
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { FolderGit2, GitBranch, Globe, SquareTerminal } from "lucide-react";
import { FolderGit2, Globe, SquareTerminal } from "lucide-react";
import type {
Project,
@@ -16,9 +16,9 @@ import {
import { repositoryShareLink } from "@/features/projects/lib/projectShareLinks";
import {
formatExactTimestamp,
listRowDescription,
relativeTime,
} from "@/features/projects/lib/projectsViewHelpers";
import { cn } from "@/shared/lib/cn";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { useRelayOrigin } from "@/shared/lib/useRelayOrigin";
import { BuzzMark } from "@/shared/ui/buzz-logo/BuzzMark";
@@ -26,12 +26,10 @@ import { Card } from "@/shared/ui/card";
import { DropdownMenuItem } from "@/shared/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import {
PROJECT_LIST_ROW_CLASS,
PROJECT_LIST_ROW_DATE_CLASS,
PROJECT_LIST_ROW_PREVIEW_CLASS,
PROJECT_LIST_ROW_TITLE_CLASS,
PROJECT_LIST_ROW_TRAILING_CLASS,
} from "./projectListRowStyles";
import { ProjectEntityListRow } from "./ProjectEntityListRow";
import {
ProjectActivityBar,
ProjectPeopleStack,
@@ -55,7 +53,13 @@ type RepositoryItemProps = RepositoryListItem & {
summary?: ProjectActivitySummary;
};
function RepositoryHostIcon({ repository }: { repository: Repository }) {
function RepositoryHostIcon({
compact = false,
repository,
}: {
compact?: boolean;
repository: Repository;
}) {
const host = projectRepoHostForRepository(repository, useRelayOrigin());
const label =
host.kind === "buzz"
@@ -63,25 +67,31 @@ function RepositoryHostIcon({ repository }: { repository: Repository }) {
: host.kind === "external"
? `Git data hosted on ${host.host}`
: "Repository host";
const mark =
host.kind === "buzz" ? (
<BuzzMark className={compact ? "h-3.5 w-4" : "h-4.5 w-5"} />
) : host.kind === "external" && host.host === "github.com" ? (
<GitHubMark className={compact ? "h-3.5 w-3.5" : "h-4.5 w-4.5"} />
) : host.kind === "external" ? (
<Globe className={compact ? "h-3.5 w-3.5" : "h-4.5 w-4.5"} />
) : (
<FolderGit2 className={compact ? "h-3.5 w-3.5" : "h-4.5 w-4.5"} />
);
return (
<Tooltip>
<TooltipTrigger asChild>
<span
aria-label={label}
className="pointer-events-auto flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-border/60 bg-muted/40 text-muted-foreground"
className={
compact
? "pointer-events-auto flex h-4 w-4 shrink-0 items-center justify-center text-muted-foreground/70"
: "pointer-events-auto flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-border/60 bg-muted/40 text-muted-foreground"
}
data-testid="repository-host-icon"
role="img"
>
{host.kind === "buzz" ? (
<BuzzMark className="h-4.5 w-5" />
) : host.kind === "external" && host.host === "github.com" ? (
<GitHubMark className="h-4.5 w-4.5" />
) : host.kind === "external" ? (
<Globe className="h-4.5 w-4.5" />
) : (
<FolderGit2 className="h-4.5 w-4.5" />
)}
{mark}
</span>
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
@@ -268,66 +278,30 @@ export function RepositoryListRow(props: RepositoryItemProps) {
repository,
summary,
} = props;
const updatedAt = summary?.updatedAt || repository.createdAt;
return (
<div
className={cn(PROJECT_LIST_ROW_CLASS, "py-3")}
data-testid={`repository-row-${repository.dtag}`}
>
<RepositoryOpenButton
onOpen={onOpen}
project={project}
repository={repository}
/>
<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
profiles={profiles}
project={project}
repository={repository}
/>
</div>
<div
className={cn(PROJECT_LIST_ROW_TRAILING_CLASS, "pointer-events-auto")}
>
<div
className="hidden w-24 shrink-0 items-center gap-1.5 text-xs text-muted-foreground md:flex"
data-testid="repositories-row-branch"
>
<GitBranch className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{repository.defaultBranch}</span>
</div>
<div
className="hidden items-center gap-3 xl:flex"
data-testid="repositories-row-summary"
>
<ProjectStatsRow fixedColumns summary={summary} />
<div className="w-20 shrink-0">
<ProjectActivityBar summary={summary} />
</div>
</div>
<div
className="hidden w-24 shrink-0 justify-end lg:flex"
data-testid="repositories-row-people"
>
<ProjectPeopleStack
profiles={profiles}
pubkeys={repositoryPeople(repository, summary)}
workOwnerPubkey={repository.owner}
/>
</div>
<div
className={PROJECT_LIST_ROW_DATE_CLASS}
data-testid="repositories-row-date"
>
<RepositoryUpdatedLabel repository={repository} summary={summary} />
</div>
<RepositoryActionsMenu
hasLocal={hasLocal}
onOpenTerminal={onOpenTerminal}
repository={repository}
/>
</div>
</div>
</div>
<ProjectEntityListRow
affiliation={project.name}
affiliationTestId="repositories-row-project"
dateSeconds={updatedAt}
dateTestId="repositories-row-date"
description={listRowDescription(repository.description, repository.name)}
descriptionTestId="repositories-row-description"
icon={<RepositoryHostIcon compact repository={repository} />}
onClick={() => onOpen(project, repository)}
people={repositoryPeople(repository, summary)}
peopleTestId="repositories-row-people"
profiles={profiles}
testId={`repository-row-${repository.dtag}`}
title={repository.name}
titleAttr={repository.name}
trailing={
<RepositoryActionsMenu
hasLocal={hasLocal}
onOpenTerminal={onOpenTerminal}
repository={repository}
/>
}
/>
);
}

View File

@@ -0,0 +1,247 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { projectsOverviewContext } from "./projectsOverviewContext.ts";
const CHANNEL_A = "11111111-1111-4111-8111-111111111111";
const CHANNEL_B = "22222222-2222-4222-8222-222222222222";
const OWNER = "a".repeat(64);
const TASK_AUTHOR = "b".repeat(64);
const REVIEW_AUTHOR = "c".repeat(64);
const BYSTANDER = "d".repeat(64);
function makeProject(overrides = {}) {
return {
id: "project-buzz",
name: "buzz",
owner: OWNER,
projectChannelId: null,
repositories: [
{
id: "repo-buzz",
name: "buzz",
owner: OWNER,
channelId: CHANNEL_A,
contributors: [BYSTANDER],
},
],
...overrides,
};
}
function makeIssue(status, author = TASK_AUTHOR) {
return {
author,
comments: [],
createdAt: 1_700_000_000,
status,
};
}
function makePullRequest(status, author = REVIEW_AUTHOR) {
return {
approvals: [],
author,
comments: [],
commit: "1".repeat(40),
createdAt: 1_700_086_400,
initialCommit: "1".repeat(40),
status,
updates: [],
};
}
test("activity pod shows workspace details and a create-project action", () => {
const context = projectsOverviewContext({
filter: "all",
issues: [],
projects: [
makeProject(),
makeProject({
id: "project-relay",
name: "relay",
repositories: [
{ id: "repo-relay", name: "relay-tools", channelId: CHANNEL_B },
],
}),
],
pullRequests: [],
summaries: {
"project-buzz": { issueCount: 4, prCount: 2 },
"project-relay": { issueCount: 1, prCount: 3 },
},
});
assert.equal(context.title, "Projects");
assert.equal(context.detailsTitle, "Details");
assert.equal(context.action?.label, "Create project");
assert.deepEqual(
context.stats.map((stat) => [stat.label, stat.count]),
[
["Projects", 2],
["Repositories", 2],
["Channels", 2],
["Tasks", 5],
["Reviews", 5],
],
);
});
test("projects pod keeps create-project and drops task/review totals", () => {
const context = projectsOverviewContext({
filter: "projects",
issues: [],
projects: [makeProject(), makeProject({ id: "project-relay" })],
pullRequests: [],
});
assert.equal(context.title, "Projects");
assert.equal(context.action?.kind, "project");
assert.deepEqual(
context.stats.map((stat) => stat.label),
["Projects", "Repositories", "Channels"],
);
});
test("repositories pod matches repository activity copy", () => {
const context = projectsOverviewContext({
filter: "repositories",
issues: [makeIssue("In Progress"), makeIssue("Done")],
projects: [makeProject()],
pullRequests: [makePullRequest("Open"), makePullRequest("Merged")],
});
assert.equal(context.title, "Repositories");
assert.equal(context.detailsTitle, "Repository activity");
assert.equal(context.action, null);
assert.deepEqual(
context.stats.map((stat) => [stat.label, stat.count]),
[
["Repositories", 1],
["Active tasks", 1],
["Open reviews", 1],
],
);
});
test("channels pod titles itself and omits a primary create action", () => {
const context = projectsOverviewContext({
filter: "channels",
issues: [],
projects: [makeProject()],
pullRequests: [],
});
assert.equal(context.title, "Channels");
assert.equal(context.detailsTitle, "Details");
assert.equal(context.action, null);
assert.deepEqual(
context.stats.map((stat) => stat.label),
["Channels", "Projects", "Repositories"],
);
});
test("tasks pod breaks down active and completed work", () => {
const context = projectsOverviewContext({
filter: "issues",
issues: [makeIssue("Triage"), makeIssue("Done"), makeIssue("Closed")],
projects: [makeProject()],
pullRequests: [],
});
assert.equal(context.title, "Tasks");
assert.equal(context.action?.label, "Create task");
assert.deepEqual(
context.stats.map((stat) => [stat.label, stat.count]),
[
["Tasks", 3],
["Active", 1],
["Completed", 2],
],
);
});
test("reviews pod breaks down open and merged work", () => {
const context = projectsOverviewContext({
filter: "prs",
issues: [],
projects: [makeProject()],
pullRequests: [
makePullRequest("Open"),
makePullRequest("Draft"),
makePullRequest("Merged"),
makePullRequest("Closed"),
],
});
assert.equal(context.title, "Reviews");
assert.equal(context.detailsTitle, "Review activity");
assert.equal(context.action?.label, "Create review");
assert.deepEqual(
context.stats.map((stat) => [stat.label, stat.count]),
[
["Reviews", 4],
["Open", 2],
["Merged", 1],
],
);
});
function peopleContext(filter, overrides = {}) {
return projectsOverviewContext({
filter,
issues: [makeIssue("In Progress")],
projects: [makeProject()],
pullRequests: [makePullRequest("Open")],
summaries: {
"project-buzz": {
activityByDay: { "2026-08-01": 4 },
latestCommit: { author: REVIEW_AUTHOR },
},
},
...overrides,
});
}
test("people on activity, tasks, reviews, and repositories are actual actors", () => {
const activity = peopleContext("all");
const tasks = peopleContext("issues");
const reviews = peopleContext("prs");
const repositories = peopleContext("repositories");
assert.deepEqual([...activity.people].sort(), [TASK_AUTHOR, REVIEW_AUTHOR]);
assert.deepEqual(tasks.people, [TASK_AUTHOR]);
assert.deepEqual(reviews.people, [REVIEW_AUTHOR]);
assert.deepEqual(repositories.people, [REVIEW_AUTHOR]);
assert.equal(activity.people.includes(BYSTANDER), false);
assert.equal(activity.people.includes(OWNER), false);
});
test("listed repository contributors without commits stay off the repositories pod", () => {
const context = peopleContext("repositories", {
issues: [],
pullRequests: [],
summaries: {},
});
assert.deepEqual(context.people, []);
});
test("projects pod shows owners and keeps the activity graph; channels hides both", () => {
const projects = peopleContext("projects");
const channels = peopleContext("channels");
assert.deepEqual(projects.people, [OWNER]);
assert.deepEqual(projects.activityByDay, { "2026-08-01": 4 });
assert.deepEqual(channels.people, []);
assert.equal(channels.activityByDay, null);
});
test("activity, projects, and repositories pods show the contribution graph", () => {
const expected = { "2026-08-01": 4 };
assert.deepEqual(peopleContext("all").activityByDay, expected);
assert.deepEqual(peopleContext("projects").activityByDay, expected);
assert.deepEqual(peopleContext("repositories").activityByDay, expected);
assert.equal(peopleContext("issues").activityByDay, null);
assert.equal(peopleContext("prs").activityByDay, null);
assert.equal(peopleContext("channels").activityByDay, null);
});

View File

@@ -0,0 +1,451 @@
import type {
Project,
ProjectActivitySummary,
ProjectIssue,
ProjectPullRequest,
} from "@/features/projects/hooks";
import { projectContributorActivityCounts } from "@/features/projects/lib/projectContributorMatching";
import { uniqueProjectRelatedChannelCount } from "@/features/projects/lib/projectRelatedChannels";
import type { ProjectsFilter } from "@/features/projects/lib/projectsViewHelpers";
import { normalizePubkey } from "@/shared/lib/pubkey";
import {
projectReviewActivity,
projectTaskActivity,
} from "./projectRightPanelContext";
import { projectsSectionTitle } from "./projectsSectionMeta";
export type ProjectsOverviewSection =
| "projects"
| "repositories"
| "channels"
| "prs"
| "issues";
export type OverviewContextStatIcon =
| "projects"
| "repositories"
| "channels"
| "tasks"
| "reviews"
| "completed"
| "merged";
export type OverviewContextAction = {
kind: "project" | "issue" | "pullRequest";
label: string;
testId: string;
} | null;
export type OverviewContextStat = {
count: number;
icon: OverviewContextStatIcon;
label: string;
section: ProjectsOverviewSection;
};
export type OverviewContextPresentation = {
action: OverviewContextAction;
activityByDay: Record<string, number> | null;
detailsTitle: string;
people: string[];
stats: OverviewContextStat[];
title: string;
};
type OverviewContextInput = {
filter: ProjectsFilter;
issues: ProjectIssue[];
projects: Project[];
pullRequests: ProjectPullRequest[];
summaries?: Record<string, ProjectActivitySummary>;
};
function summaryWorkItemCounts(
projects: Project[],
summaries: Record<string, ProjectActivitySummary> | undefined,
) {
return projects.reduce(
(stats, project) => {
const summary = summaries?.[project.id];
return {
issues: stats.issues + (summary?.issueCount ?? 0),
prs: stats.prs + (summary?.prCount ?? 0),
};
},
{ issues: 0, prs: 0 },
);
}
function repositoryCount(projects: Project[]) {
return projects.reduce(
(count, project) => count + project.repositories.length,
0,
);
}
function resolvedTaskActivity(
issues: ProjectIssue[],
summaryIssueCount: number,
) {
if (issues.length > 0) return projectTaskActivity(issues);
return {
active: summaryIssueCount,
completed: 0,
total: summaryIssueCount,
};
}
function resolvedReviewActivity(
pullRequests: ProjectPullRequest[],
summaryPrCount: number,
) {
if (pullRequests.length > 0) return projectReviewActivity(pullRequests);
return {
merged: 0,
open: summaryPrCount,
total: summaryPrCount,
};
}
function createProjectAction(): OverviewContextAction {
return {
kind: "project",
label: "Create project",
testId: "projects-overview-create-project",
};
}
function uniquePubkeys(pubkeys: Array<string | null | undefined>) {
return [
...new Set(
pubkeys
.filter(
(pubkey): pubkey is string =>
typeof pubkey === "string" && pubkey.trim().length > 0,
)
.map((pubkey) => normalizePubkey(pubkey)),
),
];
}
function summaryActivityByDay(
projects: Project[],
summaries: Record<string, ProjectActivitySummary> | undefined,
) {
const merged: Record<string, number> = {};
for (const project of projects) {
const byDay = summaries?.[project.id]?.activityByDay;
if (!byDay) continue;
for (const [day, count] of Object.entries(byDay)) {
merged[day] = (merged[day] ?? 0) + count;
}
}
return merged;
}
function latestCommitAuthors(
summaries: Record<string, ProjectActivitySummary> | undefined,
) {
if (!summaries) return [];
return uniquePubkeys(
Object.values(summaries)
.map((summary) => summary.latestCommit?.author)
.filter((author): author is string => Boolean(author)),
);
}
function workspaceActorCounts(
issues: ProjectIssue[],
pullRequests: ProjectPullRequest[],
) {
return projectContributorActivityCounts({
owner: "",
contributors: [],
issues: issues
.filter((issue) => issue.author)
.map((issue) => ({
author: issue.author,
comments: issue.comments ?? [],
})),
pullRequests: pullRequests
.filter((pullRequest) => pullRequest.author)
.map((pullRequest) => ({
author: pullRequest.author,
approvals: pullRequest.approvals ?? [],
comments: pullRequest.comments ?? [],
commit: pullRequest.commit,
initialCommit: pullRequest.initialCommit,
updates: pullRequest.updates ?? [],
})),
});
}
function peopleWithKind(
counts: ReturnType<typeof workspaceActorCounts>,
kind: "any" | "commits" | "reviews" | "tasks",
) {
return Object.entries(counts)
.filter(([, activity]) => {
if (kind === "tasks") return activity.tasks > 0;
if (kind === "reviews") return activity.reviews > 0;
if (kind === "commits") return activity.commits > 0;
return activity.tasks + activity.reviews + activity.commits > 0;
})
.map(([pubkey]) => pubkey);
}
function projectOwners(projects: Project[]) {
return uniquePubkeys(
projects.flatMap((project) => [
project.owner,
...project.repositories.map((repository) => repository.owner),
]),
);
}
function overviewContextPeople({
filter,
issues,
projects,
pullRequests,
summaries,
}: OverviewContextInput) {
const counts = workspaceActorCounts(issues, pullRequests);
const commitAuthors = uniquePubkeys([
...peopleWithKind(counts, "commits"),
...latestCommitAuthors(summaries),
]);
if (filter === "issues") return peopleWithKind(counts, "tasks");
if (filter === "prs") return peopleWithKind(counts, "reviews");
if (filter === "repositories") return commitAuthors;
if (filter === "channels") return [];
if (filter === "all") {
return uniquePubkeys([...peopleWithKind(counts, "any"), ...commitAuthors]);
}
return projectOwners(projects);
}
function overviewContextActivityByDay({
filter,
projects,
summaries,
}: OverviewContextInput): Record<string, number> | null {
if (filter === "all" || filter === "projects" || filter === "repositories") {
return summaryActivityByDay(projects, summaries);
}
return null;
}
/** Title, people, activity, and stats for the Projects overview context pod. */
export function projectsOverviewContext(
input: OverviewContextInput,
): OverviewContextPresentation {
const { filter, issues, projects, pullRequests, summaries } = input;
const summaryCounts = summaryWorkItemCounts(projects, summaries);
const tasks = resolvedTaskActivity(issues, summaryCounts.issues);
const reviews = resolvedReviewActivity(pullRequests, summaryCounts.prs);
const channelCount = uniqueProjectRelatedChannelCount(projects);
const repositories = repositoryCount(projects);
const people = overviewContextPeople(input);
const activityByDay = overviewContextActivityByDay(input);
if (filter === "repositories") {
return {
action: null,
activityByDay,
detailsTitle: "Repository activity",
people,
stats: [
{
count: repositories,
icon: "repositories",
label: "Repositories",
section: "repositories",
},
{
count: tasks.active,
icon: "tasks",
label: "Active tasks",
section: "issues",
},
{
count: reviews.open,
icon: "reviews",
label: "Open reviews",
section: "prs",
},
],
title: "Repositories",
};
}
if (filter === "channels") {
return {
action: null,
activityByDay,
detailsTitle: "Details",
people,
stats: [
{
count: channelCount,
icon: "channels",
label: "Channels",
section: "channels",
},
{
count: projects.length,
icon: "projects",
label: "Projects",
section: "projects",
},
{
count: repositories,
icon: "repositories",
label: "Repositories",
section: "repositories",
},
],
title: "Channels",
};
}
if (filter === "issues") {
return {
action: {
kind: "issue",
label: "Create task",
testId: "projects-overview-create-issue",
},
activityByDay,
detailsTitle: "Details",
people,
stats: [
{
count: tasks.total,
icon: "tasks",
label: "Tasks",
section: "issues",
},
{
count: tasks.active,
icon: "tasks",
label: "Active",
section: "issues",
},
{
count: tasks.completed,
icon: "completed",
label: "Completed",
section: "issues",
},
],
title: "Tasks",
};
}
if (filter === "prs") {
return {
action: {
kind: "pullRequest",
label: "Create review",
testId: "projects-overview-create-pull-request",
},
activityByDay,
detailsTitle: "Review activity",
people,
stats: [
{
count: reviews.total,
icon: "reviews",
label: "Reviews",
section: "prs",
},
{
count: reviews.open,
icon: "reviews",
label: "Open",
section: "prs",
},
{
count: reviews.merged,
icon: "merged",
label: "Merged",
section: "prs",
},
],
title: "Reviews",
};
}
if (filter === "all") {
return {
action: createProjectAction(),
activityByDay,
detailsTitle: "Details",
people,
stats: [
{
count: projects.length,
icon: "projects",
label: "Projects",
section: "projects",
},
{
count: repositories,
icon: "repositories",
label: "Repositories",
section: "repositories",
},
{
count: channelCount,
icon: "channels",
label: "Channels",
section: "channels",
},
{
count: tasks.total,
icon: "tasks",
label: "Tasks",
section: "issues",
},
{
count: reviews.total,
icon: "reviews",
label: "Reviews",
section: "prs",
},
],
title: "Projects",
};
}
return {
action: createProjectAction(),
activityByDay,
detailsTitle: "Details",
people,
stats: [
{
count: projects.length,
icon: "projects",
label: "Projects",
section: "projects",
},
{
count: repositories,
icon: "repositories",
label: "Repositories",
section: "repositories",
},
{
count: channelCount,
icon: "channels",
label: "Channels",
section: "channels",
},
],
title: projectsSectionTitle(filter),
};
}

View File

@@ -0,0 +1,33 @@
import {
CircleDot,
FolderGit2,
Folders,
GitPullRequest,
Hash,
type LucideIcon,
} from "lucide-react";
import type { ProjectsFilter } from "@/features/projects/lib/projectsViewHelpers";
export function projectsSectionTitle(filter: ProjectsFilter) {
if (filter === "all") return "Activity";
if (filter === "prs") return "Reviews";
if (filter === "issues") return "Tasks";
if (filter === "repositories") return "Repositories";
if (filter === "channels") return "Channels";
return "Projects";
}
export function projectsSectionIcon(filter: ProjectsFilter): LucideIcon {
if (filter === "prs") return GitPullRequest;
if (filter === "issues") return CircleDot;
if (filter === "repositories") return FolderGit2;
if (filter === "channels") return Hash;
return Folders;
}
export function openAppSearch() {
document
.querySelector<HTMLButtonElement>('[data-testid="open-search"]')
?.click();
}

View File

@@ -1,11 +1,15 @@
import { useThreadPanelWidth } from "@/shared/hooks/useThreadPanelWidth";
import type { ProjectRightPanelMode } from "./ProjectRightPanelControls";
/** Default width of the project context rail (repository details and overview). */
export const PROJECT_CONTEXT_PANEL_DEFAULT_WIDTH_PX = 280;
const PROJECT_CONTEXT_PANEL_MIN_WIDTH_PX = 240;
export function useProjectPanelWidths(mode: ProjectRightPanelMode) {
const threadPanelWidth = useThreadPanelWidth();
const repositoryContextWidth = useThreadPanelWidth(undefined, {
defaultWidthPx: 280,
minWidthPx: 240,
defaultWidthPx: PROJECT_CONTEXT_PANEL_DEFAULT_WIDTH_PX,
minWidthPx: PROJECT_CONTEXT_PANEL_MIN_WIDTH_PX,
sessionKey: "buzz.desktop.project-context-width",
});
return {

View File

@@ -1,7 +1,7 @@
/**
* Persisted remote-head watermark for sidebar-preference sync managers.
*
* Each manager (sections, sort, stars, mutes) persists the highest
* Each manager (sections, sort, stars, mutes, project membership) persists the highest
* `created_at` it has ever observed from the relay under a key scoped to
* pubkey + relay + blob type. On the next boot the manager reads this value
* back: if it is > 0 a remote blob has existed before and seed-publishing
@@ -96,7 +96,7 @@ export type BootstrapResult<T> =
| { action: "hold" };
/**
* Shared boot policy for all four sidebar-preference sync managers.
* Shared boot policy for all sidebar-preference sync managers.
*
* Each manager calls this from its `bootstrap()` method, supplying its
* surface-specific fetch, publish, and local-store accessors. The full

View File

@@ -25,11 +25,9 @@ import { isProjectOwnedByCurrentUser } from "@/features/projects/lib/projectsVie
import { projectShareLink } from "@/features/projects/lib/projectShareLinks";
import {
addProjectToSidebar,
PROJECT_SIDEBAR_MEMBERSHIP_EVENT,
type ProjectSidebarMembershipChange,
readProjectSidebarMembership,
removeProjectFromSidebar,
} from "@/features/projects/lib/projectSidebarMembership";
import { useProjectSidebarMembership } from "@/features/projects/lib/useProjectSidebarMembership";
import { selectProjectRepository } from "@/features/projects/projectModels";
import { projectMatchesRouteId } from "@/features/projects/projectRoutes";
import { ProjectBrowserDialog } from "@/features/projects/ui/ProjectBrowserDialog";
@@ -152,42 +150,13 @@ function SidebarProjectsSectionContent() {
React.useState<SidebarProjectExpansionState>(() =>
readSidebarProjectExpansion(relayOrigin, currentPubkey),
);
const [addedProjectAddresses, setAddedProjectAddresses] = React.useState<
string[]
>(() => readProjectSidebarMembership(relayOrigin, currentPubkey));
const addedProjectAddresses = useProjectSidebarMembership(
relayOrigin,
currentPubkey,
);
const createProjectMutation = useCreateProjectMutation();
const deleteProjectMutation = useDeleteProjectMutation();
const isPending = projectsQuery.isPending || identityQuery.isPending;
React.useEffect(() => {
setAddedProjectAddresses(
readProjectSidebarMembership(relayOrigin, currentPubkey),
);
// Consume the membership carried on the event: when persistence is
// unavailable the change lives only in the event detail, and re-reading
// localStorage would silently revert the user's add/remove.
const onChange = (event: Event) => {
const detail = (event as CustomEvent<ProjectSidebarMembershipChange>)
.detail;
if (
detail &&
detail.relayOrigin === relayOrigin &&
currentPubkey &&
detail.pubkey.toLowerCase() === currentPubkey.toLowerCase()
) {
setAddedProjectAddresses(detail.addresses);
return;
}
setAddedProjectAddresses(
readProjectSidebarMembership(relayOrigin, currentPubkey),
);
};
globalThis.addEventListener(PROJECT_SIDEBAR_MEMBERSHIP_EVENT, onChange);
return () =>
globalThis.removeEventListener(
PROJECT_SIDEBAR_MEMBERSHIP_EVENT,
onChange,
);
}, [currentPubkey, relayOrigin]);
React.useEffect(() => {
setProjectExpansion(
readSidebarProjectExpansion(relayOrigin, currentPubkey),
@@ -357,14 +326,7 @@ function SidebarProjectsSectionContent() {
isActive={isActive}
isExpanded={isExpanded}
onDelete={() => setProjectToDelete(project)}
onOpen={() => {
if (isActive) {
setProjectExpanded(project, !isExpanded);
return;
}
setProjectExpanded(project, true);
void goProject(project.id);
}}
onOpen={() => setProjectExpanded(project, !isExpanded)}
onRemove={() => handleRemove(project)}
project={project}
/>
@@ -631,8 +593,10 @@ function SidebarProjectRow({
tooltip={project.name}
type="button"
>
<ProjectIcon className="h-4 w-4" />
<SidebarMenuLabel>{project.name}</SidebarMenuLabel>
<ProjectIcon className={cn("h-4 w-4", !isActive && "opacity-80")} />
<SidebarMenuLabel className={cn(!isActive && "opacity-80")}>
{project.name}
</SidebarMenuLabel>
</SidebarMenuButton>
{canDelete ? (
<SidebarMenuAction

View File

@@ -42,12 +42,14 @@ export const KIND_HUDDLE_PARTICIPANT_JOINED = 48101;
export const KIND_HUDDLE_PARTICIPANT_LEFT = 48102;
export const KIND_HUDDLE_ENDED = 48103;
// NIP-78 application-specific data. All use kind 30078; the relay
// differentiates them by d-tag ("read-state:<slotId>", "channel-sections", "channel-mutes", "channel-stars", "channel-sort").
// differentiates them by d-tag ("read-state:<slotId>", "channel-sections",
// "channel-mutes", "channel-stars", "channel-sort", "project-sidebar-membership").
export const KIND_READ_STATE = 30078;
export const KIND_CHANNEL_SECTIONS = 30078;
export const KIND_CHANNEL_MUTES = 30078;
export const KIND_CHANNEL_STARS = 30078;
export const KIND_CHANNEL_SORT = 30078;
export const KIND_PROJECT_SIDEBAR_MEMBERSHIP = 30078;
export const KIND_COMMUNITY_THEME = 30078;
// NIP-33 persona/team/managed-agent projection events (d-tag keyed). Published
// backend-side as secrets-stripped snapshots; the inbound sync hook subscribes

View File

@@ -786,10 +786,11 @@
}
/*
* Repository context is a sibling pod, not a column inside the app's
* standard content card. This deliberately sits outside the cascade layers
* so it can unframe the utility-styled content surface. Chat removes the
* marker and restores the normal framed surface.
* Project context (repository detail and the projects overview rail) is a
* sibling pod, not a column inside the app's standard content card. This
* deliberately sits outside the cascade layers so it can unframe the
* utility-styled content surface. Closing the panel or opening chat removes
* the marker and restores the normal framed surface.
*/
[data-buzz-content-surface]:has([data-project-detail-screen]),
:root[data-buzz-sidebar]:not(.dark)

View File

@@ -0,0 +1,52 @@
import { motion, useReducedMotion } from "motion/react";
import type { ComponentProps } from "react";
import { cn } from "@/shared/lib/cn";
export function TerminalPanelIcon({
className,
open,
...props
}: ComponentProps<"svg"> & {
open: boolean;
}) {
const prefersReducedMotion = useReducedMotion();
return (
<svg
aria-hidden="true"
className={cn("h-4 w-auto shrink-0", className)}
fill="none"
height="22"
viewBox="0 0 24 22"
width="24"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<rect
height="20"
rx="5"
stroke="currentColor"
strokeWidth="2"
width="22"
x="1"
y="1"
/>
<motion.rect
animate={{
height: open ? 5 : 2,
rx: open ? 2.5 : 1,
y: open ? 13 : 16,
}}
fill="currentColor"
initial={false}
transition={{
duration: prefersReducedMotion ? 0 : 0.2,
ease: "easeOut",
}}
width="16"
x="4"
/>
</svg>
);
}

View File

@@ -0,0 +1,4 @@
<svg width="24" height="22" viewBox="0 0 24 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="1" width="22" height="20" rx="5" stroke="#F5EFF7" stroke-width="2"/>
<rect x="4" y="4" width="2" height="14" rx="1" fill="#F5EFF7"/>
</svg>

After

Width:  |  Height:  |  Size: 251 B

View File

@@ -0,0 +1,4 @@
<svg width="24" height="22" viewBox="0 0 24 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="1" width="22" height="20" rx="5" stroke="#F5EFF7" stroke-width="2"/>
<rect x="4" y="4" width="5" height="14" rx="2.5" fill="#F5EFF7"/>
</svg>

After

Width:  |  Height:  |  Size: 253 B

View File

@@ -0,0 +1,4 @@
<svg width="24" height="22" viewBox="0 0 24 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="1" width="22" height="20" rx="5" stroke="#F5EFF7" stroke-width="2"/>
<rect x="4" y="16" width="16" height="2" rx="1" fill="#F5EFF7"/>
</svg>

After

Width:  |  Height:  |  Size: 252 B

View File

@@ -0,0 +1,4 @@
<svg width="24" height="22" viewBox="0 0 24 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="1" width="22" height="20" rx="5" stroke="#F5EFF7" stroke-width="2"/>
<rect x="4" y="13" width="16" height="5" rx="2.5" fill="#F5EFF7"/>
</svg>

After

Width:  |  Height:  |  Size: 254 B

View File

@@ -57,7 +57,7 @@ test("top-level project lists align dates and overflow actions", async ({
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByTestId("open-projects-view").click();
await expect(
page.getByRole("heading", { level: 1, name: "Projects" }),
page.getByRole("heading", { level: 2, name: "Projects", exact: true }),
).toBeVisible();
async function trailingPositions(
@@ -103,18 +103,9 @@ test("top-level project lists align dates and overflow actions", async ({
await expect(page.getByRole("menuitem", { name: "Local" })).toBeVisible();
await page.keyboard.press("Escape");
const projectRow = page.locator('[data-testid^="project-row-"]').first();
const projectPositions = await trailingPositions(projectRow, {
summaryTestId: "projects-row-summary",
});
// Project rows show the activity bar alone — counts stay in its tooltips.
await expect(
projectRow
.getByTestId("projects-row-summary")
.getByTestId("project-activity-bar"),
).toBeVisible();
await expect(
projectRow.getByTestId("projects-row-summary"),
).not.toContainText("commits");
const projectPositions = await trailingPositions(projectRow);
await expect(projectRow.getByTestId("projects-row-context")).toBeVisible();
await expect(projectRow.getByTestId("projects-row-people")).toBeVisible();
await page.getByTestId("projects-section-repositories").click();
await page.getByRole("button", { name: "Filter repositories" }).click();
@@ -126,17 +117,14 @@ test("top-level project lists align dates and overflow actions", async ({
await expect(page.getByTestId("repository-row-relay-tools")).toBeVisible();
const repositoryRow = page.getByTestId("repository-row-buzz");
await expect(
repositoryRow.getByTestId("repositories-row-summary"),
).toContainText("commits");
repositoryRow.getByTestId("repositories-row-project"),
).toBeVisible();
await expect(
repositoryRow.getByTestId("repositories-row-branch"),
).toContainText("main");
// Subtitle is the repository location (owner/repo for Buzz-hosted repos).
await expect(repositoryRow.locator("p")).toHaveText(/\/buzz$/);
repositoryRow.getByTestId("repositories-row-description"),
).toContainText(/Relay, desktop, and mobile|community platform/);
const repositoryPositions = await trailingPositions(repositoryRow, {
actionName: /More options for/,
dateTestId: "repositories-row-date",
summaryTestId: "repositories-row-summary",
});
// No summaryX comparison: repository rows carry text stats next to the bar
// while project rows show the bar alone, so the columns differ in width by
@@ -219,11 +207,11 @@ test("top-level project lists align dates and overflow actions", async ({
.locator('[data-testid^="project-row-"]')
.first();
await expect(
responsiveRepositoryRow.getByTestId("projects-row-summary"),
).toBeHidden();
responsiveRepositoryRow.getByTestId("projects-row-context"),
).toBeVisible();
await expect(
responsiveRepositoryRow.getByTestId("projects-row-people"),
).toBeHidden();
).toBeVisible();
await expect(
responsiveRepositoryRow.getByTestId("projects-row-date"),
).toBeVisible();
@@ -470,7 +458,13 @@ test("multi-repository projects switch the active repository", async ({
},
);
await projectRow.click();
await expect(page).toHaveURL(/\/projects\//);
await expect(page).not.toHaveURL(/\/projects\//);
await expect(projectRow).toHaveAttribute("aria-expanded", "false");
await expect(relayToolsRepository).toBeHidden();
await projectRow.click();
await expect(page).not.toHaveURL(/\/projects\//);
await expect(projectRow).toHaveAttribute("aria-expanded", "true");
await expect(relayToolsRepository).toBeVisible();
const projectSidebarMetrics = await sidebarScrollContent.evaluate(
(element) => {
@@ -496,7 +490,7 @@ test("multi-repository projects switch the active repository", async ({
return element.scrollTop;
});
await projectRow.click();
await expect(page).toHaveURL(/\/projects\//);
await expect(page).not.toHaveURL(/\/projects\//);
await expect(projectRow).toHaveAttribute("aria-expanded", "true");
await expect
.poll(() =>

View File

@@ -867,9 +867,11 @@ test("project pull request author rollover stays identity-only", async ({
),
).toBeVisible();
const authorLabel = (
await author.getByTestId("projects-pr-author-label").innerText()
).trim();
const authorLabel =
(
await author.getByTestId("projects-pr-author-label").textContent()
)?.trim() ?? "";
expect(authorLabel.length).toBeGreaterThan(0);
await author.hover();
const rollover = page.getByTestId("projects-pr-author-rollover");
await expect(rollover).toBeVisible();
@@ -898,9 +900,11 @@ test("project issue author rollover matches pull requests", async ({
),
).toBeVisible();
const authorLabel = (
await author.getByTestId("projects-issue-author-label").innerText()
).trim();
const authorLabel =
(
await author.getByTestId("projects-issue-author-label").textContent()
)?.trim() ?? "";
expect(authorLabel.length).toBeGreaterThan(0);
await author.hover();
const rollover = page.getByTestId("projects-issue-author-rollover");
await expect(rollover).toBeVisible();
@@ -993,7 +997,7 @@ test("project overview reports aggregate work-item failures", async ({
);
});
test("project overview does not paint a background behind its cards", async ({
test("project overview presents collapsible context beside grouped activity", async ({
page,
}) => {
await enableProjectsFeature(page);
@@ -1005,16 +1009,138 @@ test("project overview does not paint a background behind its cards", async ({
"background-color",
"rgba(0, 0, 0, 0)",
);
await expect(page.getByTestId("projects-overview-layout")).toHaveAttribute(
"data-project-context-detached",
"true",
);
const overviewLayout = page.getByTestId("projects-overview-layout");
const overviewContentPod = page.getByTestId("projects-overview-content-pod");
const appContentSurface = page
.locator("[data-buzz-content-surface]")
.filter({ has: overviewLayout })
.first();
await expect(appContentSurface).toHaveCSS(
"background-color",
"rgba(0, 0, 0, 0)",
);
await expect(appContentSurface).toHaveCSS("border-radius", "0px");
await expect(overviewContentPod).toHaveCSS("border-radius", "16px");
await expect(page.getByTestId("projects-workspace-chrome")).toContainText(
"Projects",
);
await expect(page.getByTestId("projects-workspace-chrome")).toContainText(
"Activity",
);
await expect(page.getByTestId("projects-page-tabs")).toBeVisible();
await expect(page.getByTestId("projects-page-header")).toContainText(
"Welcome to Activity",
);
await expect(page.getByTestId("projects-activity-search")).toBeVisible();
await expect(page.getByTestId("projects-activity-intro")).toContainText(
"Keep up with commits, reviews, and tasks",
);
await expect(
page.getByTestId("projects-overview-context-panel"),
).toBeVisible();
await expect(page.getByTestId("projects-overview-context-title")).toHaveText(
"Projects",
);
expect(
Math.round(
(await page.getByTestId("projects-overview-context-panel").boundingBox())
?.width ?? 0,
),
).toBe(280);
await expect(
page.getByTestId("projects-overview-create-project"),
).toContainText("Create project");
await expect(
page
.getByTestId("projects-overview-context-panel")
.getByTestId("projects-create-menu"),
).toBeVisible();
await expect(page.getByTestId("projects-overview-stats-pod")).toBeVisible();
await expect(
page
.getByTestId("projects-overview-context-panel")
.getByTestId("projects-overview-people"),
).toBeVisible();
await expect(
page
.getByTestId("projects-overview-stats-pod")
.getByTestId("projects-overview-people"),
).toHaveCount(0);
await expect(
page
.getByTestId("projects-overview-context-panel")
.getByTestId("projects-overview-context-title"),
).toBeVisible();
await expect(
page
.getByTestId("projects-overview-context-panel")
.getByRole("heading", { name: "Details" }),
).toHaveCount(0);
await expect(
page
.getByTestId("projects-overview-context-panel")
.getByRole("heading", { name: "Activity" }),
).toHaveCount(0);
await expect(
page
.getByTestId("projects-overview-context-panel")
.getByTestId("projects-overview-activity"),
).toBeVisible();
await expect(
page.getByTestId("projects-activity-group").first(),
).toContainText(/This week|Last week|Earlier/);
const searchBox = await page
.getByTestId("projects-activity-search")
.boundingBox();
const tabMenuBox = await page.getByTestId("projects-page-tabs").boundingBox();
const firstTabBox = await page
.getByTestId("projects-section-all")
.boundingBox();
const introBox = await page
.getByTestId("projects-activity-intro")
.boundingBox();
const feedBox = await page
.getByTestId("projects-activity-group")
.first()
.boundingBox();
const contentPodBox = await overviewContentPod.boundingBox();
expect(searchBox).toBeTruthy();
expect(tabMenuBox).toBeTruthy();
expect(firstTabBox).toBeTruthy();
expect(introBox).toBeTruthy();
expect(feedBox).toBeTruthy();
expect(contentPodBox).toBeTruthy();
expect(searchBox?.x ?? 0).toBeLessThan(firstTabBox?.x ?? 0);
expect(Math.abs((searchBox?.y ?? 0) - (firstTabBox?.y ?? 0))).toBeLessThan(8);
expect(tabMenuBox?.y ?? 0).toBeLessThan(introBox?.y ?? 0);
expect(introBox?.y ?? 0).toBeLessThan(feedBox?.y ?? 0);
expect(feedBox?.width ?? 0).toBeLessThan((contentPodBox?.width ?? 0) - 48);
const feedCenter = (feedBox?.x ?? 0) + (feedBox?.width ?? 0) / 2;
const contentCenter =
(contentPodBox?.x ?? 0) + (contentPodBox?.width ?? 0) / 2;
expect(Math.abs(feedCenter - contentCenter)).toBeLessThan(24);
const stats = page.getByTestId("projects-overview-stat");
await expect(stats).toHaveCount(4);
for (let index = 0; index < 4; index += 1) {
await expect(stats.nth(index)).toHaveCSS(
"background-color",
"rgba(0, 0, 0, 0)",
);
await expect(stats.nth(index)).toHaveCSS("border-style", "solid");
}
await expect(stats).toHaveCount(5);
await expect(stats.nth(2)).toContainText("Channels");
const createBox = await page
.getByTestId("projects-overview-create-project")
.boundingBox();
const lastStatBox = await stats.last().boundingBox();
const peopleBox = await page
.getByTestId("projects-overview-people")
.boundingBox();
expect(createBox).toBeTruthy();
expect(lastStatBox).toBeTruthy();
expect(peopleBox).toBeTruthy();
expect(peopleBox?.y ?? 0).toBeGreaterThan(createBox?.y ?? 0);
expect(peopleBox?.y ?? 0).toBeGreaterThan(
(lastStatBox?.y ?? 0) + (lastStatBox?.height ?? 0) - 1,
);
const activityCards = page.getByTestId("projects-activity-card");
await expect(activityCards.first()).toBeVisible();
@@ -1026,6 +1152,104 @@ test("project overview does not paint a background behind its cards", async ({
);
await expect(activityCards.nth(index)).toHaveCSS("border-style", "solid");
}
await page.getByTestId("projects-section-channels").click();
await expect(page.getByTestId("projects-page-header")).toContainText(
"Channels",
);
await expect(
page.getByTestId("projects-overview-context-panel"),
).toBeVisible();
await expect(page.getByTestId("projects-overview-context-title")).toHaveText(
"Channels",
);
await expect(
page.getByTestId("projects-overview-create-project"),
).toHaveCount(0);
await expect(page.getByTestId("projects-overview-people")).toHaveCount(0);
await expect(page.getByTestId("projects-overview-activity")).toHaveCount(0);
await expect(stats).toHaveCount(3);
await expect(stats.first()).toContainText("Channels");
await expect(page.getByTestId("projects-overview-layout")).toHaveAttribute(
"data-project-context-detached",
"true",
);
const channelRows = page.getByTestId("project-channel-row");
// One row per project or repository binding: buzz, buzz · relay-tools,
// design-system, and relay-tools all bind the shared mock channel.
await expect(channelRows).toHaveCount(4);
await expect(channelRows.first()).toContainText("#general");
const channelsList = page.getByTestId("projects-channels-list");
await expect(channelsList).toContainText("buzz");
await expect(channelsList).toContainText("relay-tools");
await expect(channelsList).toContainText("design-system");
await expect(page.getByTestId("project-channel-project")).toHaveCount(4);
await expect(page.getByTestId("project-channel-repository")).toHaveCount(4);
await page.getByTestId("projects-section-projects").click();
await expect(page.getByTestId("projects-page-header")).toContainText(
"Projects",
);
await expect(
page.getByTestId("projects-overview-context-panel"),
).toBeVisible();
await expect(page.getByTestId("projects-overview-context-title")).toHaveText(
"Projects",
);
await expect(
page.getByTestId("projects-overview-create-project"),
).toBeVisible();
await expect(page.getByTestId("projects-overview-people")).toBeVisible();
await expect(page.getByTestId("projects-overview-activity")).toBeVisible();
await page.getByTestId("projects-section-repositories").click();
await expect(page.getByTestId("projects-overview-context-title")).toHaveText(
"Repositories",
);
await expect(
page.getByTestId("projects-overview-create-project"),
).toHaveCount(0);
await expect(stats.nth(1)).toContainText("Active tasks");
await expect(page.getByTestId("projects-overview-activity")).toBeVisible();
await page.getByTestId("projects-section-issues").click();
await expect(page.getByTestId("projects-overview-context-title")).toHaveText(
"Tasks",
);
await expect(
page.getByTestId("projects-overview-create-issue"),
).toContainText("Create task");
await expect(page.getByTestId("projects-overview-activity")).toHaveCount(0);
await page.getByTestId("projects-section-prs").click();
await expect(page.getByTestId("projects-overview-context-title")).toHaveText(
"Reviews",
);
await expect(
page.getByTestId("projects-overview-create-pull-request"),
).toContainText("Create review");
await expect(page.getByTestId("projects-overview-people")).toBeVisible();
await expect(page.getByTestId("projects-overview-activity")).toHaveCount(0);
await page.getByTestId("projects-section-all").click();
await expect(
page.getByTestId("projects-overview-context-panel"),
).toBeVisible();
await expect(page.getByTestId("projects-overview-context-title")).toHaveText(
"Projects",
);
await expect(page.getByTestId("projects-overview-activity")).toBeVisible();
await page.getByTestId("projects-overview-create-project").click();
await expect(page.getByTestId("create-project-dialog")).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByTestId("create-project-dialog")).toBeHidden();
await page.getByTestId("projects-overview-context-toggle").click();
await expect(
page.getByTestId("projects-overview-context-panel"),
).not.toBeVisible();
await expect(
page.getByTestId("projects-overview-layout"),
).not.toHaveAttribute("data-project-context-detached", "true");
await expect(stats).toHaveCount(0);
await expect(activityCards.first()).toBeVisible();
await expect(page.getByTestId("projects-create-menu")).toBeVisible();
});
test("repository rows identify their git host", async ({ page }) => {
@@ -1081,7 +1305,6 @@ test("project subsections do not paint backgrounds behind list or grid items", a
"background-color",
"rgba(0, 0, 0, 0)",
);
await expect(listItems.nth(index)).toHaveCSS("border-style", "solid");
}
await page.getByRole("button", { name: "Grid layout" }).click();
@@ -1595,3 +1818,68 @@ test("project task can be created with a category from the tasks header", async
]);
expect(createdEvent?.tags).toContainEqual(["t", "change-request"]);
});
test("narrow layouts keep section context reachable through a sheet", async ({
page,
}) => {
await page.setViewportSize({ height: 720, width: 900 });
await enableProjectsFeature(page);
await installMockBridge(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByTestId("open-projects-view").click();
// Below the detached breakpoint the docked rail never renders, but the
// context toggle stays available.
await expect(page.getByTestId("projects-page-tabs")).toBeVisible();
await expect(page.getByTestId("projects-overview-context-panel")).toHaveCount(
0,
);
const contextToggle = page.getByTestId("projects-overview-context-toggle");
await expect(contextToggle).toBeVisible();
await expect(contextToggle).toHaveAttribute("aria-pressed", "false");
// Keyboard journey into the Tasks section: open the sheet from the toggle.
await page.getByTestId("projects-section-issues").click();
await expect(page.getByTestId("projects-page-header")).toContainText("Tasks");
await contextToggle.focus();
await page.keyboard.press("Enter");
const contextSheet = page.getByTestId("projects-overview-context-sheet");
await expect(contextSheet).toBeVisible();
await expect(
contextSheet.getByTestId("projects-overview-context-title"),
).toHaveText("Tasks");
await expect(contextToggle).toHaveAttribute("aria-pressed", "true");
// Escape dismisses the sheet and returns focus to the toggle.
await page.keyboard.press("Escape");
await expect(contextSheet).toBeHidden();
await expect(contextToggle).toBeFocused();
await expect(contextToggle).toHaveAttribute("aria-pressed", "false");
// The same journey follows the Reviews section.
await page.getByTestId("projects-section-prs").click();
await expect(page.getByTestId("projects-page-header")).toContainText(
"Reviews",
);
await contextToggle.focus();
await page.keyboard.press("Enter");
await expect(contextSheet).toBeVisible();
await expect(
contextSheet.getByTestId("projects-overview-context-title"),
).toHaveText("Reviews");
await page.keyboard.press("Escape");
await expect(contextSheet).toBeHidden();
// Selecting a section inside the sheet navigates and dismisses it: the
// Activity context lists every section as a stat row.
await page.getByTestId("projects-section-all").click();
await contextToggle.focus();
await page.keyboard.press("Enter");
await expect(contextSheet).toBeVisible();
await contextSheet
.getByTestId("projects-overview-stat")
.filter({ hasText: "Tasks" })
.click();
await expect(contextSheet).toBeHidden();
await expect(page.getByTestId("projects-page-header")).toContainText("Tasks");
});

View File

@@ -18,6 +18,32 @@ async function openBuzzProject(page: import("@playwright/test").Page) {
await projectEntry.click();
}
test("projects activity overview screenshot", async ({ page }) => {
await page.addInitScript(() => {
window.localStorage.setItem("buzz-theme", "light");
});
await installMockBridge(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByTestId("open-projects-view").click();
await expect(page.getByTestId("projects-page-tabs")).toBeVisible();
await expect(page.getByTestId("projects-page-header")).toBeVisible();
await expect(page.getByTestId("projects-activity-search")).toBeVisible();
await expect(page.getByTestId("projects-activity-intro")).toContainText(
"Welcome to Activity",
);
await expect(
page.getByTestId("projects-overview-context-panel"),
).toBeVisible();
await expect(
page.getByTestId("projects-overview-create-project"),
).toBeVisible();
await expect(
page.getByTestId("projects-activity-group").first(),
).toBeVisible();
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/00-projects-pulse.png` });
});
test("sidebar project add flow browses before creating", async ({ page }) => {
await installMockBridge(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
@@ -473,20 +499,24 @@ test("projects v3 workspace screenshot states", async ({ page }) => {
"Hide project context",
);
await expect(terminalIcon).toBeVisible();
// The icon is an inline SVG drawn with currentColor, so it must inherit
// the toggle button's text color to stay tinted with button state.
expect(
await terminalIcon.evaluate((element) => {
const style = getComputedStyle(element);
return {
backgroundColor: style.backgroundColor,
maskImage: style.maskImage || style.webkitMaskImage,
parentColor: getComputedStyle(element.parentElement ?? element).color,
};
}),
await terminalIcon.evaluate((element) => ({
color: getComputedStyle(element).color,
strokesCurrentColor: Array.from(element.querySelectorAll("rect")).some(
(rect) =>
rect.getAttribute("stroke") === "currentColor" ||
rect.getAttribute("fill") === "currentColor",
),
tagName: element.tagName.toLowerCase(),
})),
).toMatchObject({
backgroundColor: await terminalButton.evaluate(
color: await terminalButton.evaluate(
(element) => getComputedStyle(element).color,
),
maskImage: expect.not.stringMatching(/^none$/),
strokesCurrentColor: true,
tagName: "svg",
});
const [repositoryTabBounds, chatTabBounds] = await Promise.all([
repositoryPanelTab.boundingBox(),
@@ -511,6 +541,9 @@ test("projects v3 workspace screenshot states", async ({ page }) => {
await expect(agentContext).toBeVisible();
await expect(agentContext).toContainText("Overview");
await expect(agentContext).not.toContainText("Buzz /");
// The context rail reveals the chat panel with a width transition; measure
// only after it settles or the panel's unclipped box overhangs the rail.
await waitForAnimations(page);
const [
attachedSharedHeaderBackdropBounds,
tabMenuHeaderBounds,
@@ -1012,25 +1045,33 @@ test("projects v3 work-item list metadata", async ({ page }) => {
await page.getByTestId("open-projects-view").click();
await page.getByTestId("projects-section-prs").click();
const reviewTable = page.getByTestId("projects-list-container");
await expect(reviewTable.locator("thead")).toHaveClass(/sr-only/);
const reviewList = page.getByTestId("projects-list-container");
await expect(reviewList).toBeVisible();
const pullRequestRow = page.getByTestId(/^projects-pr-row-/).first();
await expect(pullRequestRow).toBeVisible();
await expect(pullRequestRow).toContainText("Review");
await expect(pullRequestRow).toContainText("opened this in");
await expect(pullRequestRow).toContainText(/from\s*feature\/mock-2-0/);
await expect(pullRequestRow).not.toContainText(/#[0-9a-f]{8}/);
await expect(pullRequestRow).toContainText(/relay-tools|buzz|design-system/);
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/05-pr-list-metadata.png` });
await page.getByTestId("projects-section-issues").click();
const taskTable = page.getByTestId("projects-list-container");
await expect(taskTable.locator("thead")).toHaveClass(/sr-only/);
const taskList = page.getByTestId("projects-list-container");
await expect(taskList).toBeVisible();
const issueRow = page.getByTestId(/^projects-issue-row-/).first();
await expect(issueRow).toBeVisible();
await expect(issueRow).toContainText("Issue");
await expect(issueRow).toContainText(/opened this in\s*relay-tools/);
await expect(issueRow).not.toContainText(/#[0-9a-f]{8}/);
await expect(issueRow).toContainText(/relay-tools|buzz|design-system/);
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/06-issue-list-metadata.png` });
await page.getByTestId("projects-section-channels").click();
const channelRow = page.getByTestId("project-channel-row").first();
await expect(channelRow).toBeVisible();
await expect(channelRow).toContainText("#general");
await expect(
page.getByTestId("project-channel-project").first(),
).toBeVisible();
await expect(
page.getByTestId("project-channel-repository").first(),
).toBeVisible();
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/07-channels-list.png` });
});