feat(projects): finish multi-repository workspace flows

Complete project-home defaults, category-specific creation, responsive grouping, and project-wide task and commit views across every repository.

Signed-off-by: Thomas Petersen <thomasp@squareup.com>
Co-authored-by: Thomas Petersen <thomasp@squareup.com>
This commit is contained in:
Thomas Petersen
2026-08-22 23:28:18 -04:00
committed by Wrench
parent f0192ea976
commit d5c6df5b81
32 changed files with 1577 additions and 499 deletions

View File

@@ -33,6 +33,7 @@ export type CreateProjectInput = {
channelVisibility?: ChannelVisibility;
projectVisibility?: ProjectListingVisibility;
agents?: readonly CreateChannelManagedAgentInput[];
templateId?: string;
};
export type CreateProjectResult = {

View File

@@ -0,0 +1,43 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
PROJECT_HOME_CHANNEL_TEMPLATE,
PROJECT_HOME_TEMPLATE_ID,
renderProjectHomeCanvas,
} from "./projectHomeTemplate.ts";
test("project home is the built-in default project template", () => {
assert.equal(PROJECT_HOME_CHANNEL_TEMPLATE.id, PROJECT_HOME_TEMPLATE_ID);
assert.equal(PROJECT_HOME_CHANNEL_TEMPLATE.isBuiltin, true);
assert.equal(PROJECT_HOME_CHANNEL_TEMPLATE.name, "Project home");
});
test("project home canvas fills project, repository, and channel values", () => {
const content = renderProjectHomeCanvas({
channelId: "11111111-1111-4111-8111-111111111111",
project: {
id: "30621:owner:space-invaders",
dtag: "space-invaders",
name: "Space Invaders",
owner: "a".repeat(64),
repositories: [
{
cloneUrls: ["https://relay.example/git/owner/space-invaders"],
dtag: "space-invaders",
owner: "b".repeat(64),
},
],
},
});
assert.match(content, /# Project Channel: Space Invaders/);
assert.match(content, /`space-invaders`/);
assert.match(content, /b{64}/);
assert.match(content, /https:\/\/relay\.example\/git\/owner\/space-invaders/);
assert.match(content, /11111111-1111-4111-8111-111111111111/);
assert.equal(content.includes("{{"), false);
assert.match(content, /buzz issues status --issue <id>/);
assert.match(content, /buzz pr open --repo-owner/);
assert.match(content, /buzz canvas set .* --content -/);
});

View File

@@ -0,0 +1,101 @@
import { setCanvas } from "@/shared/api/tauri";
import type { ChannelTemplate } from "@/shared/api/types";
import type { Project } from "@/features/projects/hooks";
export const PROJECT_HOME_TEMPLATE_ID = "builtin:project-home";
export const PROJECT_HOME_CANVAS_TEMPLATE = `# Project Channel: {{PROJECT_NAME}}
This channel is the working home of **{{PROJECT_NAME}}**.
- Initial repository: \`{{REPO_SLUG}}\`
- Repository owner: \`{{REPO_OWNER_HEX}}\`
- Clone URL: \`{{REPO_CLONE_URL}}\`
- Project channel: \`{{CHANNEL_UUID}}\`
Everything about this project—decisions, tasks, code review, and releases—happens here, in the open.
## How to think about this channel
- **The channel is the project's memory.** If you did it and did not post it, it did not happen. Milestones (picked up, blocked, PR up, merged, done) are top-level posts; details go in threads.
- **Issues are the task queue.** Work starts from an issue. No issue? Create one before you build.
- **The repository is the source of truth for code; the channel is the source of truth for intent.** Read both before acting.
- **One owner per task.** Claim before you build. If it is assigned to someone else, review or unblock—do not duplicate.
## What you can do here
| Action | Command |
| --- | --- |
| Inspect the repository | \`buzz repos get --owner {{REPO_OWNER_HEX}} --id {{REPO_SLUG}}\` |
| Create a task | \`buzz issues create --channel {{CHANNEL_UUID}} --title "..." --content -\` |
| Claim or assign a task | \`buzz issues assign --issue <id> --repo-owner {{REPO_OWNER_HEX}} --repo-id {{REPO_SLUG}} --assignee <hex>\` |
| Track task state | \`buzz issues status --issue <id> --repo-owner {{REPO_OWNER_HEX}} --repo-id {{REPO_SLUG}} --status open|resolved|closed|draft\` |
| Open a review | \`buzz pr open --repo-owner {{REPO_OWNER_HEX}} --repo-id {{REPO_SLUG}} --subject "..." --body-file - --commit <tip> --clone {{REPO_CLONE_URL}} --branch-name <branch> --channel {{CHANNEL_UUID}}\` |
| Update a review | \`buzz pr update --repo-owner {{REPO_OWNER_HEX}} --repo-id {{REPO_SLUG}} --pr <id> --pr-author <hex> --commit <tip> --clone {{REPO_CLONE_URL}}\` |
| Mark a review merged or closed | \`buzz pr status --pr <id> --repo-owner {{REPO_OWNER_HEX}} --repo-id {{REPO_SLUG}} --status merged|closed\` |
| Share files or artifacts | \`buzz upload file --file <path>\` |
| Update this living document | \`buzz canvas set --channel {{CHANNEL_UUID}} --content -\` |
## Workflow
1. **Pick up:** Find or create an issue, self-assign it, and post a one-line “picked up” message in the channel.
2. **Build:** Clone or reuse a checkout under \`REPOS/\`. Work on a branch, never the default branch. Follow the repository's configured commit and sign-off policy.
3. **Verify:** Run the fullest relevant test suite before calling anything done.
4. **Ship:** Open a review and post the returned Buzz link verbatim so it renders as a card. Mark the issue resolved when merged.
5. **Report:** @mention whoever delegated the work in the message that delivers the result or blocker—not in acknowledgements.
## Norms
- Reply in-thread to continue a topic; use a top-level post for a new topic. Avoid bare acknowledgements.
- @mention only when someone must act; naming someone in narrative does not require an @mention.
- Blocked for more than 30 minutes after honest effort? Post the blocker and what you tried.
- Praise in public; correct the work, not the person.
- Give decisions of record—scope cuts, API choices, and deferrals—their own top-level post so they remain findable.
Keep this canvas current as the project evolves.`;
export const PROJECT_HOME_CHANNEL_TEMPLATE: ChannelTemplate = {
id: PROJECT_HOME_TEMPLATE_ID,
name: "Project home",
description: null,
channelType: "stream",
visibility: "open",
canvasTemplate: PROJECT_HOME_CANVAS_TEMPLATE,
agents: { personas: [], teams: [] },
isBuiltin: true,
createdAt: "",
updatedAt: "",
};
export function renderProjectHomeCanvas(input: {
channelId: string;
project: Project;
}) {
const repository = input.project.repositories[0];
const values: Record<string, string> = {
CHANNEL_UUID: input.channelId,
PROJECT_NAME: input.project.name,
REPO_CLONE_URL: repository?.cloneUrls[0] ?? "Unavailable",
REPO_OWNER_HEX: repository?.owner ?? input.project.owner,
REPO_SLUG: repository?.dtag ?? input.project.dtag,
};
return Object.entries(values).reduce(
(content, [key, value]) => content.replaceAll(`{{${key}}}`, value),
PROJECT_HOME_CANVAS_TEMPLATE,
);
}
export async function applyProjectHomeCanvas(input: {
channelId: string;
project: Project;
}) {
try {
await setCanvas({
channelId: input.channelId,
content: renderProjectHomeCanvas(input),
});
return true;
} catch {
return false;
}
}

View File

@@ -1,7 +1,10 @@
import assert from "node:assert/strict";
import test from "node:test";
import { fetchProjectsWorkItems } from "./projectWorkItems.ts";
import {
fetchProjectsWorkItems,
projectsWithWorkItemRepositories,
} from "./projectWorkItems.ts";
// ── Work-item deduplication ─────────────────────────────────────────────────
//
@@ -27,8 +30,31 @@ const projectB = {
repositories: [{ repoAddress: REPO_ADDRESS }],
};
test("work-item scope keeps explicit and repository-only read models", () => {
const explicitProject = {
id: "explicit",
legacy: false,
repositories: [{ repoAddress: REPO_ADDRESS }],
};
const repositoryOnlyProject = {
id: "repository-only",
legacy: true,
repositories: [{ repoAddress: `30617:${REPO_OWNER}:standalone` }],
};
const emptyProject = { id: "empty", legacy: false, repositories: [] };
assert.deepEqual(
projectsWithWorkItemRepositories([
explicitProject,
repositoryOnlyProject,
emptyProject,
]).map((project) => project.id),
["explicit", "repository-only"],
);
});
// Minimal valid NIP-34 issue event for the shared repo.
function makeIssue(id, updatedAt = 100) {
function makeIssue(id, updatedAt = 100, repoAddress = REPO_ADDRESS) {
return {
id,
kind: 1621,
@@ -36,12 +62,34 @@ function makeIssue(id, updatedAt = 100) {
created_at: updatedAt,
content: "An issue",
tags: [
["a", REPO_ADDRESS],
["a", repoAddress],
["subject", "Fix the thing"],
],
};
}
test("fetchProjectsWorkItems accumulates issues from every project repository", async () => {
const secondAddress = `30617:${REPO_OWNER}:desktop`;
const project = {
repositories: [
{ repoAddress: REPO_ADDRESS },
{ repoAddress: secondAddress },
],
};
const result = await fetchProjectsWorkItems(
[project],
makeFetchEvents([
makeIssue(ISSUE_ID, 100, REPO_ADDRESS),
makeIssue("j".repeat(64), 90, secondAddress),
]),
);
assert.deepEqual(
result.issues.items.map(({ repository }) => repository.repoAddress).sort(),
[REPO_ADDRESS, secondAddress].sort(),
);
});
// Minimal valid NIP-34 pull request event for the shared repo.
function makePR(id, updatedAt = 100) {
return {

View File

@@ -62,6 +62,13 @@ export type ProjectsWorkItemsResult<TProject extends ProjectReference> = {
};
};
/** Includes every repository-bearing read model, including repository-only ones. */
export function projectsWithWorkItemRepositories<
TProject extends ProjectReference,
>(projects: readonly TProject[]): TProject[] {
return projects.filter((project) => project.repositories.length > 0);
}
function groupByRepoAddress(events: RelayEvent[]): Map<string, RelayEvent[]> {
const grouped = new Map<string, RelayEvent[]>();
for (const event of events) {

View File

@@ -22,6 +22,7 @@ export function AddProjectRepositoryDialog({
onOpenChange,
open,
project,
projects,
}: {
accessChannelId?: string;
channels: Channel[];
@@ -29,37 +30,51 @@ export function AddProjectRepositoryDialog({
onAdd: (input: AddProjectRepositoryInput) => Promise<void>;
onOpenChange: (open: boolean) => void;
open: boolean;
project: Project;
project?: Project;
projects?: Project[];
}) {
const projectOptions = React.useMemo(
() => projects ?? (project ? [project] : []),
[project, projects],
);
const [selectedProjectId, setSelectedProjectId] = React.useState(
project?.id ?? projectOptions[0]?.id ?? "",
);
const selectedProject =
projectOptions.find((candidate) => candidate.id === selectedProjectId) ??
projectOptions[0];
const [name, setName] = React.useState("");
const [cloneUrl, setCloneUrl] = React.useState("");
const [selectedChannelId, setSelectedChannelId] = React.useState("");
const [errorMessage, setErrorMessage] = React.useState<string | null>(null);
const nameInputRef = React.useRef<HTMLInputElement>(null);
const projectSelectRef = React.useRef<HTMLSelectElement>(null);
React.useEffect(() => {
if (!open) return;
setName("");
setCloneUrl("");
setSelectedProjectId(project?.id ?? projectOptions[0]?.id ?? "");
setSelectedChannelId(accessChannelId ?? "");
setErrorMessage(null);
const timerId = globalThis.setTimeout(
() => nameInputRef.current?.focus(),
() =>
(projects ? projectSelectRef.current : nameInputRef.current)?.focus(),
50,
);
return () => globalThis.clearTimeout(timerId);
}, [accessChannelId, open]);
}, [accessChannelId, open, project?.id, projectOptions, projects]);
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!name.trim() || !selectedChannelId) return;
if (!name.trim() || !selectedChannelId || !selectedProject) return;
setErrorMessage(null);
try {
await onAdd({
accessChannelId: selectedChannelId,
cloneUrl: cloneUrl.trim() || undefined,
name: name.trim(),
project,
project: selectedProject,
});
onOpenChange(false);
} catch (error) {
@@ -81,11 +96,20 @@ export function AddProjectRepositoryDialog({
className="max-w-lg"
contentClassName="pt-3"
data-testid="add-project-repository-dialog"
description={`Add another repository to ${project.name}.`}
description={
selectedProject
? `Add another repository to ${selectedProject.name}.`
: "Choose a project for this repository."
}
footer={
<Button
data-testid="add-project-repository-submit"
disabled={isCreating || !name.trim() || !selectedChannelId}
disabled={
isCreating ||
!name.trim() ||
!selectedChannelId ||
!selectedProject
}
form="add-project-repository-form"
type="submit"
>
@@ -101,6 +125,37 @@ export function AddProjectRepositoryDialog({
id="add-project-repository-form"
onSubmit={(event) => void handleSubmit(event)}
>
{projects ? (
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="add-project-repository-project"
>
Project
</label>
<div className={FIELD_SHELL_CLASS}>
<select
className={cn(FIELD_CONTROL_CLASS, "w-full")}
data-testid="add-project-repository-project"
disabled={isCreating}
id="add-project-repository-project"
onChange={(event) => {
setSelectedProjectId(event.target.value);
setErrorMessage(null);
}}
ref={projectSelectRef}
required
value={selectedProject?.id ?? ""}
>
{projectOptions.map((candidate) => (
<option key={candidate.id} value={candidate.id}>
{candidate.name}
</option>
))}
</select>
</div>
</div>
) : null}
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"

View File

@@ -36,7 +36,7 @@ export function CreateProjectFormContent({
const [description, setDescription] = React.useState("");
const [errorMessage, setErrorMessage] = React.useState<string | null>(null);
const nameInputRef = React.useRef<HTMLInputElement>(null);
const settings = useCreateProjectFormSettings(active);
const settings = useCreateProjectFormSettings(active, setDescription);
React.useEffect(() => {
if (!active) return;
@@ -63,6 +63,7 @@ export function CreateProjectFormContent({
channelVisibility: settings.channelVisibility,
projectVisibility: settings.projectVisibility,
agents: settings.buildAgents(),
templateId: settings.templateId,
});
onCreated();
} catch (error) {

View File

@@ -1,18 +1,24 @@
import { ChevronDown } from "lucide-react";
import { ChevronDown, Plus } from "lucide-react";
import * as React from "react";
import { ChannelPermissionsSettings } from "@/features/channels/ui/ChannelPermissionsSettings";
import type { CreateProjectFormSettingsState } from "@/features/projects/ui/useCreateProjectFormSettings";
import { TemplateFormDialog } from "@/features/settings/ui/ChannelTemplatesSettingsCard";
import { Button } from "@/shared/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import { cn } from "@/shared/lib/cn";
const NONE_AGENT_VALUE = "__none__";
const NONE_TEAM_VALUE = "__no-team__";
const NO_TEMPLATE_VALUE = "__no-template__";
const SETTINGS_ROW_CLASS =
"flex min-h-12 items-center justify-between gap-4 rounded-xl border border-input bg-background px-3 py-3";
@@ -20,20 +26,33 @@ const SETTINGS_ROW_CLASS =
export function CreateProjectFormSettings({
agentPersonaId,
disabled,
handleTemplateChange,
handleTemplateCreated,
personas,
projectVisibility,
runtimesAvailable,
setAgentPersonaId,
setChannelVisibility,
setProjectVisibility,
setTeamId,
teamId,
teams,
templateId,
templates,
channelVisibility,
}: CreateProjectFormSettingsState & { disabled: boolean }) {
const [isCreateTemplateOpen, setIsCreateTemplateOpen] = React.useState(false);
const selectedPersona = personas.find(
(persona) => persona.id === agentPersonaId,
);
const selectedTeam = teams.find((team) => team.id === teamId);
const selectedTemplate = templates.find(
(template) => template.id === templateId,
);
const listingLabel = projectVisibility === "unlisted" ? "Unlisted" : "Listed";
const agentLabel = selectedPersona?.displayName ?? "None";
const agentDisabled = disabled || (!runtimesAvailable && personas.length > 0);
const teamDisabled = disabled || (!runtimesAvailable && teams.length > 0);
return (
<>
@@ -44,6 +63,103 @@ export function CreateProjectFormSettings({
visibility={channelVisibility}
/>
<div className={cn(SETTINGS_ROW_CLASS, disabled && "opacity-50")}>
<span className="text-sm font-medium text-foreground">
Template
<span className="ml-1 text-xs font-normal text-muted-foreground/50">
Project home by default
</span>
</span>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
aria-label={`Template: ${selectedTemplate?.name ?? "None"}`}
className="-mr-2.5 ml-auto h-9 min-w-0 max-w-[60%] justify-end px-2.5 text-right text-sm font-medium text-foreground hover:bg-muted/50"
data-testid="create-project-template"
disabled={disabled}
type="button"
variant="ghost"
>
<span className="truncate text-right">
{selectedTemplate?.name ?? "None"}
</span>
<ChevronDown className="size-4 shrink-0 text-muted-foreground/70" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuRadioGroup
onValueChange={(value) =>
handleTemplateChange(value === NO_TEMPLATE_VALUE ? "" : value)
}
value={templateId || NO_TEMPLATE_VALUE}
>
<DropdownMenuRadioItem value={NO_TEMPLATE_VALUE}>
None
</DropdownMenuRadioItem>
{templates.map((template) => (
<DropdownMenuRadioItem key={template.id} value={template.id}>
{template.name}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => setIsCreateTemplateOpen(true)}>
<Plus className="size-4" />
Create new channel template…
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<TemplateFormDialog
onCreated={handleTemplateCreated}
onOpenChange={setIsCreateTemplateOpen}
open={isCreateTemplateOpen}
template={null}
/>
</div>
<div className={cn(SETTINGS_ROW_CLASS, teamDisabled && "opacity-50")}>
<span className="text-sm font-medium text-foreground">
Team
<span className="ml-1 text-xs font-normal text-muted-foreground/50">
Optional
</span>
</span>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
aria-label={`Team: ${selectedTeam?.name ?? "None"}`}
className="-mr-2.5 ml-auto h-9 min-w-0 max-w-[60%] justify-end px-2.5 text-right text-sm font-medium text-foreground hover:bg-muted/50"
data-testid="create-project-team"
disabled={teamDisabled}
type="button"
variant="ghost"
>
<span className="truncate text-right">
{selectedTeam?.name ?? "None"}
</span>
<ChevronDown className="size-4 shrink-0 text-muted-foreground/70" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuRadioGroup
onValueChange={(value) =>
setTeamId(value === NONE_TEAM_VALUE ? "" : value)
}
value={teamId || NONE_TEAM_VALUE}
>
<DropdownMenuRadioItem value={NONE_TEAM_VALUE}>
None
</DropdownMenuRadioItem>
{teams.map((team) => (
<DropdownMenuRadioItem key={team.id} value={team.id}>
{team.name}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className={cn(SETTINGS_ROW_CLASS, disabled && "opacity-50")}>
<span className="text-sm font-medium text-foreground">
Project list

View File

@@ -203,8 +203,8 @@ export function ProjectChannelHome({
const handleExpandWorkspace = React.useCallback(() => {
if (!workspaceRepository || !workspaceSheetTab) return;
void goProject(project.id, {
...workspaceDetail?.navigation,
repositoryId: workspaceRepository.id,
...workspaceDetail?.navigation,
tab: projectHomeWorkspaceSheetExpandTab(workspaceSheetTab),
});
}, [
@@ -402,7 +402,6 @@ export function ProjectChannelHome({
onResetWidth={summaryWidth.onResetWidth}
onResizeStart={summaryWidth.onResizeStart}
testId="project-home-summary-column"
title="Overview"
widthPx={summaryWidth.widthPx}
>
<ProjectHomeContextPanel

View File

@@ -24,6 +24,7 @@ import {
} from "@/features/profile/lib/identity";
import {
CircleDot,
FolderGit2,
GitBranch,
GitCommitHorizontal,
GitPullRequest,
@@ -236,6 +237,7 @@ export function ContributorsPanel({
export function ActivityPanel({
branch,
commitItems,
snapshot,
isLoading,
error,
@@ -248,10 +250,18 @@ export function ActivityPanel({
viewerGitIdentity,
}: {
branch?: string;
commitItems?: Array<{
branch?: string;
commit: ProjectRepoCommit;
project: Repository;
projectId: string;
pullRequests?: ProjectPullRequest[];
repoContributors?: ProjectRepoContributor[];
}>;
snapshot: ProjectRepoSnapshot | null | undefined;
isLoading: boolean;
error: unknown;
onSelectCommit?: (commit: ProjectRepoCommit) => void;
onSelectCommit?: (commit: ProjectRepoCommit, project: Repository) => void;
profiles?: UserProfileLookup;
project: Repository;
projectId: string;
@@ -259,21 +269,33 @@ export function ActivityPanel({
repoContributors: ProjectRepoContributor[];
viewerGitIdentity?: ViewerGitIdentity | null;
}) {
const commits = snapshot?.commits ?? [];
const commitAuthorPubkeys = commitAuthorPubkeysFromPullRequests(
pullRequests ?? [],
);
const rangeItems = commits.map((commit) => {
const matchedProfile = profileForCommit(
const items =
commitItems ??
(snapshot?.commits ?? []).map((commit) => ({
branch,
commit,
project,
projectId,
pullRequests,
repoContributors,
}));
const showRepositoryName =
commitItems !== undefined &&
new Set(items.map((item) => item.project.repoAddress)).size > 1;
const rangeItems = items.map((item) => {
const commitAuthorPubkeys = commitAuthorPubkeysFromPullRequests(
item.pullRequests ?? [],
);
const matchedProfile = profileForCommit(
item.commit,
profiles,
commitAuthorPubkeys,
viewerGitIdentity,
);
return commitSelectionItem(
commit,
project,
projectId,
item.commit,
item.project,
item.projectId,
matchedProfile?.pubkey,
);
});
@@ -282,13 +304,15 @@ export function ActivityPanel({
return <BuzzLoadingState label="Loading activity" />;
}
if (commits.length === 0) {
if (items.length === 0) {
return (
<ProjectPanelState
description={
error
? "Refresh the repository and try again."
: "Commits pushed to this repository will appear here."
: commitItems
? "Commits pushed to this project's repositories will appear here."
: "Commits pushed to this repository will appear here."
}
error={Boolean(error)}
title={error ? "Could not load commits" : "No commits yet"}
@@ -299,9 +323,12 @@ export function ActivityPanel({
return (
<section className={PROJECT_DETAIL_PANEL_CLASS} data-project-detail-panel>
<div className="space-y-0.5 px-2">
{commits.map((commit) => {
{items.map((item) => {
const commitAuthorPubkeys = commitAuthorPubkeysFromPullRequests(
item.pullRequests ?? [],
);
const matchedProfile = profileForCommit(
commit,
item.commit,
profiles,
commitAuthorPubkeys,
viewerGitIdentity,
@@ -311,36 +338,51 @@ export function ActivityPanel({
pubkey: matchedProfile.pubkey,
profiles,
})
: commit.authorName || commit.authorEmail || "Unknown author";
const matchingContributor = repoContributors.find(
: item.commit.authorName ||
item.commit.authorEmail ||
"Unknown author";
const matchingContributor = (item.repoContributors ?? []).find(
(contributor) =>
contributor.name.trim().toLowerCase() ===
commit.authorName.trim().toLowerCase() ||
item.commit.authorName.trim().toLowerCase() ||
contributor.email.trim().toLowerCase() ===
commit.authorEmail.trim().toLowerCase(),
item.commit.authorEmail.trim().toLowerCase(),
);
return (
<ProjectWorkItemRow
eventId={commit.hash}
identifier={commit.shortHash}
eventId={item.commit.hash}
identifier={item.commit.shortHash}
identifierClassName="font-mono"
identifierTitle={`View commit ${commit.shortHash}`}
key={commit.hash}
identifierTitle={`View commit ${item.commit.shortHash}`}
key={`${item.project.repoAddress}:${item.commit.hash}`}
metadata={
branch ? (
showRepositoryName || item.branch ? (
<span className="inline-flex min-w-0 items-center gap-1">
<GitBranch className="h-3 w-3 shrink-0" />
<span className="truncate">{branch}</span>
{showRepositoryName ? (
<>
<FolderGit2 className="h-3 w-3 shrink-0" />
<span className="truncate">{item.project.name}</span>
</>
) : (
<>
<GitBranch className="h-3 w-3 shrink-0" />
<span className="truncate">{item.branch}</span>
</>
)}
</span>
) : undefined
}
onOpen={onSelectCommit ? () => onSelectCommit(commit) : undefined}
onOpen={
onSelectCommit
? () => onSelectCommit(item.commit, item.project)
: undefined
}
selection={{
item: commitSelectionItem(
commit,
project,
projectId,
item.commit,
item.project,
item.projectId,
matchedProfile?.pubkey,
),
rangeItems,
@@ -349,7 +391,7 @@ export function ActivityPanel({
<GitCommitHorizontal className="h-3.5 w-3.5 text-muted-foreground/70" />
}
testId="project-activity-feed-item"
title={commit.subject}
title={item.commit.subject}
trailing={
<>
<span
@@ -376,14 +418,16 @@ export function ActivityPanel({
</span>
<CopyCommitHashButton
className="h-5 w-5 shrink-0 text-muted-foreground/60"
hash={commit.hash}
hash={item.commit.hash}
/>
<span
className="hidden w-20 shrink-0 whitespace-nowrap text-right text-xs text-muted-foreground/55 sm:block"
data-testid="project-commit-row-date"
title={new Date(commit.timestamp * 1_000).toLocaleString()}
title={new Date(
item.commit.timestamp * 1_000,
).toLocaleString()}
>
{relativeTime(commit.timestamp)}
{relativeTime(item.commit.timestamp)}
</span>
</>
}

View File

@@ -1,12 +1,7 @@
import type * as React from "react";
import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane";
import {
AuxiliaryPanelBody,
AuxiliaryPanelHeader,
AuxiliaryPanelHeaderGroup,
AuxiliaryPanelHeaderTitleBlock,
} from "@/shared/layout/AuxiliaryPanel";
import { AuxiliaryPanelBody } from "@/shared/layout/AuxiliaryPanel";
import { cn } from "@/shared/lib/cn";
export function ProjectHomeColumn({
@@ -16,7 +11,6 @@ export function ProjectHomeColumn({
onResetWidth,
onResizeStart,
testId,
title,
widthPx,
}: {
bodyClassName?: string;
@@ -25,7 +19,6 @@ export function ProjectHomeColumn({
onResetWidth: () => void;
onResizeStart: (event: React.PointerEvent<HTMLButtonElement>) => void;
testId: string;
title: string;
widthPx: number;
}) {
return (
@@ -40,13 +33,6 @@ export function ProjectHomeColumn({
widthPx={widthPx}
>
<div className="relative z-30 flex h-full min-h-0 flex-1 flex-col overflow-hidden bg-sidebar">
<AuxiliaryPanelHeader mode="panel" transparent>
<AuxiliaryPanelHeaderGroup>
<div className="-ml-1 min-w-0 flex-1">
<AuxiliaryPanelHeaderTitleBlock title={title} />
</div>
</AuxiliaryPanelHeaderGroup>
</AuxiliaryPanelHeader>
<AuxiliaryPanelBody
className={cn("min-h-0 flex-1 overflow-hidden", bodyClassName)}
mode="panel"

View File

@@ -0,0 +1,89 @@
import type { ProjectPullRequest, Repository } from "@/features/projects/hooks";
import {
projectRepoUnavailablePresentation,
projectRepoUnavailableReason,
} from "@/features/projects/lib/projectRepoAvailability";
import type { ViewerGitIdentity } from "@/features/projects/lib/projectContributorMatching";
import type { ProjectRepositorySnapshotResult } from "@/features/projects/useProjectRepositorySnapshots";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type { ProjectRepoCommit } from "@/shared/api/types";
import { BuzzLoadingState } from "@/shared/ui/BuzzLoadingState";
import { ProjectPanelState } from "./ProjectPanelState";
import { ActivityPanel } from "./ProjectDetailFeedPanels";
export function ProjectHomeCommitsPanel({
onSelectCommit,
profiles,
projectId,
pullRequests,
results,
viewerGitIdentity,
}: {
onSelectCommit: (commit: ProjectRepoCommit, repository: Repository) => void;
profiles?: UserProfileLookup;
projectId: string;
pullRequests: ProjectPullRequest[];
results: ProjectRepositorySnapshotResult[];
viewerGitIdentity?: ViewerGitIdentity | null;
}) {
const loaded = results.filter(
(result) => (result.snapshot?.commits.length ?? 0) > 0,
);
const commitItems = loaded
.flatMap(({ repository, snapshot }) =>
(snapshot?.commits ?? []).map((commit) => ({
branch: repository.defaultBranch,
commit,
project: repository,
projectId,
pullRequests,
repoContributors: snapshot?.contributors ?? [],
})),
)
.sort((left, right) => right.commit.timestamp - left.commit.timestamp);
const failed = results.filter((result) => result.error);
const firstFailure = failed[0];
const failure = firstFailure
? projectRepoUnavailablePresentation(
projectRepoUnavailableReason(firstFailure.error),
)
: null;
if (results.some((result) => result.isLoading) && loaded.length === 0) {
return <BuzzLoadingState label="Loading commits" />;
}
if (loaded.length === 0) {
return (
<ProjectPanelState
description={
failure
? `${firstFailure.repository.name}: ${failure.description}${
failed.length > 1
? ` ${failed.length - 1} other repositories also failed.`
: ""
}`
: "Commits pushed to this project's repositories will appear here."
}
error={failed.length > 0}
title={failure?.title ?? "No commits yet"}
/>
);
}
const firstItem = commitItems[0];
if (!firstItem) return null;
return (
<ActivityPanel
commitItems={commitItems}
error={null}
isLoading={false}
onSelectCommit={onSelectCommit}
profiles={profiles}
project={firstItem.project}
projectId={projectId}
pullRequests={pullRequests}
repoContributors={firstItem.repoContributors}
snapshot={null}
viewerGitIdentity={viewerGitIdentity}
/>
);
}

View File

@@ -1,4 +1,5 @@
import {
ChevronDown,
CircleDot,
FileCode2,
FolderGit2,
@@ -7,7 +8,7 @@ import {
Hash,
Users,
} from "lucide-react";
import type * as React from "react";
import * as React from "react";
import { presentContextCount } from "@/features/projects/lib/projectHomeSummary";
import type { ProjectHomeWorkspaceSheetTab } from "@/features/projects/lib/projectHomeWorkspaceSheet";
@@ -26,36 +27,60 @@ import type { EntityLinkTab } from "@/shared/lib/entityLink";
import { Button } from "@/shared/ui/button";
import { ProjectChannelManagement } from "./ProjectChannelManagement";
import { ProjectRepositoryManagement } from "./ProjectRepositoryManagement";
import { SECTION_ACTION_VISIBILITY_CLASS } from "@/features/sidebar/ui/sidebarSectionStyles";
const PROJECT_HOME_SIDEBAR_ROW_CLASS =
"h-8 w-full justify-start gap-2 rounded-md px-2 py-1.5 text-left text-sm font-normal text-sidebar-foreground/80 transition-[background-color,color] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50";
function ContextSection({
children,
collapsible = false,
headerAction,
testId,
title,
}: {
children: React.ReactNode;
collapsible?: boolean;
headerAction?: React.ReactNode;
testId?: string;
title?: string;
}) {
const [expanded, setExpanded] = React.useState(true);
return (
<section className="space-y-1" data-testid={testId}>
<section className="group/sidebar-section space-y-1" data-testid={testId}>
{title || headerAction ? (
<div className="flex h-8 min-w-0 items-center justify-between gap-2 px-2">
{title ? (
{title && collapsible ? (
<button
aria-expanded={expanded}
className="group/section-label flex min-w-0 items-center gap-1 text-left text-xs font-medium text-sidebar-foreground/70"
onClick={() => setExpanded((current) => !current)}
type="button"
>
<span className="truncate">{title}</span>
<ChevronDown
aria-hidden="true"
className={cn(
"size-3 shrink-0 opacity-0 transition-[opacity,transform] group-hover/sidebar-section:opacity-100 group-focus-within/sidebar-section:opacity-100",
expanded ? "rotate-0" : "-rotate-90",
)}
/>
</button>
) : title ? (
<h3 className="min-w-0 truncate text-xs font-medium text-sidebar-foreground/70">
{title}
</h3>
) : (
<span />
)}
{headerAction}
{headerAction ? (
<span className={SECTION_ACTION_VISIBILITY_CLASS}>
{headerAction}
</span>
) : null}
</div>
) : null}
{children}
{!collapsible || expanded ? children : null}
</section>
);
}
@@ -297,6 +322,7 @@ export function ProjectHomeContextPanel({
</ContextNavButton>
</ContextSection>
<ContextSection
collapsible
headerAction={
<ProjectChannelManagement
identityPubkey={identityPubkey}
@@ -336,6 +362,7 @@ export function ProjectHomeContextPanel({
)}
</ContextSection>
<ContextSection
collapsible
headerAction={
<ProjectRepositoryManagement
compact

View File

@@ -1,28 +1,25 @@
import * as React from "react";
import { toast } from "sonner";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { useCommunities } from "@/features/communities/useCommunities";
import {
useProjectIssuesQuery,
useProjectPullRequestsQuery,
useProjectRepoSnapshotQuery,
useProjectsWorkItemsQuery,
useRepoStateQuery,
type Project,
} from "@/features/projects/hooks";
import { useCreateProjectIssueMutation } from "@/features/projects/issueMutations";
import { gitContributorPubkeysFromCommits } from "@/features/projects/lib/projectContributorMatching";
import { resolveProjectDefaultBranch } from "@/features/projects/lib/projectBranches";
import type { ProjectHomeWorkspaceSheetTab } from "@/features/projects/lib/projectHomeWorkspaceSheet";
import { useProjectCommitDiffQuery } from "@/features/projects/useProjectCommitDiff";
import {
CreateIssueDialog,
type CreateIssueDialogInput,
} from "./CreateIssueDialog";
import { useProjectRepositorySnapshots } from "@/features/projects/useProjectRepositorySnapshots";
import { CreateProjectIssueDialog } from "./CreateProjectIssueDialog";
import { CreatePullRequestDialog } from "./CreatePullRequestDialog";
import { ProjectCommitDetailPanel } from "./ProjectCommitDetailPanel";
import { ActivityPanel, ContributorsPanel } from "./ProjectDetailFeedPanels";
import { ContributorsPanel } from "./ProjectDetailFeedPanels";
import { ProjectHomeCodebasePanel } from "./ProjectHomeCodebasePanel";
import { ProjectHomeCommitsPanel } from "./ProjectHomeCommitsPanel";
import { ProjectIssuesPanel } from "./ProjectIssuesPanel";
import { PullRequestsPanel } from "./ProjectPullRequestsPanel";
import { PROJECT_DETAIL_PANEL_CLASS } from "./projectPanelStyles";
@@ -42,6 +39,7 @@ export type ProjectHomeWorkspaceDetail = {
filePath?: string;
issueId?: string;
pullRequestId?: string;
repositoryId?: string;
};
onBack: () => void;
};
@@ -82,6 +80,8 @@ export function ProjectHomeWorkspaceSheet({
const [selectedCommitHash, setSelectedCommitHash] = React.useState<
string | null
>(null);
const [selectedCommitRepositoryId, setSelectedCommitRepositoryId] =
React.useState<string | null>(null);
const [filesContext, setFilesContext] = React.useState<{
kind: "file" | "folder";
onBack?: () => void;
@@ -91,9 +91,23 @@ export function ProjectHomeWorkspaceSheet({
const [createPullRequestOpen, setCreatePullRequestOpen] =
React.useState(false);
const issuesQuery = useProjectIssuesQuery(repository);
const projectScope = React.useMemo(() => [project], [project]);
const workItemsQuery = useProjectsWorkItemsQuery(projectScope);
const pullRequestsQuery = useProjectPullRequestsQuery(repository);
const issues = issuesQuery.data ?? [];
const issueItems = React.useMemo(
() =>
(workItemsQuery.data?.issues.items ?? []).map(
({ issue, repository: issueRepository }) => ({
issue,
project: issueRepository,
}),
),
[workItemsQuery.data?.issues.items],
);
const issues = React.useMemo(
() => issueItems.map(({ issue }) => issue),
[issueItems],
);
const pullRequests = pullRequestsQuery.data ?? [];
const people = useProjectDetailPeople({
issues,
@@ -113,13 +127,23 @@ export function ProjectHomeWorkspaceSheet({
true,
);
const snapshot = snapshotQuery.data ?? null;
const repositorySnapshots = useProjectRepositorySnapshots(
project.repositories,
tab === "commits",
);
const selectedCommitResult =
repositorySnapshots.find(
({ repository: candidate }) =>
candidate.id === selectedCommitRepositoryId,
) ?? null;
const selectedCommitRepository =
selectedCommitResult?.repository ?? repository;
const commitDiffQuery = useProjectCommitDiffQuery(
repository,
selectedCommitRepository,
selectedCommitHash,
"remote",
activeCommunity?.reposDir,
);
const createIssueMutation = useCreateProjectIssueMutation(repository);
const contributorPubkeysByGitIdentity = React.useMemo(
() =>
gitContributorPubkeysFromCommits(snapshot?.commits ?? [], pullRequests),
@@ -129,24 +153,37 @@ export function ProjectHomeWorkspaceSheet({
pullRequests.find(
(pullRequest) => pullRequest.id === selectedPullRequestId,
) ?? null;
const selectedIssueItem =
issueItems.find(({ issue }) => issue.id === selectedIssueId) ?? null;
const selectedCommit =
selectedCommitResult?.snapshot?.commits.find(
(commit) => commit.hash === selectedCommitHash,
) ??
snapshot?.commits.find((commit) => commit.hash === selectedCommitHash) ??
null;
const selectedCommitPullRequest = selectedCommitHash
? pullRequests.find(
(pullRequest) =>
pullRequest.commit === selectedCommitHash ||
pullRequest.initialCommit === selectedCommitHash,
)
? selectedCommitRepository.id === repository.id
? pullRequests.find(
(pullRequest) =>
pullRequest.commit === selectedCommitHash ||
pullRequest.initialCommit === selectedCommitHash,
)
: null
: null;
const handleCreateIssue = React.useCallback(
async (input: CreateIssueDialogInput) => {
const issueId = await createIssueMutation.mutateAsync(input);
toast.success("Task created.");
await issuesQuery.refetch();
const handleIssueCreated = React.useCallback(
async (
createdProject: Project,
_createdRepository: Project["repositories"][number],
issueId: string,
) => {
if (createdProject.id !== project.id) {
await goProject(createdProject.id, { issueId });
return;
}
await workItemsQuery.refetch();
setSelectedIssueId(issueId);
},
[createIssueMutation, issuesQuery],
[goProject, project.id, workItemsQuery],
);
const handlePullRequestCreated = React.useCallback(
async (
@@ -179,7 +216,10 @@ export function ProjectHomeWorkspaceSheet({
if (tab === "issues" && selectedIssueId) {
return {
backLabel: "Back to Tasks",
navigation: { issueId: selectedIssueId },
navigation: {
issueId: selectedIssueId,
repositoryId: selectedIssueItem?.project.id,
},
onBack: () => setSelectedIssueId(null),
};
}
@@ -193,8 +233,14 @@ export function ProjectHomeWorkspaceSheet({
if (tab === "commits" && selectedCommitHash) {
return {
backLabel: "Back to Commits",
navigation: { commitHash: selectedCommitHash },
onBack: () => setSelectedCommitHash(null),
navigation: {
commitHash: selectedCommitHash,
repositoryId: selectedCommitRepository.id,
},
onBack: () => {
setSelectedCommitHash(null);
setSelectedCommitRepositoryId(null);
},
};
}
if (tab === "files" && filesContext?.onBack) {
@@ -208,7 +254,9 @@ export function ProjectHomeWorkspaceSheet({
}, [
filesContext,
selectedCommitHash,
selectedCommitRepository.id,
selectedIssueId,
selectedIssueItem?.project.id,
selectedPullRequestId,
tab,
]);
@@ -224,7 +272,7 @@ export function ProjectHomeWorkspaceSheet({
React.useEffect(() => {
if (tab === "issues" && !selectedIssueId) {
onCreateActionChange?.({
disabled: createIssueMutation.isPending,
disabled: project.repositories.length === 0,
label: "Create task",
onClick: () => setCreateIssueOpen(true),
});
@@ -241,8 +289,8 @@ export function ProjectHomeWorkspaceSheet({
}
onCreateActionChange?.(null);
}, [
createIssueMutation.isPending,
onCreateActionChange,
project.repositories.length,
projects.length,
selectedIssueId,
selectedPullRequestId,
@@ -260,9 +308,12 @@ export function ProjectHomeWorkspaceSheet({
case "issues":
body = (
<ProjectIssuesPanel
error={workItemsQuery.error}
isLoading={workItemsQuery.isLoading}
issueItems={issueItems}
onSelectedIssueIdChange={setSelectedIssueId}
profiles={people.profiles}
project={repository}
project={selectedCommitRepository}
selectedIssueId={selectedIssueId}
/>
);
@@ -293,17 +344,15 @@ export function ProjectHomeWorkspaceSheet({
project={repository}
/>
) : (
<ActivityPanel
branch={defaultBranch ?? undefined}
error={snapshotQuery.error}
isLoading={snapshotQuery.isPending}
onSelectCommit={(commit) => setSelectedCommitHash(commit.hash)}
<ProjectHomeCommitsPanel
onSelectCommit={(commit, commitRepository) => {
setSelectedCommitRepositoryId(commitRepository.id);
setSelectedCommitHash(commit.hash);
}}
profiles={people.profiles}
project={repository}
projectId={project.id}
pullRequests={pullRequests}
repoContributors={snapshot?.contributors ?? []}
snapshot={snapshot}
results={repositorySnapshots}
viewerGitIdentity={people.viewerGitIdentity}
/>
);
@@ -362,12 +411,12 @@ export function ProjectHomeWorkspaceSheet({
reposDir={activeCommunity?.reposDir}
/>
) : null}
<CreateIssueDialog
isCreating={createIssueMutation.isPending}
onCreate={handleCreateIssue}
<CreateProjectIssueDialog
initialProjectId={project.id}
onCreated={handleIssueCreated}
onOpenChange={setCreateIssueOpen}
open={createIssueOpen}
projectName={repository.name}
projects={projectScope}
/>
</div>
);

View File

@@ -116,6 +116,11 @@ const ISSUE_STATUS_ORDER: readonly ProjectIssue["status"][] = [
"Closed",
];
export type ProjectIssuePanelItem = {
issue: ProjectIssue;
project: Project;
};
function issueMembers(
project: Project,
issue: ProjectIssue,
@@ -415,54 +420,69 @@ export function ProjectIssueDetail({
}
export function ProjectIssuesPanel({
error,
isLoading,
issueItems,
onSelectedIssueIdChange,
profiles,
project,
selectedIssueId,
}: {
error?: unknown;
isLoading?: boolean;
issueItems?: ProjectIssuePanelItem[];
onSelectedIssueIdChange: (id: string | null) => void;
profiles?: UserProfileLookup;
project: Project;
selectedIssueId: string | null;
}) {
const issuesQuery = useProjectIssuesQuery(project);
const issues = issuesQuery.data ?? [];
const selectedIssue =
issues.find((issue) => issue.id === selectedIssueId) ?? null;
const issuesQuery = useProjectIssuesQuery(
issueItems === undefined ? project : null,
);
const resolvedItems =
issueItems ?? (issuesQuery.data ?? []).map((issue) => ({ issue, project }));
const selectedItem =
resolvedItems.find(({ issue }) => issue.id === selectedIssueId) ?? null;
const loading = isLoading ?? issuesQuery.isLoading;
const loadError = error ?? issuesQuery.error;
if (issuesQuery.isLoading) {
if (loading) {
return <BuzzLoadingState label="Loading tasks" />;
}
if (issues.length === 0) {
if (resolvedItems.length === 0) {
return (
<ProjectPanelState
description={
issuesQuery.error
? "Refresh the repository and try again."
: "Tasks created for this repository will appear here."
loadError
? "Refresh the project and try again."
: issueItems
? "Tasks created for this project's repositories will appear here."
: "Tasks created for this repository will appear here."
}
error={Boolean(issuesQuery.error)}
title={issuesQuery.error ? "Could not load tasks" : "No tasks yet"}
error={Boolean(loadError)}
title={loadError ? "Could not load tasks" : "No tasks yet"}
/>
);
}
if (selectedIssue) {
if (selectedItem) {
return (
<ProjectIssueDetail
issue={selectedIssue}
issue={selectedItem.issue}
profiles={profiles}
project={project}
project={selectedItem.project}
/>
);
}
const groups = ISSUE_STATUS_ORDER.map((status) => ({
items: issues.filter((issue) => issue.status === status),
items: resolvedItems.filter(({ issue }) => issue.status === status),
status,
})).filter((group) => group.items.length > 0);
const rangeItems = issues.map((issue) => issueSelectionItem(project, issue));
const rangeItems = resolvedItems.map(({ issue, project: itemProject }) =>
issueSelectionItem(itemProject, issue),
);
return (
<div>
@@ -477,17 +497,19 @@ export function ProjectIssuesPanel({
state={visual.progress}
/>
}
items={items.map((issue) => issueSelectionItem(project, issue))}
items={items.map(({ issue, project: itemProject }) =>
issueSelectionItem(itemProject, issue),
)}
key={status}
label={status}
>
{items.map((issue) => (
{items.map(({ issue, project: itemProject }) => (
<IssueRow
issue={issue}
key={issue.id}
onOpen={() => onSelectedIssueIdChange(issue.id)}
profiles={profiles}
project={project}
project={itemProject}
rangeItems={rangeItems}
/>
))}

View File

@@ -15,6 +15,7 @@ export function ProjectSelectableGroup({
icon,
items,
label,
labelClassName,
labelTestId,
testId,
}: {
@@ -27,6 +28,7 @@ export function ProjectSelectableGroup({
icon: React.ReactNode;
items: ProjectSelectionItem[];
label: string;
labelClassName?: string;
labelTestId?: string;
testId: string;
}) {
@@ -93,7 +95,10 @@ export function ProjectSelectableGroup({
type="button"
>
<span
className="min-w-0 truncate font-medium text-foreground/80"
className={cn(
"min-w-0 truncate font-medium text-muted-foreground",
labelClassName,
)}
data-testid={labelTestId}
>
{label}

View File

@@ -34,6 +34,9 @@ export function ProjectTabsList({
<ArrowLeft className="h-full w-full" strokeWidth={2} />
</button>
<TabsList className="h-full min-w-0 max-w-full flex-none justify-start gap-1.5 overflow-x-auto bg-transparent p-0 scrollbar-none">
<TabsTrigger className={PROJECT_TAB_TRIGGER_CLASS} value="overview">
<ProjectTabLabel>Overview</ProjectTabLabel>
</TabsTrigger>
<TabsTrigger className={PROJECT_TAB_TRIGGER_CLASS} value="files">
<ProjectTabLabel>Files</ProjectTabLabel>
</TabsTrigger>

View File

@@ -0,0 +1,106 @@
import * as React from "react";
import { toast } from "sonner";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { useChannelsQuery } from "@/features/channels/hooks";
import type { Project } from "@/features/projects/hooks";
import { useAddProjectChannelMutation } from "@/features/projects/useAddProjectChannel";
import { useAddProjectRepositoryMutation } from "@/features/projects/useAddProjectRepository";
import { AddProjectRepositoryDialog } from "@/features/projects/ui/AddProjectRepositoryDialog";
import { CreateChannelDialog } from "@/features/sidebar/ui/CreateChannelDialog";
export function ProjectsCategoryCreateDialogs({
channelOpen,
editableProjects,
onChannelOpenChange,
onRepositoryOpenChange,
ownerControlAgentPubkeyFor,
repositoryOpen,
}: {
channelOpen: boolean;
editableProjects: Project[];
onChannelOpenChange: (open: boolean) => void;
onRepositoryOpenChange: (open: boolean) => void;
ownerControlAgentPubkeyFor: (project: Project) => string | undefined;
repositoryOpen: boolean;
}) {
const { goChannel, goProject } = useAppNavigation();
const [channelProjectId, setChannelProjectId] = React.useState("");
const channelProject =
editableProjects.find((project) => project.id === channelProjectId) ??
editableProjects[0];
const createChannelMutation = useAddProjectChannelMutation();
const createRepositoryMutation = useAddProjectRepositoryMutation();
const channelsQuery = useChannelsQuery({ enabled: repositoryOpen });
const repositoryAccessChannels = React.useMemo(
() =>
(channelsQuery.data ?? []).filter(
(channel) =>
channel.isMember &&
!channel.archivedAt &&
channel.channelType !== "dm",
),
[channelsQuery.data],
);
return (
<>
<CreateChannelDialog
channelKind={channelOpen ? "stream" : null}
description={
channelProject
? `Add another stream to ${channelProject.name}.`
: "Choose a project for this channel."
}
isCreating={createChannelMutation.isPending}
onCreate={async (input) => {
if (!channelProject) throw new Error("Choose a project.");
const result = await createChannelMutation.mutateAsync({
...input,
ownerControlAgentPubkey: ownerControlAgentPubkeyFor(channelProject),
project: channelProject,
});
toast.success(`Channel "#${result.channel.name}" created.`);
await goChannel(result.channel.id);
}}
onOpenChange={onChannelOpenChange}
testId="create-project-channel-dialog"
title="Create a project channel"
>
<label className="block space-y-1.5 text-sm font-medium">
<span>Project</span>
<select
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm font-normal outline-hidden focus:ring-1 focus:ring-ring"
data-testid="create-project-channel-project"
disabled={createChannelMutation.isPending}
onChange={(event) => setChannelProjectId(event.target.value)}
value={channelProject?.id ?? ""}
>
{editableProjects.map((project) => (
<option key={project.id} value={project.id}>
{project.name}
</option>
))}
</select>
</label>
</CreateChannelDialog>
<AddProjectRepositoryDialog
channels={repositoryAccessChannels}
isCreating={createRepositoryMutation.isPending}
onAdd={async (input) => {
const result = await createRepositoryMutation.mutateAsync({
...input,
ownerControlAgentPubkey: ownerControlAgentPubkeyFor(input.project),
});
toast.success(`Repository "${result.repository.name}" created.`);
await goProject(input.project.id, {
repositoryId: result.repository.id,
});
}}
onOpenChange={onRepositoryOpenChange}
open={repositoryOpen}
projects={editableProjects}
/>
</>
);
}

View File

@@ -1,127 +0,0 @@
import { CircleDot, FolderGit2, GitPullRequest, Plus } from "lucide-react";
import * as React from "react";
import { Button } from "@/shared/ui/button";
import {
POPOVER_SHADOW_STYLE,
POPOVER_SURFACE_CLASS,
} from "@/shared/ui/popoverSurface";
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;
}) {
const [open, setOpen] = React.useState(false);
const containerRef = React.useRef<HTMLElement>(null);
React.useEffect(() => {
if (!open) return;
function handlePointerDown(event: PointerEvent) {
if (!containerRef.current?.contains(event.target as Node)) {
setOpen(false);
}
}
globalThis.document.addEventListener(
"pointerdown",
handlePointerDown,
true,
);
return () =>
globalThis.document.removeEventListener(
"pointerdown",
handlePointerDown,
true,
);
}, [open]);
function select(action: () => void) {
setOpen(false);
action();
}
return (
<nav
aria-label="Create project item"
className="relative shrink-0"
onBlurCapture={(event) => {
if (!event.currentTarget.contains(event.relatedTarget)) {
setOpen(false);
}
}}
onKeyDown={(event) => {
if (event.key === "Escape") {
setOpen(false);
containerRef.current?.querySelector<HTMLElement>("button")?.focus();
}
}}
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
ref={containerRef}
>
<Button
aria-expanded={open}
aria-haspopup="menu"
aria-label="Create"
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={compact ? "ghost" : "default"}
>
<Plus className="h-4 w-4" />
</Button>
{open ? (
<div className="absolute right-0 top-full z-50 min-w-48 pt-1">
<div
className={`rounded-xl p-1 ${POPOVER_SURFACE_CLASS}`}
role="menu"
style={POPOVER_SHADOW_STYLE}
>
<button
className={MENU_ITEM_CLASS}
onClick={() => select(onCreateProject)}
role="menuitem"
type="button"
>
<FolderGit2 />
Project
</button>
<button
className={MENU_ITEM_CLASS}
onClick={() => select(onCreateIssue)}
role="menuitem"
type="button"
>
<CircleDot />
Task
</button>
<button
className={MENU_ITEM_CLASS}
onClick={() => select(onCreatePullRequest)}
role="menuitem"
type="button"
>
<GitPullRequest />
Review
</button>
</div>
</div>
) : null}
</nav>
);
}

View File

@@ -1,6 +1,7 @@
import * as React from "react";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { FolderGit2, Folders } from "lucide-react";
import type {
Project,
ProjectActivitySummary,
@@ -17,14 +18,16 @@ import {
} from "@/features/projects/lib/projectShareLinks";
import {
isProjectOwnedByCurrentUser,
isProjectMine,
projectPeople,
type ProjectsFilter,
type ProjectsViewMode,
} from "@/features/projects/lib/projectsViewHelpers";
import {
type ProjectSelectionItem,
selectionItemFromProject,
selectionItemFromRepository,
} from "@/features/projects/lib/projectSelection";
import { ProjectSelectableGroup } from "@/features/projects/ui/ProjectSelectableGroup";
import {
EmptyFilteredState,
ProjectGridCard,
@@ -35,7 +38,78 @@ import {
RepositoryListRow,
} from "@/features/projects/ui/RepositoryCards";
import { useIncrementalMount } from "@/shared/hooks/useIncrementalMount";
import { cn } from "@/shared/lib/cn";
import { normalizePubkey } from "@/shared/lib/pubkey";
const RESPONSIVE_CARD_GRID_CLASS =
"grid gap-3 [grid-template-columns:repeat(auto-fit,minmax(min(100%,16rem),1fr))]";
function CollectionGroup({
children,
icon,
items,
title,
}: {
children: React.ReactNode;
icon: React.ReactNode;
items: ProjectSelectionItem[];
title: string;
}) {
return (
<ProjectSelectableGroup
contentClassName="mt-2 space-y-0"
count={items.length}
groupKey={title}
headerClassName="mx-0 gap-3 px-3"
headerTestId="projects-collection-group-header"
icon={icon}
items={items}
label={title}
labelClassName="text-sm font-normal text-foreground/80"
labelTestId="projects-collection-group-label"
testId="projects-collection-group"
>
{children}
</ProjectSelectableGroup>
);
}
function repositoryIsMine(
repository: Repository,
currentPubkey: string | undefined,
) {
if (!currentPubkey) return false;
const viewer = normalizePubkey(currentPubkey);
return (
normalizePubkey(repository.owner) === viewer ||
repository.contributors.some((pubkey) => normalizePubkey(pubkey) === viewer)
);
}
function projectSelectionItems(projects: readonly Project[]) {
return projects.map((project) =>
selectionItemFromProject({
channelId: project.projectChannelId,
id: project.id,
owner: project.owner,
shareLink: projectShareLink(project),
title: project.name,
}),
);
}
function repositorySelectionItems(
rows: ReadonlyArray<{ project: Project; repository: Repository }>,
) {
return rows.map((row) =>
selectionItemFromRepository({
channelId: row.repository.channelId ?? row.project.projectChannelId,
id: row.repository.id,
owner: row.repository.owner,
shareLink: repositoryShareLink(row.repository),
title: row.repository.name,
}),
);
}
// Stable fallback so a cache miss cannot hand a memoized card a fresh array.
const EMPTY_PEOPLE: string[] = [];
@@ -43,7 +117,6 @@ const EMPTY_PEOPLE: string[] = [];
export function ProjectsOverviewProjectItems({
currentPubkey,
deleteDisabled,
filter,
localRepoNames,
onDelete,
onOpen,
@@ -56,7 +129,6 @@ export function ProjectsOverviewProjectItems({
}: {
currentPubkey: string | undefined;
deleteDisabled: boolean;
filter: ProjectsFilter;
localRepoNames: Set<string>;
onDelete: (project: Project) => void;
onOpen: (project: Project) => void;
@@ -114,82 +186,122 @@ export function ProjectsOverviewProjectItems({
() => visibleProjects.slice(0, mountedCount),
[mountedCount, visibleProjects],
);
const mountedProjectIds = React.useMemo(
() => new Set(mountedProjects.map((project) => project.id)),
[mountedProjects],
);
if (visibleProjects.length === 0) {
return <EmptyFilteredState />;
}
const groups = [
{
items: visibleProjects.filter((project) =>
isProjectMine(project, currentPubkey),
),
title: "Mine",
},
{
items: visibleProjects.filter(
(project) => !isProjectMine(project, currentPubkey),
),
title: "Other projects",
},
].filter((group) => group.items.length > 0);
if (viewMode === "grid") {
return (
<div
className={cn(
"grid gap-3 md:grid-cols-2",
filter !== "all" && "xl:grid-cols-3",
)}
>
{mountedProjects.map((project) => {
const summary = summaries?.[project.id];
return (
<div
className={
"[contain-intrinsic-size:auto_11rem] [content-visibility:auto]"
}
key={project.id}
>
<ProjectGridCard
canDelete={isProjectOwnedByCurrentUser(project, currentPubkey)}
deleteDisabled={deleteDisabled}
hasLocal={hasLocalCheckout(project, localRepoNames)}
onDelete={onDelete}
onOpen={onOpen}
onOpenTerminal={onOpenTerminal}
people={peopleByProject.get(project.id) ?? EMPTY_PEOPLE}
profiles={profiles}
project={project}
repositoryUnavailableReason={repositoryUnavailableReasonFor(
project,
)}
summary={summary}
/>
<div className="space-y-0">
{groups.map((group) => (
<CollectionGroup
icon={<Folders className="h-4 w-4" />}
items={projectSelectionItems(group.items)}
key={group.title}
title={group.title}
>
<div className={RESPONSIVE_CARD_GRID_CLASS}>
{group.items
.filter((project) => mountedProjectIds.has(project.id))
.map((project) => {
const summary = summaries?.[project.id];
return (
<div
className="[contain-intrinsic-size:auto_11rem] [content-visibility:auto]"
key={project.id}
>
<ProjectGridCard
canDelete={isProjectOwnedByCurrentUser(
project,
currentPubkey,
)}
deleteDisabled={deleteDisabled}
hasLocal={hasLocalCheckout(project, localRepoNames)}
onDelete={onDelete}
onOpen={onOpen}
onOpenTerminal={onOpenTerminal}
people={peopleByProject.get(project.id) ?? EMPTY_PEOPLE}
profiles={profiles}
project={project}
repositoryUnavailableReason={repositoryUnavailableReasonFor(
project,
)}
summary={summary}
/>
</div>
);
})}
</div>
);
})}
</CollectionGroup>
))}
</div>
);
}
return (
<div data-testid="projects-list-container">
{visibleProjects.map((project) => {
const summary = summaries?.[project.id];
return (
<div
className={
"[contain-intrinsic-size:auto_3.5rem] [content-visibility:auto]"
}
key={project.id}
>
<ProjectListRow
canDelete={isProjectOwnedByCurrentUser(project, currentPubkey)}
deleteDisabled={deleteDisabled}
hasLocal={hasLocalCheckout(project, localRepoNames)}
onDelete={onDelete}
onOpen={onOpen}
onOpenTerminal={onOpenTerminal}
people={peopleByProject.get(project.id) ?? EMPTY_PEOPLE}
profiles={profiles}
project={project}
repositoryUnavailableReason={repositoryUnavailableReasonFor(
project,
)}
selectionRangeItems={selectionRangeItems}
summary={summary}
/>
<div className="space-y-0" data-testid="projects-list-container">
{groups.map((group) => (
<CollectionGroup
icon={<Folders className="h-4 w-4" />}
items={projectSelectionItems(group.items)}
key={group.title}
title={group.title}
>
<div>
{group.items.map((project) => {
const summary = summaries?.[project.id];
return (
<div
className="[contain-intrinsic-size:auto_3.5rem] [content-visibility:auto]"
key={project.id}
>
<ProjectListRow
canDelete={isProjectOwnedByCurrentUser(
project,
currentPubkey,
)}
deleteDisabled={deleteDisabled}
hasLocal={hasLocalCheckout(project, localRepoNames)}
onDelete={onDelete}
onOpen={onOpen}
onOpenTerminal={onOpenTerminal}
people={peopleByProject.get(project.id) ?? EMPTY_PEOPLE}
profiles={profiles}
project={project}
repositoryUnavailableReason={repositoryUnavailableReasonFor(
project,
)}
selectionRangeItems={selectionRangeItems}
summary={summary}
/>
</div>
);
})}
</div>
);
})}
</CollectionGroup>
))}
</div>
);
}
export function ProjectsOverviewRepositoryItems({
currentPubkey,
localRepoNames,
onOpen,
onOpenTerminal,
@@ -198,6 +310,7 @@ export function ProjectsOverviewRepositoryItems({
viewMode,
visibleRepositories,
}: {
currentPubkey: string | undefined;
localRepoNames: Set<string>;
onOpen: (project: Project, repository: Repository) => void;
onOpenTerminal: (repository: Repository) => void;
@@ -233,53 +346,102 @@ export function ProjectsOverviewRepositoryItems({
() => visibleRepositories.slice(0, mountedCount),
[mountedCount, visibleRepositories],
);
const mountedRepositoryAddresses = React.useMemo(
() =>
new Set(
mountedRepositories.map(({ repository }) => repository.repoAddress),
),
[mountedRepositories],
);
if (visibleRepositories.length === 0) {
return <EmptyFilteredState />;
}
const groups = [
{
items: visibleRepositories.filter(({ repository }) =>
repositoryIsMine(repository, currentPubkey),
),
title: "Mine",
},
{
items: visibleRepositories.filter(
({ repository }) => !repositoryIsMine(repository, currentPubkey),
),
title: "Other repositories",
},
].filter((group) => group.items.length > 0);
if (viewMode === "grid") {
return (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{mountedRepositories.map(({ project, repository }) => (
<div
className={
"[contain-intrinsic-size:auto_11rem] [content-visibility:auto]"
}
key={repository.repoAddress}
<div className="space-y-0">
{groups.map((group) => (
<CollectionGroup
icon={<FolderGit2 className="h-4 w-4" />}
items={repositorySelectionItems(group.items)}
key={group.title}
title={group.title}
>
<RepositoryGridCard
hasLocal={hasLocalRepositoryCheckout(repository, localRepoNames)}
onOpen={onOpen}
onOpenTerminal={onOpenTerminal}
profiles={profiles}
project={project}
repository={repository}
summary={summaries?.[repository.repoAddress]}
/>
</div>
<div className={RESPONSIVE_CARD_GRID_CLASS}>
{group.items
.filter(({ repository }) =>
mountedRepositoryAddresses.has(repository.repoAddress),
)
.map(({ project, repository }) => (
<div
className="[contain-intrinsic-size:auto_11rem] [content-visibility:auto]"
key={repository.repoAddress}
>
<RepositoryGridCard
hasLocal={hasLocalRepositoryCheckout(
repository,
localRepoNames,
)}
onOpen={onOpen}
onOpenTerminal={onOpenTerminal}
profiles={profiles}
project={project}
repository={repository}
summary={summaries?.[repository.repoAddress]}
/>
</div>
))}
</div>
</CollectionGroup>
))}
</div>
);
}
return (
<div data-testid="projects-list-container">
{visibleRepositories.map(({ project, repository }) => (
<div
className={
"[contain-intrinsic-size:auto_3.5rem] [content-visibility:auto]"
}
key={repository.repoAddress}
<div className="space-y-0" data-testid="projects-list-container">
{groups.map((group) => (
<CollectionGroup
icon={<FolderGit2 className="h-4 w-4" />}
items={repositorySelectionItems(group.items)}
key={group.title}
title={group.title}
>
<RepositoryListRow
hasLocal={hasLocalRepositoryCheckout(repository, localRepoNames)}
onOpen={onOpen}
onOpenTerminal={onOpenTerminal}
profiles={profiles}
project={project}
repository={repository}
selectionRangeItems={selectionRangeItems}
summary={summaries?.[repository.repoAddress]}
/>
</div>
<div>
{group.items.map(({ project, repository }) => (
<div
className="[contain-intrinsic-size:auto_3.5rem] [content-visibility:auto]"
key={repository.repoAddress}
>
<RepositoryListRow
hasLocal={hasLocalRepositoryCheckout(
repository,
localRepoNames,
)}
onOpen={onOpen}
onOpenTerminal={onOpenTerminal}
profiles={profiles}
project={project}
repository={repository}
selectionRangeItems={selectionRangeItems}
summary={summaries?.[repository.repoAddress]}
/>
</div>
))}
</div>
</CollectionGroup>
))}
</div>
);

View File

@@ -26,10 +26,10 @@ import type { ProjectsActivityDigest } from "@/features/projects/lib/projectsAct
import { useProjectSelection } from "@/features/projects/lib/useProjectSelection";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { ProjectsCreateMenu } from "./ProjectsCreateMenu";
import { ProjectsOverviewPeople } from "./ProjectsOverviewRail";
import { ProjectsSelectionCountMenu } from "./ProjectsSelectionCountMenu";
import {
type OverviewContextAction,
type OverviewContextStatIcon,
type ProjectsOverviewSection,
projectsOverviewContext,
@@ -56,7 +56,10 @@ type ProjectsOverviewPanelProps = {
type ProjectsOverviewContextPanelProps = {
filter: ProjectsFilter;
canCreateTarget: boolean;
issues: ProjectIssue[];
onAddChannel: () => void;
onAddRepository: () => void;
onChatWithAgent: (items: ProjectSelectionItem[]) => void;
onCreateIssue: () => void;
onCreateProject: () => void;
@@ -70,25 +73,48 @@ type ProjectsOverviewContextPanelProps = {
summaries?: Record<string, ProjectActivitySummary>;
};
function OverviewActionButton({
children,
onClick,
testId,
function OverviewCreateButton({
action,
canCreateTarget,
onAddChannel,
onAddRepository,
onCreateIssue,
onCreateProject,
onCreatePullRequest,
}: {
children: React.ReactNode;
onClick: () => void;
testId?: string;
action: Exclude<OverviewContextAction, null>;
canCreateTarget: boolean;
onAddChannel: () => void;
onAddRepository: () => void;
onCreateIssue: () => void;
onCreateProject: () => void;
onCreatePullRequest: () => void;
}) {
const actionHandler =
action.kind === "issue"
? onCreateIssue
: action.kind === "pullRequest"
? onCreatePullRequest
: action.kind === "project"
? onCreateProject
: action.kind === "channel"
? onAddChannel
: onAddRepository;
const requiresProject =
action.kind === "channel" || action.kind === "repository";
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 text-muted-foreground hover:bg-muted/70 hover:text-foreground [&_svg]:h-4 [&_svg]:w-4 [&_svg]:shrink-0 [&_svg]:text-muted-foreground"
data-testid={testId}
onClick={onClick}
size="sm"
aria-label={action.label}
className="h-6 w-6 shrink-0 rounded-md text-muted-foreground hover:bg-muted/70 hover:text-foreground"
data-testid={action.testId}
disabled={requiresProject && !canCreateTarget}
onClick={actionHandler}
size="icon"
title={action.label}
type="button"
variant="ghost"
>
{children}
<Plus className="h-4 w-4" />
</Button>
);
}
@@ -172,8 +198,11 @@ export function ProjectsActivityIntro({
}
export function ProjectsOverviewContextPanel({
canCreateTarget,
filter,
issues,
onAddChannel,
onAddRepository,
onChatWithAgent,
onCreateIssue,
onCreateProject,
@@ -215,13 +244,6 @@ export function ProjectsOverviewContextPanel({
summaries,
],
);
const actionHandler =
context.action?.kind === "issue"
? onCreateIssue
: context.action?.kind === "pullRequest"
? onCreatePullRequest
: onCreateProject;
return (
<div
className={cn(
@@ -246,25 +268,21 @@ export function ProjectsOverviewContextPanel({
>
{context.title}
</h2>
<ProjectsCreateMenu
compact
onCreateIssue={onCreateIssue}
onCreateProject={onCreateProject}
onCreatePullRequest={onCreatePullRequest}
/>
{context.action ? (
<OverviewCreateButton
action={context.action}
canCreateTarget={canCreateTarget}
onAddChannel={onAddChannel}
onAddRepository={onAddRepository}
onCreateIssue={onCreateIssue}
onCreateProject={onCreateProject}
onCreatePullRequest={onCreatePullRequest}
/>
) : null}
</div>
)}
{selectionPresentation ? null : (
<div className="space-y-2.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="space-y-2.5 text-sm"
data-testid="projects-overview-stats-pod"

View File

@@ -1,4 +1,15 @@
import { Bot, GitPullRequest, Link2, ListChecks, X } from "lucide-react";
import {
Bot,
CircleDot,
FolderGit2,
Folders,
GitCommitHorizontal,
GitPullRequest,
Hash,
Link2,
ListChecks,
X,
} from "lucide-react";
import * as React from "react";
import {
@@ -19,6 +30,16 @@ function selectionActionIcon(id: ProjectSelectionAction["id"]) {
return Link2;
}
function selectionKindIcon(kind: ProjectSelectionItem["kind"] | undefined) {
if (kind === "channel") return Hash;
if (kind === "commit") return GitCommitHorizontal;
if (kind === "project") return Folders;
if (kind === "repository") return FolderGit2;
if (kind === "review") return GitPullRequest;
if (kind === "task") return CircleDot;
return ListChecks;
}
/** Inline actions for the current Projects selection. */
export function ProjectsSelectionCountMenu({
onChatWithAgent,
@@ -33,6 +54,8 @@ export function ProjectsSelectionCountMenu({
}) {
const selection = useProjectSelection();
const openChannelWithDraft = useProjectDiscussInChannel(selectionItems);
const selectionKind = selectionItems[0]?.kind;
const SelectionIcon = selectionKindIcon(selectionKind);
const discussInChannel = React.useCallback(
(channelId: string) => {
@@ -68,25 +91,25 @@ export function ProjectsSelectionCountMenu({
return (
<div className="w-full" data-testid="projects-selection-actions">
<div
className="flex min-w-0 items-center gap-2.5"
className="inline-flex max-w-full items-center gap-2 rounded-full border border-border/60 bg-muted/35 px-2.5 py-1.5"
data-testid="projects-selection-summary"
>
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-muted/60 text-muted-foreground">
<ListChecks className="h-4 w-4" />
</span>
<span className="min-w-0">
<span className="block text-2xs font-medium uppercase tracking-wider text-muted-foreground/55">
Selection
</span>
<h2
className="truncate text-sm font-semibold text-foreground"
data-testid="projects-overview-context-title"
>
{presentation.title}
</h2>
</span>
<SelectionIcon
className="h-3.5 w-3.5 shrink-0 text-muted-foreground"
data-testid={
selectionKind
? `projects-selection-kind-${selectionKind}`
: undefined
}
/>
<h2
className="truncate text-sm font-medium text-foreground"
data-testid="projects-overview-context-title"
>
{presentation.title}
</h2>
</div>
<div className="space-y-2.5 pt-4">
<div className="space-y-2.5 pt-3">
{presentation.actions
.filter(
(action) =>

View File

@@ -2,7 +2,9 @@ import * as React from "react";
import { toast } from "sonner";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { useManagedAgentsQuery } from "@/features/agents/hooks";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import { ownsAuthorAgent } from "@/features/profile/lib/identity";
import {
type Project,
type ProjectIssue,
@@ -17,6 +19,7 @@ import {
import { useRepositoryActivitySummariesQuery } from "@/features/projects/repositoryActivityHooks";
import { useCreateProjectMutation } from "@/features/projects/useCreateProject";
import { isExplicitProject } from "@/features/projects/projectModels";
import { projectsWithWorkItemRepositories } from "@/features/projects/projectWorkItems";
import { useProjectsRepoSnapshotsQuery } from "@/features/projects/useProjectsRepoSnapshots";
import { buildProjectSelectionAgentContext } from "@/features/projects/lib/projectDetailAgentContext";
import { buildProjectsActivityDigest } from "@/features/projects/lib/projectsActivityDigest";
@@ -53,6 +56,7 @@ import { CreateProjectDialog } from "@/features/projects/ui/CreateProjectDialog"
import { CreateProjectIssueDialog } from "@/features/projects/ui/CreateProjectIssueDialog";
import { CreatePullRequestDialog } from "@/features/projects/ui/CreatePullRequestDialog";
import { ProjectAgentChatPanel } from "@/features/projects/ui/ProjectAgentChatPanel";
import { ProjectsCategoryCreateDialogs } from "@/features/projects/ui/ProjectsCategoryCreateDialogs";
import { ProjectsIssuesList } from "@/features/projects/ui/ProjectsIssuesList";
import { ProjectsWorkspaceChrome } from "@/features/projects/ui/ProjectDetailChrome";
import { ProjectsPullRequestsList } from "@/features/projects/ui/ProjectsPullRequestsList";
@@ -115,6 +119,7 @@ export function ProjectsView() {
useProjectsScrollIndicator();
const projectsQuery = useProjectsQuery();
const identityQuery = useIdentityQuery();
const managedAgentsQuery = useManagedAgentsQuery();
const projectReadModels = projectsQuery.data ?? [];
const projects = React.useMemo(
() => projectReadModels.filter(isExplicitProject),
@@ -146,7 +151,11 @@ export function ProjectsView() {
const repositoryActivitySummariesQuery = useRepositoryActivitySummariesQuery(
filter === "repositories" ? projectReadModels : [],
);
const projectsWorkItemsQuery = useProjectsWorkItemsQuery(projectReadModels);
const workItemProjects = React.useMemo(
() => projectsWithWorkItemRepositories(projectReadModels),
[projectReadModels],
);
const projectsWorkItemsQuery = useProjectsWorkItemsQuery(workItemProjects);
// One blobless clone per primary Buzz repository, only while the overview
// header is visible.
const snapshotProjects = React.useMemo(
@@ -169,6 +178,8 @@ export function ProjectsView() {
memberChannelIds,
);
const [createProjectOpen, setCreateProjectOpen] = React.useState(false);
const [createChannelOpen, setCreateChannelOpen] = React.useState(false);
const [createRepositoryOpen, setCreateRepositoryOpen] = React.useState(false);
const [createIssueOpen, setCreateIssueOpen] = React.useState(false);
const [createPullRequestOpen, setCreatePullRequestOpen] =
React.useState(false);
@@ -233,6 +244,42 @@ export function ProjectsView() {
);
const deleteProjectMutation = useDeleteProjectMutation();
const currentPubkey = identityQuery.data?.pubkey;
const managedAgentPubkeys = React.useMemo(
() =>
new Set(
(managedAgentsQuery.data ?? []).map((agent) =>
normalizePubkey(agent.pubkey),
),
),
[managedAgentsQuery.data],
);
const editableProjects = React.useMemo(() => {
if (!currentPubkey) return [];
const viewer = normalizePubkey(currentPubkey);
return projects.filter((project) => {
const owner = normalizePubkey(project.owner);
return (
owner === viewer ||
managedAgentPubkeys.has(owner) ||
ownsAuthorAgent(profiles?.[owner], currentPubkey)
);
});
}, [currentPubkey, managedAgentPubkeys, profiles, projects]);
const ownerControlAgentPubkeyFor = React.useCallback(
(project: Project) => {
const owner = normalizePubkey(project.owner);
if (
owner === normalizePubkey(currentPubkey ?? "") ||
managedAgentPubkeys.has(owner)
) {
return undefined;
}
return ownsAuthorAgent(profiles?.[owner], currentPubkey)
? project.owner
: undefined;
},
[currentPubkey, managedAgentPubkeys, profiles],
);
const handleViewModeChange = React.useCallback(
(nextViewMode: ProjectsViewMode) => {
@@ -526,7 +573,6 @@ export function ProjectsView() {
<ProjectsOverviewProjectItems
currentPubkey={currentPubkey}
deleteDisabled={deleteProjectMutation.isPending}
filter={filter}
localRepoNames={localRepoNames}
onDelete={handleDeleteProject}
onOpen={handleOpenProject}
@@ -541,6 +587,7 @@ export function ProjectsView() {
const repositoryItems = (
<ProjectsOverviewRepositoryItems
currentPubkey={currentPubkey}
localRepoNames={localRepoNames}
onOpen={handleOpenRepository}
onOpenTerminal={handleOpenRepositoryTerminal}
@@ -596,8 +643,11 @@ export function ProjectsView() {
);
const contextPanelProps = {
canCreateTarget: editableProjects.length > 0,
filter,
issues: contextIssues,
onAddChannel: () => setCreateChannelOpen(true),
onAddRepository: () => setCreateRepositoryOpen(true),
onChatWithAgent: (items: ProjectSelectionItem[]) =>
setSelectionAgentContext(buildProjectSelectionAgentContext(items)),
onCreateIssue: () => setCreateIssueOpen(true),
@@ -735,6 +785,14 @@ export function ProjectsView() {
open={createIssueOpen}
projects={projects}
/>
<ProjectsCategoryCreateDialogs
channelOpen={createChannelOpen}
editableProjects={editableProjects}
onChannelOpenChange={setCreateChannelOpen}
onRepositoryOpenChange={setCreateRepositoryOpen}
ownerControlAgentPubkeyFor={ownerControlAgentPubkeyFor}
repositoryOpen={createRepositoryOpen}
/>
<div className="flex min-h-0 min-w-0 flex-1">
<div className="relative min-h-0 min-w-0 flex-1">
<div

View File

@@ -51,7 +51,7 @@ function makePullRequest(status, author = REVIEW_AUTHOR) {
};
}
test("activity pod shows workspace details and a create-project action", () => {
test("activity pod shows workspace details without a create action", () => {
const context = projectsOverviewContext({
filter: "all",
issues: [],
@@ -74,7 +74,7 @@ test("activity pod shows workspace details and a create-project action", () => {
assert.equal(context.title, "Projects");
assert.equal(context.detailsTitle, "Details");
assert.equal(context.action?.label, "Create project");
assert.equal(context.action, null);
assert.deepEqual(
context.stats.map((stat) => [stat.label, stat.count]),
[
@@ -103,7 +103,7 @@ test("projects pod keeps create-project and drops task/review totals", () => {
);
});
test("repositories pod matches repository activity copy", () => {
test("repositories pod matches repository activity copy and add action", () => {
const context = projectsOverviewContext({
filter: "repositories",
issues: [makeIssue("In Progress"), makeIssue("Done")],
@@ -113,7 +113,8 @@ test("repositories pod matches repository activity copy", () => {
assert.equal(context.title, "Repositories");
assert.equal(context.detailsTitle, "Repository activity");
assert.equal(context.action, null);
assert.equal(context.action?.kind, "repository");
assert.equal(context.action?.label, "Add repository");
assert.deepEqual(
context.stats.map((stat) => [stat.label, stat.count]),
[
@@ -124,7 +125,7 @@ test("repositories pod matches repository activity copy", () => {
);
});
test("channels pod titles itself and omits a primary create action", () => {
test("channels pod titles itself and provides an add action", () => {
const context = projectsOverviewContext({
filter: "channels",
issues: [],
@@ -134,7 +135,8 @@ test("channels pod titles itself and omits a primary create action", () => {
assert.equal(context.title, "Channels");
assert.equal(context.detailsTitle, "Details");
assert.equal(context.action, null);
assert.equal(context.action?.kind, "channel");
assert.equal(context.action?.label, "Add channel");
assert.deepEqual(
context.stats.map((stat) => stat.label),
["Channels", "Projects", "Repositories"],

View File

@@ -32,7 +32,7 @@ export type OverviewContextStatIcon =
| "merged";
export type OverviewContextAction = {
kind: "project" | "issue" | "pullRequest";
kind: "channel" | "issue" | "project" | "pullRequest" | "repository";
label: string;
testId: string;
} | null;
@@ -238,7 +238,11 @@ export function projectsOverviewContext(
if (filter === "repositories") {
return {
action: null,
action: {
kind: "repository",
label: "Add repository",
testId: "projects-overview-add-repository",
},
detailsTitle: "Repository activity",
people,
stats: [
@@ -267,7 +271,11 @@ export function projectsOverviewContext(
if (filter === "channels") {
return {
action: null,
action: {
kind: "channel",
label: "Add channel",
testId: "projects-overview-add-channel",
},
detailsTitle: "Details",
people,
stats: [
@@ -362,7 +370,7 @@ export function projectsOverviewContext(
if (filter === "all") {
return {
action: createProjectAction(),
action: null,
detailsTitle: "Details",
people,
stats: [

View File

@@ -4,31 +4,63 @@ import type { CreateChannelManagedAgentInput } from "@/features/agents/channelAg
import {
useAvailableAcpRuntimes,
usePersonasQuery,
useTeamsQuery,
} from "@/features/agents/hooks";
import { getActivePersonas } from "@/features/agents/lib/catalog";
import { resolvePersonaRuntime } from "@/features/agents/lib/resolvePersonaRuntime";
import {
getUsableTeams,
resolveTeamPersonas,
} from "@/features/agents/lib/teamPersonas";
import { useChannelTemplatesQuery } from "@/features/channel-templates/hooks";
import {
PROJECT_HOME_CHANNEL_TEMPLATE,
PROJECT_HOME_TEMPLATE_ID,
} from "@/features/projects/lib/projectHomeTemplate";
import type { ProjectListingVisibility } from "@/features/projects/projectCreation";
import type { ChannelVisibility } from "@/shared/api/types";
import type { ChannelTemplate, ChannelVisibility } from "@/shared/api/types";
export function useCreateProjectFormSettings(active: boolean) {
export function useCreateProjectFormSettings(
active: boolean,
onTemplateDescriptionChange?: (description: string) => void,
) {
const personasQuery = usePersonasQuery({ enabled: active });
const runtimesQuery = useAvailableAcpRuntimes({ enabled: active });
const teamsQuery = useTeamsQuery();
const templatesQuery = useChannelTemplatesQuery();
const [channelVisibility, setChannelVisibility] =
React.useState<ChannelVisibility>("open");
const [projectVisibility, setProjectVisibility] =
React.useState<ProjectListingVisibility>("listed");
const [agentPersonaId, setAgentPersonaId] = React.useState("");
const [teamId, setTeamId] = React.useState("");
const [templateId, setTemplateId] = React.useState(PROJECT_HOME_TEMPLATE_ID);
const personas = React.useMemo(
() => getActivePersonas(personasQuery.data ?? []),
[personasQuery.data],
);
const teams = React.useMemo(
() => getUsableTeams(teamsQuery.data ?? [], personas),
[personas, teamsQuery.data],
);
const templates = React.useMemo(
() => [
PROJECT_HOME_CHANNEL_TEMPLATE,
...(templatesQuery.data ?? []).filter(
(template) => template.id !== PROJECT_HOME_TEMPLATE_ID,
),
],
[templatesQuery.data],
);
React.useEffect(() => {
if (!active) return;
setChannelVisibility("open");
setProjectVisibility("listed");
setAgentPersonaId("");
setTeamId("");
setTemplateId(PROJECT_HOME_TEMPLATE_ID);
}, [active]);
React.useEffect(() => {
@@ -39,52 +71,113 @@ export function useCreateProjectFormSettings(active: boolean) {
setAgentPersonaId("");
}
}, [agentPersonaId, personas]);
React.useEffect(() => {
if (teamId && !teams.some((team) => team.id === teamId)) {
setTeamId("");
}
}, [teamId, teams]);
React.useEffect(() => {
if (
templateId &&
!templates.some((template) => template.id === templateId)
) {
setTemplateId("");
}
}, [templateId, templates]);
const buildAgents =
React.useCallback((): CreateChannelManagedAgentInput[] => {
if (!agentPersonaId) return [];
const persona = personas.find((entry) => entry.id === agentPersonaId);
if (!persona) {
throw new Error("Choose an agent that still exists.");
}
const defaultRuntime = runtimesQuery.data[0] ?? null;
const resolved = resolvePersonaRuntime(
persona.runtime,
runtimesQuery.data,
defaultRuntime,
false,
);
if (!resolved.runtime) {
throw new Error(
resolved.warnings[0] ??
"No agent runtimes are available. Install a runtime to add an agent.",
const inputs: CreateChannelManagedAgentInput[] = [];
const seenPersonaIds = new Set<string>();
const addPersona = (
persona: (typeof personas)[number],
selectedTeamId?: string,
) => {
if (seenPersonaIds.has(persona.id)) return;
const resolved = resolvePersonaRuntime(
persona.runtime,
runtimesQuery.data,
defaultRuntime,
false,
);
}
return [
{
if (!resolved.runtime) {
throw new Error(
resolved.warnings[0] ??
"No agent runtimes are available. Install a runtime to add agents.",
);
}
seenPersonaIds.add(persona.id);
inputs.push({
runtime: resolved.runtime,
name: persona.displayName,
personaId: persona.id,
teamId: selectedTeamId,
harnessOverride: false,
systemPrompt: persona.systemPrompt,
avatarUrl: persona.avatarUrl ?? undefined,
model: persona.model ?? undefined,
role: "bot",
backend: { type: "local" },
},
];
}, [agentPersonaId, personas, runtimesQuery.data]);
});
};
if (teamId) {
const team = teams.find((entry) => entry.id === teamId);
if (!team) throw new Error("Choose a team that still exists.");
const resolution = resolveTeamPersonas(team, personas);
for (const persona of resolution.resolvedPersonas) {
addPersona(persona, team.id);
}
}
if (agentPersonaId) {
const persona = personas.find((entry) => entry.id === agentPersonaId);
if (!persona) throw new Error("Choose an agent that still exists.");
addPersona(persona);
}
return inputs;
}, [agentPersonaId, personas, runtimesQuery.data, teamId, teams]);
const applyTemplate = React.useCallback(
(template: ChannelTemplate) => {
setTemplateId(template.id);
setChannelVisibility(template.visibility);
if (template.id !== PROJECT_HOME_TEMPLATE_ID) {
onTemplateDescriptionChange?.(template.description ?? "");
}
},
[onTemplateDescriptionChange],
);
const handleTemplateChange = React.useCallback(
(nextTemplateId: string) => {
if (!nextTemplateId) {
setTemplateId("");
setChannelVisibility("open");
onTemplateDescriptionChange?.("");
return;
}
const template = templates.find((entry) => entry.id === nextTemplateId);
if (template) applyTemplate(template);
},
[applyTemplate, onTemplateDescriptionChange, templates],
);
return {
agentPersonaId,
buildAgents,
channelVisibility,
handleTemplateCreated: applyTemplate,
handleTemplateChange,
personas,
projectVisibility,
runtimesAvailable: runtimesQuery.data.length > 0,
setAgentPersonaId,
setChannelVisibility,
setProjectVisibility,
setTeamId,
teamId,
teams,
templateId,
templates,
};
}

View File

@@ -1,10 +1,12 @@
import * as React from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
channelsQueryKey,
upsertCachedChannel,
} from "@/features/channels/hooks";
import { useApplyTemplate } from "@/features/channel-templates/useApplyTemplate";
import { type Project, projectsQueryKey } from "@/features/projects/hooks";
import {
createProject,
@@ -13,6 +15,10 @@ import {
type CreateProjectResumeState,
} from "@/features/projects/createProject";
import { addProjectToSidebar } from "@/features/projects/lib/projectSidebarMembership";
import {
applyProjectHomeCanvas,
PROJECT_HOME_TEMPLATE_ID,
} from "@/features/projects/lib/projectHomeTemplate";
import type { Channel } from "@/shared/api/types";
import { getCachedRelayOrigin } from "@/shared/lib/mediaUrl";
@@ -21,6 +27,7 @@ export type { CreateProjectInput, CreateProjectResult };
/** Mutation that creates a project home and inserts it into the caches. */
export function useCreateProjectMutation() {
const queryClient = useQueryClient();
const { applyAgents, applyCanvas } = useApplyTemplate();
const resumeRef = React.useRef<CreateProjectResumeState>({
channels: new Map(),
projectIds: new Set(),
@@ -29,7 +36,7 @@ export function useCreateProjectMutation() {
return useMutation({
mutationFn: (input: CreateProjectInput) =>
createProject(input, resumeRef.current),
onSuccess: ({ channel, project }) => {
onSuccess: async ({ channel, project }, input) => {
addProjectToSidebar(
project.projectAddress,
getCachedRelayOrigin(),
@@ -57,6 +64,25 @@ export function useCreateProjectMutation() {
queryKey: channelsQueryKey,
refetchType: "none",
});
const useProjectHomeTemplate =
input.templateId === undefined ||
input.templateId === PROJECT_HOME_TEMPLATE_ID;
if (useProjectHomeTemplate) {
const applied = await applyProjectHomeCanvas({
channelId: channel.id,
project,
});
if (!applied) {
toast.warning(
"Project created, but its project-home canvas could not be added.",
);
}
} else if (input.templateId) {
await Promise.all([
applyCanvas(input.templateId, channel.id, channel.name),
applyAgents(input.templateId, channel.id),
]);
}
}
void queryClient.invalidateQueries({ queryKey: projectsQueryKey });
},

View File

@@ -0,0 +1,60 @@
import { useQueries } from "@tanstack/react-query";
import type {
ProjectRepoSnapshot,
Repository,
} from "@/features/projects/hooks";
import { getProjectRepoSnapshot } from "@/shared/api/projectGit";
export type ProjectRepositorySnapshotResult = {
error: unknown;
isLoading: boolean;
repository: Repository;
snapshot: ProjectRepoSnapshot | null;
};
/** Loads each project repository independently so one failure stays partial. */
export function useProjectRepositorySnapshots(
repositories: Repository[],
enabled = true,
): ProjectRepositorySnapshotResult[] {
const queries = useQueries({
queries: repositories.map((repository) => ({
enabled: Boolean(enabled && repository.cloneUrls[0]),
queryFn: async () => {
const cloneUrl = repository.cloneUrls[0];
if (!cloneUrl) return null;
return getProjectRepoSnapshot({
baseBranch: repository.defaultBranch,
cloneUrl,
defaultBranch: repository.defaultBranch,
});
},
queryKey: [
"project",
repository.id,
"repo-snapshot",
repository.defaultBranch,
"none",
"none",
"no-tag",
"no-tag-commit",
],
retry: 1,
staleTime: 30_000,
})),
});
return repositories.map((repository, index) => {
const query = queries[index];
return {
error:
enabled && !repository.cloneUrls[0]
? new Error("Repository not found on the relay.")
: query?.error,
isLoading: query?.isLoading ?? false,
repository,
snapshot: query?.data ?? null,
};
});
}

View File

@@ -1,3 +1,5 @@
import type { ReactNode } from "react";
import type { ChannelVisibility } from "@/shared/api/types";
import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content";
import { Dialog } from "@/shared/ui/dialog";
@@ -17,6 +19,7 @@ type ChannelKind = "stream" | "forum";
type CreateChannelDialogProps = {
/** Which kind of channel to create, or null when closed. */
channelKind: ChannelKind | null;
children?: ReactNode;
description?: string;
isCreating: boolean;
onOpenChange: (open: boolean) => void;
@@ -33,6 +36,7 @@ type CreateChannelDialogProps = {
export function CreateChannelDialog({
channelKind,
children,
description,
isCreating,
onOpenChange,
@@ -80,6 +84,7 @@ export function CreateChannelDialog({
id={CREATE_CHANNEL_FORM_ID}
onSubmit={form.handleSubmit}
>
{children}
<CreateChannelFormFields form={form} />
</form>
</ChooserDialogContent>

View File

@@ -18,6 +18,11 @@ async function enableProjectsFeature(page: import("@playwright/test").Page) {
});
}
async function openCreateProjectDialog(page: import("@playwright/test").Page) {
await page.getByTestId("projects-section-projects").click();
await page.getByTestId("projects-overview-create-project").click();
}
async function addProjectToSidebar(
page: import("@playwright/test").Page,
dtag: string,
@@ -178,17 +183,14 @@ test("top-level project lists show metadata and overflow actions", async ({
await expect(
page.getByRole("button", { name: "Filter reviews" }),
).toHaveCount(0);
await page.getByTestId("projects-create-menu").hover();
await expect(page.getByRole("menuitem", { name: "Project" })).toBeVisible();
await expect(page.getByRole("menuitem", { name: "Task" })).toBeVisible();
await page.getByRole("menuitem", { name: "Review", exact: true }).click();
await page.getByTestId("projects-overview-create-pull-request").click();
await expect(page.getByTestId("create-pull-request-dialog")).toBeVisible();
await expect(
page.getByTestId("create-pull-request-repository"),
).toBeVisible();
await page.keyboard.press("Escape");
await page.getByTestId("projects-create-menu").hover();
await page.getByRole("menuitem", { name: "Task" }).click();
await page.getByRole("button", { name: "Tasks", exact: true }).click();
await page.getByTestId("projects-overview-create-issue").click();
await expect(page.getByTestId("create-issue-repository")).toBeVisible();
await page.keyboard.press("Escape");
const pullRequestRow = page
@@ -258,13 +260,16 @@ test("creating a project opens its channel conversation", async ({ page }) => {
await installMockBridge(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByTestId("open-projects-view").click();
await page.getByTestId("projects-create-menu").hover();
await page.getByRole("menuitem", { name: "Project" }).click();
await openCreateProjectDialog(page);
await page.getByTestId("create-project-name").fill("multi-repo-demo");
await page
.getByTestId("create-project-description")
.fill("A grouped project created through the desktop app.");
await expect(page.getByTestId("create-project-listing")).toHaveText("Listed");
await expect(page.getByTestId("create-project-template")).toHaveText(
"Project home",
);
await expect(page.getByTestId("create-project-team")).toHaveText("None");
await expect(page.getByTestId("create-project-agent")).toHaveText("None");
await page.getByTestId("create-project-submit").click();
@@ -298,6 +303,11 @@ test("creating a project opens its channel conversation", async ({ page }) => {
await page.keyboard.press("Escape");
await expect(page.getByTestId("add-project-repository-dialog")).toBeHidden();
await expect(page.getByTestId("project-home-summary-column")).toBeVisible();
await expect(
page
.getByTestId("project-home-summary-column")
.getByRole("heading", { name: "Overview", exact: true }),
).toHaveCount(0);
await expect(
page
.getByTestId("project-home-summary-column")
@@ -327,8 +337,40 @@ test("creating a project opens its channel conversation", async ({ page }) => {
await expect(
page.getByTestId("project-home-context-channel"),
).not.toContainText("people in this channel");
await expect(page.getByTestId("add-project-channel")).toBeVisible();
await expect(page.getByTestId("add-project-repository")).toBeVisible();
const channelSection = page.getByTestId("project-home-context-channel");
const channelSectionToggle = channelSection.getByRole("button", {
name: "Channels",
exact: true,
});
await channelSectionToggle.click();
await expect(
channelSection.getByTestId("project-home-context-home-channel"),
).toHaveCount(0);
await channelSectionToggle.click();
await expect(
channelSection.getByTestId("project-home-context-home-channel"),
).toBeVisible();
const codebaseSection = page.getByTestId("project-home-context-codebase");
const codebaseSectionToggle = codebaseSection.getByRole("button", {
name: "Codebase",
exact: true,
});
await codebaseSectionToggle.click();
await expect(
codebaseSection.getByTestId("project-home-context-repo-multi-repo-demo"),
).toHaveCount(0);
await codebaseSectionToggle.click();
const channelAction = page.getByTestId("add-project-channel").locator("..");
const repositoryAction = page
.getByTestId("add-project-repository")
.locator("..");
await expect(channelAction).toHaveCSS("opacity", "0");
await expect(repositoryAction).toHaveCSS("opacity", "0");
await page.getByTestId("project-home-context-channel").hover();
await expect(channelAction).toHaveCSS("opacity", "1");
await page.getByTestId("project-home-context-codebase").hover();
await expect(repositoryAction).toHaveCSS("opacity", "1");
await page.getByTestId("project-home-context-channel").hover();
await page.getByTestId("add-project-channel").click();
await expect(page.getByTestId("create-project-channel-dialog")).toBeVisible();
await page.keyboard.press("Escape");
@@ -419,8 +461,7 @@ test("creating a project opens its channel conversation", async ({ page }) => {
.getByTestId("project-detail-chrome")
.getByRole("button", { name: "Projects" })
.click();
await page.getByTestId("projects-create-menu").hover();
await page.getByRole("menuitem", { name: "Project" }).click();
await openCreateProjectDialog(page);
await page.getByTestId("create-project-name").fill("multi-repo-demo");
await page.getByTestId("create-project-submit").click();
await expect(page.getByTestId("create-project-dialog")).toBeVisible();
@@ -451,8 +492,7 @@ test("unsupported relays cannot create a channel-first project", async ({
await installMockBridge(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByTestId("open-projects-view").click();
await page.getByTestId("projects-create-menu").hover();
await page.getByRole("menuitem", { name: "Project" }).click();
await openCreateProjectDialog(page);
await page.getByTestId("create-project-name").fill("legacy-fallback");
await page.getByTestId("create-project-submit").click();
@@ -484,8 +524,7 @@ test("project creation can retry after its repository publication fails", async
await installMockBridge(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByTestId("open-projects-view").click();
await page.getByTestId("projects-create-menu").hover();
await page.getByRole("menuitem", { name: "Project" }).click();
await openCreateProjectDialog(page);
await page.getByTestId("create-project-name").fill("retry-project");
await page.getByTestId("create-project-submit").click();
@@ -510,8 +549,7 @@ test("project creation is idempotent after a lost publish acknowledgement", asyn
await installMockBridge(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByTestId("open-projects-view").click();
await page.getByTestId("projects-create-menu").hover();
await page.getByRole("menuitem", { name: "Project" }).click();
await openCreateProjectDialog(page);
await page.getByTestId("create-project-name").fill("lost-ack-project");
await page.getByTestId("create-project-submit").click();

View File

@@ -1166,6 +1166,10 @@ test("sidebar distinguishes the Projects overview from an open project", async (
await projectsOverview.click();
await expect(projectsOverview).toHaveAttribute("data-active", "true");
await expect(sidebarProject).toHaveAttribute("data-active", "false");
await expect(sidebarProject.locator("svg").first()).toHaveCSS(
"opacity",
"0.8",
);
await expect(sidebarProject.locator('[data-sidebar="menu-label"]')).toHaveCSS(
"opacity",
"0.8",
@@ -1728,18 +1732,13 @@ test("project overview presents collapsible context beside grouped activity", as
).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();
).toHaveCount(0);
await expect(page.getByTestId("projects-overview-stats-pod")).toBeVisible();
await expect(
page
.getByTestId("projects-overview-context-panel")
.getByTestId("projects-overview-people"),
).toBeVisible();
).toHaveCount(0);
await expect(
page
.getByTestId("projects-overview-stats-pod")
@@ -1803,17 +1802,12 @@ test("project overview presents collapsible context beside grouped activity", as
const stats = page.getByTestId("projects-overview-stat");
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,
);
@@ -1839,9 +1833,13 @@ test("project overview presents collapsible context beside grouped activity", as
await expect(page.getByTestId("projects-overview-context-title")).toHaveText(
"Channels",
);
await expect(page.getByTestId("projects-overview-add-channel")).toBeVisible();
await page.getByTestId("projects-overview-add-channel").click();
await expect(page.getByTestId("create-project-channel-dialog")).toBeVisible();
await expect(
page.getByTestId("projects-overview-create-project"),
).toHaveCount(0);
page.getByTestId("create-project-channel-project"),
).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByTestId("projects-overview-people")).toHaveCount(0);
await expect(page.getByTestId("projects-overview-activity")).toHaveCount(0);
await expect(stats).toHaveCount(3);
@@ -1895,15 +1893,21 @@ test("project overview presents collapsible context beside grouped activity", as
await expect(
page.getByTestId("projects-overview-create-project"),
).toBeVisible();
await expect(page.getByTestId("projects-overview-people")).toBeVisible();
await expect(page.getByTestId("projects-overview-people")).toHaveCount(0);
await expect(page.getByTestId("projects-overview-activity")).toHaveCount(0);
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);
page.getByTestId("projects-overview-add-repository"),
).toBeVisible();
await page.getByTestId("projects-overview-add-repository").click();
await expect(page.getByTestId("add-project-repository-dialog")).toBeVisible();
await expect(
page.getByTestId("add-project-repository-project"),
).toBeVisible();
await page.keyboard.press("Escape");
await expect(stats.nth(1)).toContainText("Active tasks");
await expect(page.getByTestId("projects-overview-activity")).toHaveCount(0);
await page.getByTestId("projects-section-issues").click();
@@ -1912,7 +1916,7 @@ test("project overview presents collapsible context beside grouped activity", as
);
await expect(
page.getByTestId("projects-overview-create-issue"),
).toContainText("Create task");
).toHaveAttribute("aria-label", "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(
@@ -1920,8 +1924,8 @@ test("project overview presents collapsible context beside grouped activity", as
);
await expect(
page.getByTestId("projects-overview-create-pull-request"),
).toContainText("Create review");
await expect(page.getByTestId("projects-overview-people")).toBeVisible();
).toHaveAttribute("aria-label", "Create review");
await expect(page.getByTestId("projects-overview-people")).toHaveCount(0);
await expect(page.getByTestId("projects-overview-activity")).toHaveCount(0);
await page.getByTestId("projects-section-all").click();
await expect(
@@ -1932,6 +1936,7 @@ test("project overview presents collapsible context beside grouped activity", as
);
await expect(page.getByTestId("projects-overview-activity")).toHaveCount(0);
await page.getByTestId("projects-section-projects").click();
await page.getByTestId("projects-overview-create-project").click();
await expect(page.getByTestId("create-project-dialog")).toBeVisible();
await page.keyboard.press("Escape");
@@ -1951,11 +1956,6 @@ test("project overview presents collapsible context beside grouped activity", as
);
await expect(stats).toHaveCount(5);
await expect(activityCards.first()).toBeVisible();
await expect(
page
.getByTestId("projects-workspace-chrome")
.getByTestId("projects-create-menu"),
).toHaveCount(0);
});
test("project overview chrome toggles a detached resizable agent chat", async ({
@@ -2148,12 +2148,6 @@ test("project overview info control animates the context rail", async ({
await expect(toggle).toHaveAttribute("aria-pressed", "false");
await expect(railPanel).toHaveCSS("transform", "none");
expect(await surfaceStyle()).toEqual(expandedSurfaceStyle);
await expect(
page
.getByTestId("projects-workspace-chrome")
.getByTestId("projects-create-menu"),
).toHaveCount(0);
await toggle.click();
await expect(rail).toHaveCSS("width", "288px");
await expect(toggle).toHaveAttribute("aria-pressed", "true");
@@ -2677,8 +2671,8 @@ test("selecting repository workspace rows switches the context pod to the cluste
expect(
Math.abs(reviewRowOrder.checkbox - reviewRowOrder.status),
).toBeLessThanOrEqual(1);
expect(reviewRowOrder.status).toBeLessThan(reviewRowOrder.title);
expect(reviewRowOrder.title).toBeLessThan(reviewRowOrder.identifier);
expect(reviewRowOrder.status).toBeLessThan(reviewRowOrder.identifier);
expect(reviewRowOrder.identifier).toBeLessThan(reviewRowOrder.title);
await reviewRows.first().getByTestId("projects-row-select").click();
await expect(
reviewRows.first().getByTestId("project-work-item-status-icon"),
@@ -2914,7 +2908,14 @@ test("project detail content areas do not paint background fills", async ({
}
};
for (const tab of ["Files", "Commits", "Tasks", "Review", "Contributors"]) {
for (const tab of [
"Overview",
"Files",
"Commits",
"Tasks",
"Review",
"Contributors",
]) {
await page.getByRole("tab", { name: tab, exact: true }).click();
await expectVisiblePanelsToBeTransparent();
}

View File

@@ -107,7 +107,7 @@ test("projects activity overview screenshot", async ({ page }) => {
).toBeVisible();
await expect(
page.getByTestId("projects-overview-create-project"),
).toBeVisible();
).toHaveCount(0);
await expect(
page.getByTestId("projects-activity-group").first(),
).toBeVisible();
@@ -170,6 +170,10 @@ test("sidebar project add flow browses before creating", async ({ page }) => {
page.getByTestId("create-project-channel-permissions"),
).toBeVisible();
await expect(page.getByTestId("create-project-listing")).toHaveText("Listed");
await expect(page.getByTestId("create-project-template")).toHaveText(
"Project home",
);
await expect(page.getByTestId("create-project-team")).toHaveText("None");
await expect(page.getByTestId("create-project-agent")).toHaveText("None");
await page.getByRole("button", { name: "Back to projects" }).click();
await expect(browser).toBeVisible();
@@ -292,6 +296,7 @@ test("projects v3 workspace screenshot states", async ({ page }) => {
// Borderless workspace: repository controls live in the persistent,
// resizable auxiliary panel instead of a header row.
const backButton = page.getByTestId("project-workspace-back");
const overviewTab = page.getByRole("tab", { name: "Overview" });
const filesTab = page.getByRole("tab", { name: "Files" });
const channelsTab = page.getByRole("tab", { name: "Channels" });
const contributorsTab = page.getByRole("tab", { name: "Contributors" });
@@ -324,7 +329,11 @@ test("projects v3 workspace screenshot states", async ({ page }) => {
(menuBox?.x ?? 0) + (menuBox?.width ?? 0),
);
};
await expect(overviewTab).toBeVisible();
await expect(filesTab).toBeVisible();
expect((await overviewTab.boundingBox())?.x).toBeLessThan(
(await filesTab.boundingBox())?.x ?? 0,
);
await expect(backButton).toBeVisible();
await expect(page.getByTestId("app-sidebar")).toBeVisible();
await expect(projectDetailScroll).toHaveCSS("overscroll-behavior-y", "none");
@@ -1022,12 +1031,12 @@ test("projects v3 workspace screenshot states", async ({ page }) => {
).toBe(4);
expect(
Math.round((firstIssueBounds?.x ?? 0) - (issueHeaderBounds?.x ?? 0)),
).toBe(8);
).toBe(0);
expect(
Math.round(
(issueHeaderBounds?.width ?? 0) - (firstIssueBounds?.width ?? 0),
),
).toBe(16);
).toBe(0);
expect(
(secondIssueBounds?.y ?? 0) -
((firstIssueBounds?.y ?? 0) + (firstIssueBounds?.height ?? 0)),
@@ -1139,12 +1148,12 @@ test("projects v3 workspace screenshot states", async ({ page }) => {
).toBe(4);
expect(
Math.round((firstReviewBounds?.x ?? 0) - (reviewHeaderBounds?.x ?? 0)),
).toBe(8);
).toBe(0);
expect(
Math.round(
(reviewHeaderBounds?.width ?? 0) - (firstReviewBounds?.width ?? 0),
),
).toBe(16);
).toBe(0);
expect(
(secondReviewBounds?.y ?? 0) -
((firstReviewBounds?.y ?? 0) + (firstReviewBounds?.height ?? 0)),