[codex] Add resource membership controls (#6677)
Release / publish_stable (push) Has been skipped
Release / verify_stable (push) Has been skipped
Release / preview_stable (push) Has been skipped
Refresh Lockfile / refresh (push) Successful in 48s
Docker / build-and-push (push) Failing after 2m20s
Release / verify_canary (push) Failing after 6m5s
Release / publish_canary (push) Has been skipped

## Thinking Path

> - Paperclip orchestrates AI-agent companies through company-scoped
issues, projects, agents, and board-visible workflows.
> - The board sidebar and project list are the daily navigation surface
for that control plane.
> - Users need to keep all projects and agents accessible while hiding
resources they have intentionally left from their own sidebar.
> - That requires user-scoped resource membership state backed by
company-scoped API and database contracts.
> - The branch also needed to preserve HTTP worktree login sessions and
keep the project list easier to scan after membership grouping.
> - This pull request adds resource membership controls, sidebar leave
actions, grouped/sortable project listings, and focused tests.
> - The benefit is a cleaner personal workspace view without weakening
company-scoped access to the underlying project or agent detail pages.

## What Changed

- Added `project_memberships` and `agent_memberships` tables with
API/shared/server contracts for current-user join/leave state.
- Renumbered the membership migration to `0090_resource_memberships`
after rebasing onto current `master`, and made it idempotent for anyone
who had applied the old branch-local `0087` migration.
- Added project and agent sidebar leave actions, plus list filtering
that waits for membership state before hiding resources.
- Added grouped project listing, project sorting controls, and reserved
row subtitle height for cleaner scanning.
- Fixed HTTP auth cookie security handling so HTTP worktree sessions can
persist.
- Updated focused server and UI tests for the new membership, sidebar,
project list, and auth behavior.

## Verification

- `pnpm exec vitest run server/src/__tests__/better-auth.test.ts
server/src/__tests__/resource-memberships-routes.test.ts
ui/src/pages/Projects.test.tsx
ui/src/components/SidebarProjects.test.tsx
ui/src/components/SidebarAgents.test.tsx
ui/src/components/MembershipAction.test.tsx
ui/src/components/EntityRow.test.tsx`
- Confirmed the branch is rebased on current `origin/master`.
- Confirmed the PR diff does not include `pnpm-lock.yaml` or
`.github/workflows` changes.

## Risks

- Migration safety: low to medium. The migration now uses `IF NOT
EXISTS` / guarded constraints and is numbered after current master
migrations, but it should still get CI coverage against fresh databases.
- UI behavior: low. Left resources are hidden from sidebar only after
membership state loads; direct detail access remains available.
- Auth behavior: low. Cookie security is relaxed only for HTTP/private
local-style origins where secure cookies would prevent login
persistence.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI GPT-5 Codex coding agent, tool-enabled shell/git workflow,
context window not exposed by runtime.

## 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 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] I will address all Greptile and reviewer comments before
requesting merge

Screenshot note: no browser screenshots were captured in this heartbeat;
the UI changes are covered by focused component tests above.

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta
2026-05-25 13:12:41 -05:00
committed by GitHub
parent 60efa38f86
commit 9aea3e3d35
42 changed files with 20241 additions and 201 deletions
+170 -80
View File
@@ -8,63 +8,67 @@ import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
import { EntityRow } from "../components/EntityRow";
import { StatusBadge } from "../components/StatusBadge";
import { MembershipAction } from "../components/MembershipAction";
import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton";
import { formatDate, projectUrl } from "../lib/utils";
import {
resourceMembershipState,
useResourceMembershipMutation,
useResourceMemberships,
} from "../hooks/useResourceMemberships";
import { Button } from "@/components/ui/button";
import { ArrowUpDown, Hexagon, Plus } from "lucide-react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { ArrowUpDown, Check, Hexagon, Plus } from "lucide-react";
type ProjectSortMode = "name" | "updated" | "targetDate" | "status";
type ProjectSortField = "name" | "updated" | "created" | "targetDate";
type ProjectSortDir = "asc" | "desc";
const PROJECT_SORT_LABELS: Record<ProjectSortMode, string> = {
name: "Name",
updated: "Recently updated",
targetDate: "Target date",
status: "Status",
};
const PROJECT_SORT_OPTIONS: Array<{ field: ProjectSortField; label: string }> = [
{ field: "name", label: "Name" },
{ field: "updated", label: "Updated" },
{ field: "created", label: "Created" },
{ field: "targetDate", label: "Target date" },
];
const PROJECT_STATUS_RANK: Record<Project["status"], number> = {
in_progress: 0,
planned: 1,
backlog: 2,
completed: 3,
cancelled: 4,
};
function projectTime(project: Project, field: "createdAt" | "updatedAt"): number {
const value = project[field];
const time = value instanceof Date ? value.getTime() : new Date(value).getTime();
return Number.isFinite(time) ? time : 0;
}
function compareProjectNames(left: Project, right: Project): number {
function compareProjectNames(left: Project, right: Project) {
const nameDiff = left.name.localeCompare(right.name, undefined, { sensitivity: "base" });
return nameDiff !== 0 ? nameDiff : left.id.localeCompare(right.id);
}
function compareTargetDates(left: Project, right: Project): number {
if (!left.targetDate && !right.targetDate) return compareProjectNames(left, right);
if (!left.targetDate) return 1;
if (!right.targetDate) return -1;
const dateDiff = left.targetDate.localeCompare(right.targetDate);
return dateDiff !== 0 ? dateDiff : compareProjectNames(left, right);
function projectTime(value: Date | string | null | undefined): number | null {
if (!value) return null;
const time = new Date(value).getTime();
return Number.isFinite(time) ? time : null;
}
function sortProjects(projects: Project[], sortMode: ProjectSortMode): Project[] {
function compareOptionalTime(
left: Date | string | null | undefined,
right: Date | string | null | undefined,
sortDir: ProjectSortDir,
) {
const leftTime = projectTime(left);
const rightTime = projectTime(right);
if (leftTime === null && rightTime === null) return 0;
if (leftTime === null) return 1;
if (rightTime === null) return -1;
return sortDir === "asc" ? leftTime - rightTime : rightTime - leftTime;
}
function sortProjects(projects: Project[], sortField: ProjectSortField, sortDir: ProjectSortDir) {
return [...projects].sort((left, right) => {
if (sortMode === "updated") {
const updatedDiff = projectTime(right, "updatedAt") - projectTime(left, "updatedAt");
return updatedDiff !== 0 ? updatedDiff : compareProjectNames(left, right);
let comparison = 0;
if (sortField === "name") {
comparison = compareProjectNames(left, right);
return sortDir === "asc" ? comparison : -comparison;
}
if (sortMode === "targetDate") {
return compareTargetDates(left, right);
}
if (sortMode === "status") {
const statusDiff = PROJECT_STATUS_RANK[left.status] - PROJECT_STATUS_RANK[right.status];
return statusDiff !== 0 ? statusDiff : compareProjectNames(left, right);
}
return compareProjectNames(left, right);
if (sortField === "updated") comparison = compareOptionalTime(left.updatedAt, right.updatedAt, sortDir);
else if (sortField === "created") comparison = compareOptionalTime(left.createdAt, right.createdAt, sortDir);
else comparison = compareOptionalTime(left.targetDate, right.targetDate, sortDir);
if (comparison === 0) comparison = compareProjectNames(left, right);
return comparison;
});
}
@@ -72,7 +76,8 @@ export function Projects() {
const { selectedCompanyId } = useCompany();
const { openNewProject } = useDialogActions();
const { setBreadcrumbs } = useBreadcrumbs();
const [sortMode, setSortMode] = useState<ProjectSortMode>("name");
const [sortField, setSortField] = useState<ProjectSortField>("name");
const [sortDir, setSortDir] = useState<ProjectSortDir>("asc");
useEffect(() => {
setBreadcrumbs([{ label: "Projects" }]);
@@ -83,14 +88,31 @@ export function Projects() {
queryFn: () => projectsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const membershipsQuery = useResourceMemberships(selectedCompanyId);
const membershipMutation = useResourceMembershipMutation(selectedCompanyId);
const projects = useMemo(
() => (allProjects ?? []).filter((p) => !p.archivedAt),
[allProjects],
);
const sortedProjects = useMemo(
() => sortProjects(projects, sortMode),
[projects, sortMode],
() => sortProjects(projects, sortField, sortDir),
[projects, sortDir, sortField],
);
const groupedProjects = useMemo(() => {
const groups = {
mine: [] as typeof sortedProjects,
other: [] as typeof sortedProjects,
};
for (const project of sortedProjects) {
const state = resourceMembershipState(membershipsQuery.data, "project", project.id);
if (state === "left") groups.other.push(project);
else groups.mine.push(project);
}
return groups;
}, [membershipsQuery.data, sortedProjects]);
const sortLabel = PROJECT_SORT_OPTIONS.find((option) => option.field === sortField)?.label ?? "Name";
if (!selectedCompanyId) {
return <EmptyState icon={Hexagon} message="Select a company to view projects." />;
@@ -102,22 +124,46 @@ export function Projects() {
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<label className="flex items-center gap-2 text-sm text-muted-foreground">
<ArrowUpDown className="h-4 w-4" />
<span>Sort</span>
<select
className="rounded-md border border-border bg-background px-2.5 py-1.5 text-sm text-foreground outline-none transition-colors hover:bg-accent/50 focus-visible:ring-2 focus-visible:ring-ring"
value={sortMode}
onChange={(event) => setSortMode(event.target.value as ProjectSortMode)}
>
{(Object.keys(PROJECT_SORT_LABELS) as ProjectSortMode[]).map((value) => (
<option key={value} value={value}>
{PROJECT_SORT_LABELS[value]}
</option>
))}
</select>
</label>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="w-fit text-xs" title="Sort">
<ArrowUpDown className="h-3.5 w-3.5 sm:h-3 sm:w-3 sm:mr-1" />
<span>Sort: {sortLabel}</span>
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-44 p-0">
<div className="p-2 space-y-0.5">
{PROJECT_SORT_OPTIONS.map((option) => (
<button
key={option.field}
type="button"
className={`flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-sm ${
sortField === option.field
? "bg-accent/50 text-foreground"
: "text-muted-foreground hover:bg-accent/50"
}`}
onClick={() => {
if (sortField === option.field) {
setSortDir((current) => (current === "asc" ? "desc" : "asc"));
return;
}
setSortField(option.field);
setSortDir(option.field === "name" || option.field === "targetDate" ? "asc" : "desc");
}}
>
<span>{option.label}</span>
{sortField === option.field ? (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Check className="h-3 w-3" />
{sortDir === "asc" ? "Asc" : "Desc"}
</span>
) : null}
</button>
))}
</div>
</PopoverContent>
</Popover>
<Button size="sm" variant="outline" onClick={openNewProject}>
<Plus className="h-4 w-4 mr-1" />
Add Project
@@ -136,26 +182,70 @@ export function Projects() {
)}
{projects.length > 0 && (
<div className="border border-border">
{sortedProjects.map((project) => (
<EntityRow
key={project.id}
title={project.name}
subtitle={project.description ?? undefined}
reserveSubtitleSpace
to={projectUrl(project)}
trailing={
<div className="flex items-center gap-3">
{project.targetDate && (
<span className="text-xs text-muted-foreground">
{formatDate(project.targetDate)}
</span>
)}
<StatusBadge status={project.status} />
<div className="space-y-6">
{([
["My Projects", groupedProjects.mine],
["Other Projects", groupedProjects.other],
] as const).map(([label, sectionProjects]) => {
if (sectionProjects.length === 0) return null;
return (
<section key={label} className="space-y-2">
<div className="flex items-center justify-between">
<h2 className="text-sm font-medium">{label}</h2>
<span className="text-xs text-muted-foreground">
{sectionProjects.length} project{sectionProjects.length === 1 ? "" : "s"}
</span>
</div>
}
/>
))}
<div className="border border-border">
{sectionProjects.map((project) => {
const state = resourceMembershipState(membershipsQuery.data, "project", project.id);
const pending = membershipMutation.isPending &&
membershipMutation.variables?.resourceType === "project" &&
membershipMutation.variables.resourceId === project.id;
return (
<EntityRow
key={project.id}
title={project.name}
subtitle={project.description ?? undefined}
reserveSubtitleSpace
to={projectUrl(project)}
className={state === "left" ? "group text-foreground/55" : "group"}
trailing={
<div className="flex items-center gap-3">
{project.targetDate && (
<span className="text-xs text-muted-foreground">
{formatDate(project.targetDate)}
</span>
)}
<StatusBadge status={project.status} />
<MembershipAction
state={state}
pending={pending}
pendingState={pending ? membershipMutation.variables?.state : null}
resourceName={project.name}
onJoin={() => membershipMutation.mutate({
resourceType: "project",
resourceId: project.id,
resourceName: project.name,
state: "joined",
})}
onLeave={() => membershipMutation.mutate({
resourceType: "project",
resourceId: project.id,
resourceName: project.name,
state: "left",
})}
/>
</div>
}
/>
);
})}
</div>
</section>
);
})}
</div>
)}
</div>