[codex] Add resource membership controls (#6677)

## 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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 20241 additions and 201 deletions

View file

@ -0,0 +1,114 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type {
ResourceMembershipResourceType,
ResourceMembershipState,
ResourceMemberships,
} from "@paperclipai/shared";
import { resourceMembershipsApi } from "../api/resourceMemberships";
import { useToastActions } from "../context/ToastContext";
import { queryKeys } from "../lib/queryKeys";
type MutationVariables = {
resourceType: ResourceMembershipResourceType;
resourceId: string;
resourceName: string;
state: ResourceMembershipState;
};
function emptyMemberships(): ResourceMemberships {
return {
projectMemberships: {},
agentMemberships: {},
updatedAt: null,
};
}
function applyMembershipState(
current: ResourceMemberships | undefined,
resourceType: ResourceMembershipResourceType,
resourceId: string,
state: ResourceMembershipState,
): ResourceMemberships {
const base = current ?? emptyMemberships();
if (resourceType === "project") {
return {
...base,
projectMemberships: {
...base.projectMemberships,
[resourceId]: state,
},
updatedAt: new Date(),
};
}
return {
...base,
agentMemberships: {
...base.agentMemberships,
[resourceId]: state,
},
updatedAt: new Date(),
};
}
export function resourceMembershipState(
memberships: ResourceMemberships | undefined,
resourceType: ResourceMembershipResourceType,
resourceId: string,
): ResourceMembershipState {
const state = resourceType === "project"
? memberships?.projectMemberships[resourceId]
: memberships?.agentMemberships[resourceId];
return state === "left" ? "left" : "joined";
}
export function useResourceMemberships(companyId: string | null | undefined) {
return useQuery({
queryKey: queryKeys.resourceMemberships.mine(companyId ?? "__none__"),
queryFn: () => resourceMembershipsApi.listMine(companyId!),
enabled: !!companyId,
});
}
export function useResourceMembershipMutation(companyId: string | null | undefined) {
const queryClient = useQueryClient();
const { pushToast } = useToastActions();
const queryKey = queryKeys.resourceMemberships.mine(companyId ?? "__none__");
return useMutation({
mutationFn: (variables: MutationVariables) => {
if (!companyId) throw new Error("Select a company first.");
return variables.resourceType === "project"
? resourceMembershipsApi.updateProject(companyId, variables.resourceId, { state: variables.state })
: resourceMembershipsApi.updateAgent(companyId, variables.resourceId, { state: variables.state });
},
onMutate: async (variables) => {
await queryClient.cancelQueries({ queryKey });
const previous = queryClient.getQueryData<ResourceMemberships>(queryKey);
queryClient.setQueryData<ResourceMemberships>(
queryKey,
applyMembershipState(previous, variables.resourceType, variables.resourceId, variables.state),
);
return { previous };
},
onError: (error, variables, context) => {
if (context?.previous) {
queryClient.setQueryData(queryKey, context.previous);
}
const verb = variables.state === "left" ? "leave" : "join";
pushToast({
title: `Couldn't ${verb} ${variables.resourceName}.`,
body: error instanceof Error ? error.message : "Try again.",
tone: "error",
});
},
onSuccess: (result, variables) => {
queryClient.setQueryData<ResourceMemberships>(
queryKey,
(current) => applyMembershipState(current, variables.resourceType, result.resourceId, result.state),
);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey });
},
});
}