Files
paperclip/ui/src/components/NewProjectDialog.tsx
T
scotttong eaef47f4c7 Information Architecture + project/agent visual refresh (experimental) (#7543)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The board UI is the control surface for issues, projects, agents,
goals, workspaces, and operator settings.
> - The existing navigation and list surfaces make several
high-frequency workflows feel harder to scan than they should,
especially around projects and agents.
> - The product direction is to improve those surfaces without breaking
the existing route model or forcing a new IA on every operator at once.
> - This pull request now keeps the dependent IA, project identity, and
agent-list visual refresh work together while the Issue-to-Task copy
migration is split into #7651.
> - The benefit is a clearer left nav, better project identity, denser
agent/project list rows, and brand-aligned status treatment while
preserving the classic default experience behind a flag.

## Linked Issues or Issue Description

Refs #7645
Refs #7651

Internal planning/work references: PAP-53, PAP-56, PAP-58, PAP-59,
PAP-60, PAP-61, PAP-68, PAP-69, PAP-70, PAP-71, PAP-72, PAP-75, PAP-76,
PAP-80, PAP-85, PAP-86, PAP-87, PAP-88, PAP-89.

## What Changed

- Adds `enableStreamlinedLeftNavigation`, defaulting off, and gates
sidebar presentation so classic navigation remains the default.
- Adds project icon persistence, validation, portability, picker UI, and
`ProjectTile` rendering while defaulting new projects to neutral gray.
- Adds projects-list task-count and budget summary data with focused
server/shared/UI coverage.
- Refreshes agent list rows, row actions, active/recent sidebar
behavior, and status capsule/chip styling for the approved brand state
system.
- Removes the placeholder Conference room and Artifacts nav/routes from
the finalized experimental nav direction.
- Removes `pnpm-lock.yaml` and the Issue-to-Task copy migration from
this PR diff; the copy migration now lives in #7651.

## Verification

- Existing branch verification from the authored commits: UI typecheck,
targeted unit tests, and light/dark visual checks for `/agents`, agent
detail, and design-guide status states.
- Maintainer cleanup verification on `75e34e5`: `git diff --check
origin/master...HEAD` passed, the `design/` diff is empty, and the PR
diff is 61 files, below Greptile's 100-file review limit.
- `pnpm --filter @paperclipai/ui build` passed.
- `NODE_ENV=test pnpm exec vitest run
ui/src/components/Sidebar.test.tsx` passed: 1 file, 8 tests.
- CI and Greptile should rerun on the latest push.

## Risks

- Broad UI surface area: the experimental flag keeps the classic nav
default, but changed shared components such as `EntityRow`,
`ProjectTile`, and agent status badges could affect multiple pages.
- Database migration: `projects.icon` is additive and nullable, but
migration ordering and portability import/export must stay aligned.
- The Issue-to-Task copy migration is now separated into #7651, so
reviewers should evaluate this PR as IA/project/agent presentation work
only.
- Visual regressions are possible across smaller widths because the PR
intentionally changes dense list-row layouts.

## Model Used

Claude Opus 4.8 assisted the original feature commits.
Paperclip-Paperclip agents assisted some planning/design commits. Codex
/ GPT-5-class coding agent with shell, GitHub CLI, and repository access
performed this PR-readiness cleanup and split.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Dotta <bippadotta@protonmail.com>
2026-06-06 09:17:27 -05:00

447 lines
16 KiB
TypeScript

import { useMemo, useRef, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useDialog } from "../context/DialogContext";
import { useCompany } from "../context/CompanyContext";
import { accessApi } from "../api/access";
import { projectsApi } from "../api/projects";
import { agentsApi } from "../api/agents";
import { goalsApi } from "../api/goals";
import { assetsApi } from "../api/assets";
import { buildMarkdownMentionOptions } from "../lib/company-members";
import { queryKeys } from "../lib/queryKeys";
import {
Dialog,
DialogContent,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Maximize2,
Minimize2,
Target,
Calendar,
Plus,
X,
HelpCircle,
} from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "../lib/utils";
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
import { StatusBadge } from "./StatusBadge";
import { ChoosePathButton } from "./PathInstructionsModal";
const projectStatuses = [
{ value: "backlog", label: "Backlog" },
{ value: "planned", label: "Planned" },
{ value: "in_progress", label: "In Progress" },
{ value: "completed", label: "Completed" },
{ value: "cancelled", label: "Cancelled" },
];
export function NewProjectDialog() {
const { newProjectOpen, closeNewProject } = useDialog();
const { selectedCompanyId, selectedCompany } = useCompany();
const queryClient = useQueryClient();
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [status, setStatus] = useState("planned");
const [goalIds, setGoalIds] = useState<string[]>([]);
const [targetDate, setTargetDate] = useState("");
const [expanded, setExpanded] = useState(false);
const [workspaceLocalPath, setWorkspaceLocalPath] = useState("");
const [workspaceRepoUrl, setWorkspaceRepoUrl] = useState("");
const [workspaceError, setWorkspaceError] = useState<string | null>(null);
const [statusOpen, setStatusOpen] = useState(false);
const [goalOpen, setGoalOpen] = useState(false);
const descriptionEditorRef = useRef<MarkdownEditorRef>(null);
const { data: goals } = useQuery({
queryKey: queryKeys.goals.list(selectedCompanyId!),
queryFn: () => goalsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId && newProjectOpen,
});
const { data: agents } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!),
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId && newProjectOpen,
});
const { data: companyMembers } = useQuery({
queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId!),
queryFn: () => accessApi.listUserDirectory(selectedCompanyId!),
enabled: !!selectedCompanyId && newProjectOpen,
});
const mentionOptions = useMemo<MentionOption[]>(() => {
return buildMarkdownMentionOptions({
agents,
members: companyMembers?.users,
});
}, [agents, companyMembers?.users]);
const createProject = useMutation({
mutationFn: (data: Record<string, unknown>) =>
projectsApi.create(selectedCompanyId!, data),
});
const uploadDescriptionImage = useMutation({
mutationFn: async (file: File) => {
if (!selectedCompanyId) throw new Error("No company selected");
return assetsApi.uploadImage(selectedCompanyId, file, "projects/drafts");
},
});
function reset() {
setName("");
setDescription("");
setStatus("planned");
setGoalIds([]);
setTargetDate("");
setExpanded(false);
setWorkspaceLocalPath("");
setWorkspaceRepoUrl("");
setWorkspaceError(null);
}
const isAbsolutePath = (value: string) => value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value);
const looksLikeRepoUrl = (value: string) => {
try {
const parsed = new URL(value);
if (parsed.protocol !== "https:") return false;
const segments = parsed.pathname.split("/").filter(Boolean);
return segments.length >= 2;
} catch {
return false;
}
};
const deriveWorkspaceNameFromPath = (value: string) => {
const normalized = value.trim().replace(/[\\/]+$/, "");
const segments = normalized.split(/[\\/]/).filter(Boolean);
return segments[segments.length - 1] ?? "Local folder";
};
const deriveWorkspaceNameFromRepo = (value: string) => {
try {
const parsed = new URL(value);
const segments = parsed.pathname.split("/").filter(Boolean);
const repo = segments[segments.length - 1]?.replace(/\.git$/i, "") ?? "";
return repo || "GitHub repo";
} catch {
return "GitHub repo";
}
};
async function handleSubmit() {
if (!selectedCompanyId || !name.trim()) return;
const localPath = workspaceLocalPath.trim();
const repoUrl = workspaceRepoUrl.trim();
if (localPath && !isAbsolutePath(localPath)) {
setWorkspaceError("Local folder must be a full absolute path.");
return;
}
if (repoUrl && !looksLikeRepoUrl(repoUrl)) {
setWorkspaceError("Repo must use a valid GitHub or GitHub Enterprise repo URL.");
return;
}
setWorkspaceError(null);
try {
const created = await createProject.mutateAsync({
name: name.trim(),
description: description.trim() || undefined,
status,
// No color is sent — new projects persist color = null (neutral gray). See PAP-68.
...(goalIds.length > 0 ? { goalIds } : {}),
...(targetDate ? { targetDate } : {}),
});
if (localPath || repoUrl) {
const workspacePayload: Record<string, unknown> = {
name: localPath
? deriveWorkspaceNameFromPath(localPath)
: deriveWorkspaceNameFromRepo(repoUrl),
...(localPath ? { cwd: localPath } : {}),
...(repoUrl ? { repoUrl } : {}),
};
await projectsApi.createWorkspace(created.id, workspacePayload);
}
queryClient.invalidateQueries({ queryKey: queryKeys.projects.list(selectedCompanyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(created.id) });
reset();
closeNewProject();
} catch {
// surface through createProject.isError
}
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
handleSubmit();
}
}
const selectedGoals = (goals ?? []).filter((g) => goalIds.includes(g.id));
const availableGoals = (goals ?? []).filter((g) => !goalIds.includes(g.id));
return (
<Dialog
open={newProjectOpen}
onOpenChange={(open) => {
if (!open) {
reset();
closeNewProject();
}
}}
>
<DialogContent
showCloseButton={false}
className={cn("p-0 gap-0", expanded ? "sm:max-w-2xl" : "sm:max-w-lg")}
onKeyDown={handleKeyDown}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
{selectedCompany && (
<span className="bg-muted px-1.5 py-0.5 rounded text-xs font-medium">
{selectedCompany.name.slice(0, 3).toUpperCase()}
</span>
)}
<span className="text-muted-foreground/60">&rsaquo;</span>
<span>New project</span>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon-xs"
className="text-muted-foreground"
onClick={() => setExpanded(!expanded)}
>
{expanded ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
</Button>
<Button
variant="ghost"
size="icon-xs"
className="text-muted-foreground"
onClick={() => { reset(); closeNewProject(); }}
>
<span className="text-lg leading-none">&times;</span>
</Button>
</div>
</div>
{/* Name */}
<div className="px-4 pt-4 pb-2 shrink-0">
<input
className="w-full text-lg font-semibold bg-transparent outline-none placeholder:text-muted-foreground/50"
placeholder="Project name"
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Tab" && !e.shiftKey) {
e.preventDefault();
descriptionEditorRef.current?.focus();
}
}}
autoFocus
/>
</div>
{/* Description */}
<div className="px-4 pb-2">
<MarkdownEditor
ref={descriptionEditorRef}
value={description}
onChange={setDescription}
placeholder="Add description..."
bordered={false}
mentions={mentionOptions}
contentClassName={cn("text-sm text-muted-foreground", expanded ? "min-h-[220px]" : "min-h-[120px]")}
imageUploadHandler={async (file) => {
const asset = await uploadDescriptionImage.mutateAsync(file);
return asset.contentPath;
}}
/>
</div>
<div className="px-4 pt-3 pb-3 space-y-3 border-t border-border">
<div>
<div className="mb-1 flex items-center gap-1.5">
<label className="block text-xs text-muted-foreground">Repo URL</label>
<span className="text-xs text-muted-foreground/50">optional</span>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<HelpCircle className="h-3 w-3 text-muted-foreground/50 cursor-help" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[240px] text-xs">
Link a GitHub repository so agents can clone, read, and push code for this project.
</TooltipContent>
</Tooltip>
</div>
<input
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs outline-none"
value={workspaceRepoUrl}
onChange={(e) => { setWorkspaceRepoUrl(e.target.value); setWorkspaceError(null); }}
placeholder="https://github.com/org/repo"
/>
</div>
<div>
<div className="mb-1 flex items-center gap-1.5">
<label className="block text-xs text-muted-foreground">Local folder</label>
<span className="text-xs text-muted-foreground/50">optional</span>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<HelpCircle className="h-3 w-3 text-muted-foreground/50 cursor-help" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[240px] text-xs">
Set an absolute path on this machine where local agents will read and write files for this project.
</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center gap-2">
<input
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs font-mono outline-none"
value={workspaceLocalPath}
onChange={(e) => { setWorkspaceLocalPath(e.target.value); setWorkspaceError(null); }}
placeholder="/absolute/path/to/workspace"
/>
<ChoosePathButton />
</div>
</div>
{workspaceError && (
<p className="text-xs text-destructive">{workspaceError}</p>
)}
</div>
{/* Property chips */}
<div className="flex items-center gap-1.5 px-4 py-2 border-t border-border flex-wrap">
{/* Status */}
<Popover open={statusOpen} onOpenChange={setStatusOpen}>
<PopoverTrigger asChild>
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors">
<StatusBadge status={status} />
</button>
</PopoverTrigger>
<PopoverContent className="w-40 p-1" align="start">
{projectStatuses.map((s) => (
<button
key={s.value}
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
s.value === status && "bg-accent"
)}
onClick={() => { setStatus(s.value); setStatusOpen(false); }}
>
{s.label}
</button>
))}
</PopoverContent>
</Popover>
{selectedGoals.map((goal) => (
<span
key={goal.id}
className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-1 text-xs"
>
<Target className="h-3 w-3 text-muted-foreground" />
<span className="max-w-[160px] truncate">{goal.title}</span>
<button
className="text-muted-foreground hover:text-foreground"
onClick={() => setGoalIds((prev) => prev.filter((id) => id !== goal.id))}
aria-label={`Remove goal ${goal.title}`}
type="button"
>
<X className="h-3 w-3" />
</button>
</span>
))}
<Popover open={goalOpen} onOpenChange={setGoalOpen}>
<PopoverTrigger asChild>
<button
className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors disabled:opacity-60"
disabled={selectedGoals.length > 0 && availableGoals.length === 0}
>
{selectedGoals.length > 0 ? <Plus className="h-3 w-3 text-muted-foreground" /> : <Target className="h-3 w-3 text-muted-foreground" />}
{selectedGoals.length > 0 ? "+ Goal" : "Goal"}
</button>
</PopoverTrigger>
<PopoverContent className="w-56 p-1" align="start">
{selectedGoals.length === 0 && (
<button
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-muted-foreground"
onClick={() => setGoalOpen(false)}
>
No goal
</button>
)}
{availableGoals.map((g) => (
<button
key={g.id}
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 truncate"
onClick={() => {
setGoalIds((prev) => [...prev, g.id]);
setGoalOpen(false);
}}
>
{g.title}
</button>
))}
{selectedGoals.length > 0 && availableGoals.length === 0 && (
<div className="px-2 py-1.5 text-xs text-muted-foreground">
All goals already selected.
</div>
)}
</PopoverContent>
</Popover>
{/* Target date */}
<div className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs">
<Calendar className="h-3 w-3 text-muted-foreground" />
<input
type="date"
className="bg-transparent outline-none text-xs w-24"
value={targetDate}
onChange={(e) => setTargetDate(e.target.value)}
placeholder="Target date"
/>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-between px-4 py-2.5 border-t border-border">
{createProject.isError ? (
<p className="text-xs text-destructive">Failed to create project.</p>
) : (
<span />
)}
<Button
size="sm"
disabled={!name.trim() || createProject.isPending}
onClick={handleSubmit}
>
{createProject.isPending ? "Creating…" : "Create project"}
</Button>
</div>
</DialogContent>
</Dialog>
);
}