mirror of
https://github.com/block/buzz.git
synced 2026-10-30 22:11:36 +00:00
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:
@@ -33,6 +33,7 @@ export type CreateProjectInput = {
|
||||
channelVisibility?: ChannelVisibility;
|
||||
projectVisibility?: ProjectListingVisibility;
|
||||
agents?: readonly CreateChannelManagedAgentInput[];
|
||||
templateId?: string;
|
||||
};
|
||||
|
||||
export type CreateProjectResult = {
|
||||
|
||||
@@ -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 -/);
|
||||
});
|
||||
101
desktop/src/features/projects/lib/projectHomeTemplate.ts
Normal file
101
desktop/src/features/projects/lib/projectHomeTemplate.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
89
desktop/src/features/projects/ui/ProjectHomeCommitsPanel.tsx
Normal file
89
desktop/src/features/projects/ui/ProjectHomeCommitsPanel.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
|
||||
Reference in New Issue
Block a user