mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 10:26:26 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The board UI is the main daily navigation surface for agents, projects, and their related resources. > - Operators need a lightweight way to keep frequently used agents and projects close without changing company-wide ordering or ownership. > - Resource memberships already model per-user relationships to projects and agents, so they are the right place to store user-specific starred state. > - This pull request extends that membership contract with a starred timestamp and exposes star controls in list/detail views. > - The sidebar then uses those starred memberships to show compact, user-specific shortcuts. > - The benefit is faster navigation without introducing a separate favorites system or leaking preferences across users. ## Linked Issues or Issue Description No public GitHub issue exists. Feature request: ## Problem or motivation Users cannot pin frequently used agents or projects into the main sidebar. Returning to important resources requires scanning full project/agent lists or navigating through detail pages, which adds friction to repeated daily workflows. ## Proposed solution Store a per-user `starred_at` timestamp on agent and project memberships, expose API actions to set or clear that state, add star toggle controls to list/detail pages, and render starred projects and agents as compact sidebar shortcuts. ## Alternatives considered A separate favorites table would work, but it would duplicate membership scoping and require another resource relationship model. Keeping starred state on memberships preserves existing company/user boundaries and avoids a second source of truth. ## Roadmap alignment Checked `ROADMAP.md`; no overlapping planned core work for starred resource/sidebar navigation was found. ## Additional context The affected subsystems are `packages/db`, `packages/shared`, `server/`, and `ui/`. The migration is idempotent with `IF NOT EXISTS` guards so environments that saw an earlier local migration name can still apply the final ordered migration safely. ## What Changed - Added idempotent migration `0133_resource_membership_stars` for `starred_at` columns and lookup indexes on agent/project memberships. - Extended shared resource membership types and validators with starred metadata and actions. - Updated server resource membership services/routes to read and mutate starred resource state. - Added reusable star toggle UI and resource membership hook support for starred state. - Added starred projects and agents sidebar rendering, plus star controls on list and detail pages. - Added focused shared, server, and UI coverage for starred membership behavior and sidebar rendering. ## Verification - Rebased and force-with-lease pushed current PR head `a086fc965391c9e50a51b5b83b5b44a797b2a6f4` onto current `paperclipai/paperclip:master`; `gh pr view` reports `MERGEABLE` with no merge conflicts. GitHub checks are green for this fresh head. - `pnpm exec vitest run packages/shared/src/resource-memberships.test.ts server/src/__tests__/resource-memberships-routes.test.ts server/src/__tests__/workspace-runtime.test.ts ui/src/components/Sidebar.test.tsx ui/src/components/SidebarAgents.test.tsx ui/src/components/SidebarStarredProjects.test.tsx ui/src/components/StarToggle.test.tsx ui/src/pages/InstanceExperimentalSettings.test.tsx` passed after the rebase: 8 files, 143 tests. - Greptile re-review is 5/5; the remaining screenshot thread was resolved as non-blocking because this task explicitly requested no screenshots/images in the PR. - `pnpm exec vitest run ui/src/components/SidebarStarredProjects.test.tsx` passed after the mobile pending-spinner fix. - `pnpm exec vitest run packages/shared/src/resource-memberships.test.ts server/src/__tests__/resource-memberships-routes.test.ts ui/src/components/Sidebar.test.tsx ui/src/components/SidebarAgents.test.tsx ui/src/components/SidebarStarredProjects.test.tsx ui/src/components/StarToggle.test.tsx ui/src/pages/InstanceExperimentalSettings.test.tsx` passed: 7 files, 68 tests. - `pnpm --filter @paperclipai/db typecheck && pnpm --filter @paperclipai/shared typecheck && pnpm --filter @paperclipai/server typecheck && pnpm --filter @paperclipai/ui typecheck` passed db/shared/server, then failed in pre-existing UI code outside this PR: `src/pages/CompanyEnvironments.tsx` missing `@xterm/*` type declarations and `previous` possibly null. - Checked that the PR diff does not include `pnpm-lock.yaml` or `.github/workflows` changes. - Checked `ROADMAP.md` and found no overlapping planned core work for starred resource/sidebar navigation. - Searched existing GitHub PRs for duplicate starred-resource/sidebar work and found none. ## Risks - Migration touches membership tables. The SQL uses `IF NOT EXISTS` for columns and indexes so environments that saw an earlier local migration name can still apply this safely. - Sidebar ordering and visibility changes could affect users who rely on the previous flat sidebar layout. - Starred state is per-user membership metadata; code paths must continue preserving company/user scoping around memberships. > 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, tool-enabled coding agent with shell/GitHub access. Context window not disclosed by the 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 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 not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [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>
192 lines
6.8 KiB
TypeScript
192 lines
6.8 KiB
TypeScript
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;
|
|
/** Join / leave transition. Omit to only change the starred flag. */
|
|
state?: ResourceMembershipState;
|
|
/** Star / unstar transition. Omit to only change join/leave state. */
|
|
starred?: boolean;
|
|
};
|
|
|
|
function emptyMemberships(): ResourceMemberships {
|
|
return {
|
|
projectMemberships: {},
|
|
agentMemberships: {},
|
|
starredProjectIds: [],
|
|
starredAgentIds: [],
|
|
projectStarredAt: {},
|
|
agentStarredAt: {},
|
|
updatedAt: null,
|
|
};
|
|
}
|
|
|
|
function starKeys(resourceType: ResourceMembershipResourceType) {
|
|
return resourceType === "project"
|
|
? { ids: "starredProjectIds", at: "projectStarredAt", state: "projectMemberships" }
|
|
: { ids: "starredAgentIds", at: "agentStarredAt", state: "agentMemberships" };
|
|
}
|
|
|
|
/**
|
|
* Apply an optimistic membership change to the cached memberships snapshot.
|
|
* Mirrors the server rules (see server/src/services/resource-memberships.ts):
|
|
* - starred=true always implies joined and stamps starredAt,
|
|
* - starred=false clears the star but leaves join/leave state alone,
|
|
* - state="left" clears any star (you cannot star a left resource).
|
|
*/
|
|
function applyMembershipChange(
|
|
current: ResourceMemberships | undefined,
|
|
resourceType: ResourceMembershipResourceType,
|
|
resourceId: string,
|
|
change: { state?: ResourceMembershipState; starred?: boolean },
|
|
): ResourceMemberships {
|
|
const base = current ?? emptyMemberships();
|
|
const keys = starKeys(resourceType);
|
|
|
|
// Resolve next join/leave state (starring implies joined).
|
|
const currentStateMap = base[keys.state as "projectMemberships"] ?? {};
|
|
const previousState: ResourceMembershipState =
|
|
currentStateMap[resourceId] === "left" ? "left" : "joined";
|
|
const nextState: ResourceMembershipState =
|
|
change.starred === true ? "joined" : change.state ?? previousState;
|
|
|
|
// Resolve next starred set.
|
|
const currentStarredIds = base[keys.ids as "starredProjectIds"] ?? [];
|
|
const nextStarredAt = { ...(base[keys.at as "projectStarredAt"] ?? {}) };
|
|
const previouslyStarred = currentStarredIds.includes(resourceId);
|
|
const nextStarred =
|
|
nextState === "left"
|
|
? false
|
|
: change.starred === true
|
|
? true
|
|
: change.starred === false
|
|
? false
|
|
: previouslyStarred;
|
|
|
|
let starredIds = currentStarredIds;
|
|
if (nextStarred && !previouslyStarred) {
|
|
// Newest star sorts first, matching the server's starredAt DESC ordering.
|
|
starredIds = [resourceId, ...currentStarredIds];
|
|
nextStarredAt[resourceId] = new Date();
|
|
} else if (!nextStarred && previouslyStarred) {
|
|
starredIds = currentStarredIds.filter((id) => id !== resourceId);
|
|
delete nextStarredAt[resourceId];
|
|
}
|
|
|
|
return {
|
|
...base,
|
|
[keys.state]: {
|
|
...currentStateMap,
|
|
[resourceId]: nextState,
|
|
},
|
|
[keys.ids]: starredIds,
|
|
[keys.at]: nextStarredAt,
|
|
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";
|
|
}
|
|
|
|
/** Whether the current viewer has starred this resource (navigation preference). */
|
|
export function isStarred(
|
|
memberships: ResourceMemberships | undefined,
|
|
resourceType: ResourceMembershipResourceType,
|
|
resourceId: string,
|
|
): boolean {
|
|
const ids = resourceType === "project"
|
|
? memberships?.starredProjectIds
|
|
: memberships?.starredAgentIds;
|
|
return Array.isArray(ids) && ids.includes(resourceId);
|
|
}
|
|
|
|
/** Ordered starred ids (server returns starredAt DESC; falls back to empty). */
|
|
export function starredResourceIds(
|
|
memberships: ResourceMemberships | undefined,
|
|
resourceType: ResourceMembershipResourceType,
|
|
): string[] {
|
|
const ids = resourceType === "project"
|
|
? memberships?.starredProjectIds
|
|
: memberships?.starredAgentIds;
|
|
return Array.isArray(ids) ? ids : [];
|
|
}
|
|
|
|
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.");
|
|
const body = { state: variables.state, starred: variables.starred };
|
|
return variables.resourceType === "project"
|
|
? resourceMembershipsApi.updateProject(companyId, variables.resourceId, body)
|
|
: resourceMembershipsApi.updateAgent(companyId, variables.resourceId, body);
|
|
},
|
|
onMutate: async (variables) => {
|
|
await queryClient.cancelQueries({ queryKey });
|
|
const previous = queryClient.getQueryData<ResourceMemberships>(queryKey);
|
|
queryClient.setQueryData<ResourceMemberships>(
|
|
queryKey,
|
|
applyMembershipChange(previous, variables.resourceType, variables.resourceId, {
|
|
state: variables.state,
|
|
starred: variables.starred,
|
|
}),
|
|
);
|
|
return { previous };
|
|
},
|
|
onError: (error, variables, context) => {
|
|
if (context?.previous) {
|
|
queryClient.setQueryData(queryKey, context.previous);
|
|
}
|
|
const verb = variables.starred !== undefined
|
|
? variables.starred ? "star" : "unstar"
|
|
: 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,
|
|
// Loose null-check: a missing or null starredAt both mean "not starred".
|
|
(current) => applyMembershipChange(current, variables.resourceType, result.resourceId, {
|
|
state: result.state,
|
|
starred: result.starredAt != null,
|
|
}),
|
|
);
|
|
},
|
|
onSettled: () => {
|
|
queryClient.invalidateQueries({ queryKey });
|
|
},
|
|
});
|
|
}
|