diff --git a/doc/spec/invite-flow.md b/doc/spec/invite-flow.md index fad3c56e..ac71d79d 100644 --- a/doc/spec/invite-flow.md +++ b/doc/spec/invite-flow.md @@ -32,7 +32,7 @@ flowchart TD Accepted[Invite state: accepted] BootstrapDone[Bootstrap acceptedno join request] HumanReuse{Matching human join requestalready exists for same user/email?} - HumanPending[Join requestpending_approval] + HumanPending[Legacy human join requestpending_approval] HumanApproved[Join requestapproved] HumanRejected[Join requestrejected] AgentPending[Agent join requestpending_approval+ optional claim secret] @@ -52,12 +52,10 @@ flowchart TD BootstrapDone --> Accepted Active --> HumanReuse: human accept - HumanReuse --> HumanPending: reuse existing pending request - HumanReuse --> HumanApproved: reuse existing approved request - HumanReuse --> HumanPending: no reusable requestcreate new request - HumanPending --> HumanApproved: board approves + HumanReuse --> HumanApproved: reuse existing pending/approved requestensure active membership + HumanReuse --> HumanApproved: no reusable requestcreate and approve request + HumanPending --> HumanApproved: same invitee replays accepted inviteor board approves legacy request HumanPending --> HumanRejected: board rejects - HumanPending --> Accepted HumanApproved --> Accepted Active --> AgentPending: agent accept @@ -150,7 +148,8 @@ stateDiagram-v2 state AcceptedInviteSummary { [*] --> SummaryBranch - SummaryBranch --> PendingApprovalReload: joinRequestStatus=pending_approval + SummaryBranch --> AcceptPending: human joinRequestStatus=pending_approval/approvedand membership missing + SummaryBranch --> PendingApprovalReload: agent joinRequestStatus=pending_approval SummaryBranch --> OpeningCompany: joinRequestStatus=approvedand human invite user is now a member SummaryBranch --> RejectedReload: joinRequestStatus=rejected SummaryBranch --> ConsumedReload: approved agent invite or other consumed state @@ -177,6 +176,7 @@ sequenceDiagram participant Landing as Invite landing UI participant Auth as Auth session participant Join as join_requests table + participant Membership as company_memberships + grants Board->>Settings: Choose role and click Create invite Settings->>API: POST /api/companies/:companyId/invites @@ -197,15 +197,19 @@ sequenceDiagram API->>Join: Look for reusable human join request alt Reusable pending or approved request exists API->>Invites: Mark invite accepted - API-->>Landing: Existing join request status + API->>Membership: Ensure active membership and role grants + API->>Join: Mark join request approved if needed + API-->>Landing: approved join request else No reusable request exists API->>Invites: Mark invite accepted API->>Join: Insert pending_approval join request - API-->>Landing: New pending_approval join request + API->>Membership: Ensure active membership and role grants + API->>Join: Mark join request approved + API-->>Landing: approved join request end ``` -### Human Approval And Reload Path +### Legacy Human Reload And Repair Path ```mermaid sequenceDiagram @@ -214,8 +218,6 @@ sequenceDiagram participant Landing as Invite landing UI participant API as Access routes participant Join as join_requests table - actor Approver as Company admin - participant Queue as Access queue UI participant Membership as company_memberships + grants Invitee->>Landing: Reload consumed invite URL @@ -223,20 +225,15 @@ sequenceDiagram API->>Join: Load join request by inviteId API-->>Landing: joinRequestStatus + joinRequestType - alt joinRequestStatus = pending_approval - Landing-->>Invitee: Show waiting-for-approval panel - Approver->>Queue: Review request in Company Settings -> Access - Queue->>API: POST /companies/:companyId/join-requests/:requestId/approve - API->>Membership: Ensure membership and grants - API->>Join: Mark join request approved - Invitee->>Landing: Refresh after approval - Landing->>API: GET /api/invites/:token - API->>Join: Reload approved join request + alt human joinRequestStatus = pending_approval or approved but membership missing + Landing->>API: POST /api/invites/:token/accept (requestType=human) + API->>Membership: Ensure active membership and role grants + API->>Join: Mark join request approved if needed API-->>Landing: approved status Landing-->>Invitee: Opening company and redirect else joinRequestStatus = rejected Landing-->>Invitee: Show rejected error panel - else joinRequestStatus = approved but membership missing + else agent invite or unavailable consumed state Landing-->>Invitee: Fall through to consumed/unavailable state end ``` @@ -286,14 +283,15 @@ sequenceDiagram ## Notes - `GET /api/invites/:token` treats `revoked` and `expired` invites as unavailable. Accepted invites remain resolvable when they already have a linked join request, and the summary now includes `joinRequestStatus` plus `joinRequestType`. -- Human acceptance consumes the invite immediately and then either creates a new join request or reuses an existing `pending_approval` or `approved` human join request for the same user/email. +- Human acceptance consumes the invite, creates or reuses the matching human join request, immediately marks it `approved`, and ensures an active company membership with the invite's selected role/grants. - The landing page has two layers of post-accept UI: - immediate mutation-result UI from `POST /api/invites/:token/accept` - reload-time summary UI from `GET /api/invites/:token` once the invite has already been consumed - Reload behavior for accepted company invites is now status-sensitive: - - `pending_approval` re-renders the waiting-for-approval panel + - human `pending_approval` or `approved` states replay acceptance for the same signed-in user/email so legacy consumed invites can repair missing membership + - agent `pending_approval` re-renders the waiting-for-approval panel - `rejected` renders the "This join request was not approved." error panel - - `approved` only becomes a success path for human invites after membership is visible to the current session; otherwise the page falls through to the generic consumed/unavailable state + - `approved` becomes a success path for human invites after membership is visible to the current session - `GET /api/invites/:token/logo` still rejects accepted invites, so accepted-invite reload states may fall back to the generated company icon even though the summary payload still carries `companyLogoUrl`. -- The only accepted-invite replay path in the current implementation is `POST /api/invites/:token/accept` for `agent` requests with `adapterType=openclaw_gateway`, and only when the existing join request is still `pending_approval` or already `approved`. +- Accepted-invite replay is supported for matching human invitees to repair/complete membership, and for `agent` requests with `adapterType=openclaw_gateway` when the existing join request is still `pending_approval` or already `approved`. - `bootstrap_ceo` invites are one-time and do not create join requests. diff --git a/packages/plugins/sdk/src/index.ts b/packages/plugins/sdk/src/index.ts index 1fbf172b..63ec9c70 100644 --- a/packages/plugins/sdk/src/index.ts +++ b/packages/plugins/sdk/src/index.ts @@ -129,6 +129,8 @@ export type { // JSON-RPC protocol types export type { JsonRpcId, + JsonRpcInvocationScope, + JsonRpcInvocationContext, JsonRpcRequest, JsonRpcSuccessResponse, JsonRpcError, diff --git a/packages/plugins/sdk/src/protocol.ts b/packages/plugins/sdk/src/protocol.ts index 2c76beab..92fcbf42 100644 --- a/packages/plugins/sdk/src/protocol.ts +++ b/packages/plugins/sdk/src/protocol.ts @@ -87,6 +87,19 @@ export const JSONRPC_VERSION = "2.0" as const; */ export type JsonRpcId = string | number; +/** + * Host-owned scope attached to a host→worker invocation. Workers may echo the + * invocation id on nested worker→host calls, but they never author this scope. + */ +export interface JsonRpcInvocationScope { + readonly companyId?: string | null; +} + +export interface JsonRpcInvocationContext { + readonly id: string; + readonly scope: JsonRpcInvocationScope; +} + /** * A JSON-RPC 2.0 request message. * diff --git a/packages/shared/src/types/workspace-runtime.ts b/packages/shared/src/types/workspace-runtime.ts index c9466ca9..fe136776 100644 --- a/packages/shared/src/types/workspace-runtime.ts +++ b/packages/shared/src/types/workspace-runtime.ts @@ -168,7 +168,11 @@ export interface ExecutionWorkspaceSummary { id: string; name: string; mode: Exclude | "adapter_managed" | "cloud_sandbox"; + status: ExecutionWorkspaceStatus; + cwd: string | null; + branchName: string | null; projectWorkspaceId: string | null; + lastUsedAt: Date; } export interface ExecutionWorkspace { diff --git a/server/src/__tests__/better-auth.test.ts b/server/src/__tests__/better-auth.test.ts index 2e2821d7..53718076 100644 --- a/server/src/__tests__/better-auth.test.ts +++ b/server/src/__tests__/better-auth.test.ts @@ -44,6 +44,47 @@ describe("Better Auth cookie scoping", () => { cookiePrefix: "paperclip-pap-worktree", useSecureCookies: false, }); + expect(getCookies({ + advanced: buildBetterAuthAdvancedOptions({ disableSecureCookies: true }), + } as BetterAuthOptions).sessionToken.name).toBe("paperclip-pap-worktree.session_token"); + }); + + it("disables secure cookies for authenticated private auto-origin dev servers", () => { + expect(shouldDisableSecureAuthCookies({ + deploymentMode: "authenticated", + deploymentExposure: "private", + authBaseUrlMode: "auto", + authPublicBaseUrl: undefined, + publicUrl: undefined, + })).toBe(true); + }); + + it("keeps secure cookies for authenticated public auto-origin servers", () => { + expect(shouldDisableSecureAuthCookies({ + deploymentMode: "authenticated", + deploymentExposure: "public", + authBaseUrlMode: "auto", + authPublicBaseUrl: undefined, + publicUrl: undefined, + })).toBe(false); + }); + + it("uses an explicit public URL when deciding whether secure cookies are required", () => { + expect(shouldDisableSecureAuthCookies({ + deploymentMode: "authenticated", + deploymentExposure: "private", + authBaseUrlMode: "auto", + authPublicBaseUrl: undefined, + publicUrl: "https://paperclip.example.test", + })).toBe(false); + + expect(shouldDisableSecureAuthCookies({ + deploymentMode: "authenticated", + deploymentExposure: "public", + authBaseUrlMode: "explicit", + authPublicBaseUrl: "http://paperclip.local.test:3100", + publicUrl: undefined, + })).toBe(true); }); it("disables secure cookies when no canonical public auth URL is configured", () => { @@ -71,13 +112,14 @@ describe("Better Auth cookie scoping", () => { } as Parameters[0])).toBe(false); }); - it("lets PAPERCLIP_PUBLIC_URL override the auth base URL for cookie security", () => { - process.env.PAPERCLIP_PUBLIC_URL = "http://paperclip-dev:46259"; + it("uses the caller-resolved public URL for cookie security", () => { + process.env.PAPERCLIP_PUBLIC_URL = "https://ignored.example.test"; expect(shouldDisableSecureAuthCookies({ deploymentMode: "authenticated", authBaseUrlMode: "explicit", authPublicBaseUrl: "https://paperclip.example.test", + publicUrl: "http://paperclip-dev:46259", } as Parameters[0])).toBe(true); }); diff --git a/server/src/__tests__/dev-runner-worktree.test.ts b/server/src/__tests__/dev-runner-worktree.test.ts index 461f2aab..dc326edd 100644 --- a/server/src/__tests__/dev-runner-worktree.test.ts +++ b/server/src/__tests__/dev-runner-worktree.test.ts @@ -63,6 +63,42 @@ describe("dev-runner worktree env bootstrap", () => { expect(env.PAPERCLIP_OPTIONAL).toBe(""); }); + it("repairs stale migrated config paths before loading worktree env", () => { + const root = createTempRoot("paperclip-dev-runner-worktree-migrated-env-"); + const localConfigPath = path.join(root, ".paperclip", "config.json"); + const worktreesDir = path.join(root, ".paperclip-worktrees"); + fs.mkdirSync(path.dirname(localConfigPath), { recursive: true }); + fs.writeFileSync(path.join(root, ".git"), "gitdir: /tmp/paperclip/.git/worktrees/feature\n", "utf8"); + fs.writeFileSync(localConfigPath, "{}\n", "utf8"); + fs.writeFileSync( + resolveWorktreeEnvFilePath(root), + [ + "PAPERCLIP_HOME=/old/home/.paperclip-worktrees", + "PAPERCLIP_INSTANCE_ID=feature-worktree", + "PAPERCLIP_CONFIG=/old/home/paperclip/.paperclip/worktrees/feature/.paperclip/config.json", + "PAPERCLIP_CONTEXT=/old/home/.paperclip-worktrees/context.json", + "PAPERCLIP_IN_WORKTREE=true", + "PAPERCLIP_WORKTREE_NAME=feature-worktree", + "", + ].join("\n"), + "utf8", + ); + + const env: NodeJS.ProcessEnv = { + PAPERCLIP_WORKTREES_DIR: worktreesDir, + }; + const result = bootstrapDevRunnerWorktreeEnv(root, env); + + expect(result).toEqual({ + envPath: resolveWorktreeEnvFilePath(root), + missingEnv: false, + }); + expect(env.PAPERCLIP_HOME).toBe(worktreesDir); + expect(env.PAPERCLIP_CONFIG).toBe(localConfigPath); + expect(env.PAPERCLIP_CONTEXT).toBe(path.join(worktreesDir, "context.json")); + expect(env.PAPERCLIP_INSTANCE_ID).toBe("feature-worktree"); + }); + it("reports uninitialized linked worktrees so dev runner can fail fast", () => { const root = createTempRoot("paperclip-dev-runner-worktree-missing-"); fs.writeFileSync(path.join(root, ".git"), "gitdir: /tmp/paperclip/.git/worktrees/feature\n", "utf8"); diff --git a/server/src/__tests__/execution-workspaces-service.test.ts b/server/src/__tests__/execution-workspaces-service.test.ts index f4c87e5f..fc8692fd 100644 --- a/server/src/__tests__/execution-workspaces-service.test.ts +++ b/server/src/__tests__/execution-workspaces-service.test.ts @@ -343,6 +343,83 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { expect(readExecutionWorkspaceConfig(byId.get(untouchedWorkspaceId) ?? null)).toBeNull(); }); + it("limits reusable summaries to open non-shared execution workspaces", async () => { + const companyId = randomUUID(); + const projectId = randomUUID(); + const openWorkspaceId = randomUUID(); + const sharedWorkspaceId = randomUUID(); + const closedWorkspaceId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: "PAP", + requireBoardApprovalForNewAgents: false, + }); + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Reusable workspaces", + status: "in_progress", + executionWorkspacePolicy: { + enabled: true, + }, + }); + await db.insert(executionWorkspaces).values([ + { + id: openWorkspaceId, + companyId, + projectId, + mode: "isolated_workspace", + strategyType: "git_worktree", + name: "Open isolated workspace", + status: "idle", + providerType: "git_worktree", + cwd: "/tmp/open-workspace", + branchName: "paperclip/open", + }, + { + id: sharedWorkspaceId, + companyId, + projectId, + mode: "shared_workspace", + strategyType: "project_primary", + name: "Shared session", + status: "active", + providerType: "local_fs", + cwd: "/tmp/project-primary", + }, + { + id: closedWorkspaceId, + companyId, + projectId, + mode: "isolated_workspace", + strategyType: "git_worktree", + name: "Closed isolated workspace", + status: "active", + providerType: "git_worktree", + cwd: "/tmp/closed-workspace", + closedAt: new Date("2026-05-23T20:00:00.000Z"), + }, + ]); + + const summaries = await svc.listSummaries(companyId, { + projectId, + reuseEligible: true, + }); + + expect(summaries).toEqual([ + expect.objectContaining({ + id: openWorkspaceId, + name: "Open isolated workspace", + mode: "isolated_workspace", + status: "idle", + cwd: "/tmp/open-workspace", + branchName: "paperclip/open", + }), + ]); + }); + it("warns about dirty and unmerged git worktrees and reports cleanup actions", async () => { const repoRoot = await createTempRepo(); tempDirs.add(repoRoot); diff --git a/server/src/__tests__/invite-accept-existing-member.test.ts b/server/src/__tests__/invite-accept-existing-member.test.ts index 913fcdbf..660a854e 100644 --- a/server/src/__tests__/invite-accept-existing-member.test.ts +++ b/server/src/__tests__/invite-accept-existing-member.test.ts @@ -4,12 +4,17 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { accessRoutes } from "../routes/access.js"; import { errorHandler } from "../middleware/index.js"; +const accessServiceMock = vi.hoisted(() => ({ + isInstanceAdmin: vi.fn(), + canUser: vi.fn(), + hasPermission: vi.fn(), + ensureMembership: vi.fn(), + setPrincipalGrants: vi.fn(), +})); +const logActivityMock = vi.hoisted(() => vi.fn()); + vi.mock("../services/index.js", () => ({ - accessService: () => ({ - isInstanceAdmin: vi.fn(), - canUser: vi.fn(), - hasPermission: vi.fn(), - }), + accessService: () => accessServiceMock, agentService: () => ({ getById: vi.fn(), }), @@ -20,10 +25,36 @@ vi.mock("../services/index.js", () => ({ revokeBoardApiKey: vi.fn(), }), deduplicateAgentName: vi.fn(), - logActivity: vi.fn(), + logActivity: logActivityMock, notifyHireApproved: vi.fn(), })); +type QueryHooks = { + onSet?: (value: unknown) => void; + onValues?: (value: unknown) => void; +}; + +function createQuery(rows: unknown[], hooks: QueryHooks = {}) { + const query = { + from: vi.fn(() => query), + where: vi.fn(() => query), + orderBy: vi.fn(() => query), + set: vi.fn((value: unknown) => { + hooks.onSet?.(value); + return query; + }), + values: vi.fn((value: unknown) => { + hooks.onValues?.(value); + return query; + }), + returning: vi.fn(() => query), + then(resolve: (value: unknown[]) => unknown, reject?: (reason: unknown) => unknown) { + return Promise.resolve(rows).then(resolve, reject); + }, + }; + return query; +} + function createDbStub() { const updateMock = vi.fn(); const invite = { @@ -75,22 +106,26 @@ function createDbStub() { } function createApp(db: Record) { + return createAppWithActor(db, { + type: "board", + source: "session", + userId: "user-1", + companyIds: ["company-1"], + memberships: [ + { + companyId: "company-1", + membershipRole: "owner", + status: "active", + }, + ], + }); +} + +function createAppWithActor(db: Record, actor: Record) { const app = express(); app.use(express.json()); app.use((req, _res, next) => { - (req as any).actor = { - type: "board", - source: "session", - userId: "user-1", - companyIds: ["company-1"], - memberships: [ - { - companyId: "company-1", - membershipRole: "owner", - status: "active", - }, - ], - }; + (req as any).actor = actor; next(); }); app.use( @@ -106,6 +141,162 @@ function createApp(db: Record) { return app; } +function createDirectHumanInviteDbStub() { + const insertedValues: unknown[] = []; + const updateValues: unknown[] = []; + const invite = { + id: "invite-1", + companyId: "company-1", + inviteType: "company_join", + allowedJoinTypes: "human", + tokenHash: "hash", + defaultsPayload: { human: { role: "owner" } }, + expiresAt: new Date("2027-03-10T00:00:00.000Z"), + invitedByUserId: "inviter-user", + revokedAt: null, + acceptedAt: null, + createdAt: new Date("2026-03-07T00:00:00.000Z"), + updatedAt: new Date("2026-03-07T00:00:00.000Z"), + }; + const createdJoinRequest = { + id: "join-1", + inviteId: "invite-1", + companyId: "company-1", + requestType: "human", + status: "pending_approval", + requestIp: "::ffff:127.0.0.1", + requestingUserId: "invitee-user", + requestEmailSnapshot: "invitee@example.com", + agentName: null, + adapterType: null, + capabilities: null, + agentDefaultsPayload: null, + claimSecretHash: null, + claimSecretExpiresAt: null, + claimSecretConsumedAt: null, + createdAgentId: null, + approvedByUserId: null, + approvedAt: null, + rejectedByUserId: null, + rejectedAt: null, + createdAt: new Date("2026-03-07T00:01:00.000Z"), + updatedAt: new Date("2026-03-07T00:01:00.000Z"), + }; + const approvedJoinRequest = { + ...createdJoinRequest, + status: "approved", + approvedByUserId: "inviter-user", + approvedAt: new Date("2026-03-07T00:02:00.000Z"), + updatedAt: new Date("2026-03-07T00:02:00.000Z"), + }; + const selectResponses = [ + [invite], + [{ email: "invitee@example.com" }], + [], + ]; + const updateResponses = [[], [approvedJoinRequest]]; + const insertResponses = [[createdJoinRequest]]; + + const db = { + select() { + return createQuery(selectResponses.shift() ?? []); + }, + update() { + return createQuery(updateResponses.shift() ?? [], { + onSet: (value) => updateValues.push(value), + }); + }, + insert() { + return createQuery(insertResponses.shift() ?? [], { + onValues: (value) => insertedValues.push(value), + }); + }, + transaction(callback: (tx: unknown) => unknown) { + return callback(db); + }, + }; + + return { db, insertedValues, updateValues }; +} + +function createAcceptedHumanInviteReplayDbStub() { + const updateValues: unknown[] = []; + const invite = { + id: "invite-1", + companyId: "company-1", + inviteType: "company_join", + allowedJoinTypes: "human", + tokenHash: "hash", + defaultsPayload: { human: { role: "operator" } }, + expiresAt: new Date("2027-03-10T00:00:00.000Z"), + invitedByUserId: "inviter-user", + revokedAt: null, + acceptedAt: new Date("2026-03-07T00:05:00.000Z"), + createdAt: new Date("2026-03-07T00:00:00.000Z"), + updatedAt: new Date("2026-03-07T00:05:00.000Z"), + }; + const pendingJoinRequest = { + id: "join-1", + inviteId: "invite-1", + companyId: "company-1", + requestType: "human", + status: "pending_approval", + requestIp: "::ffff:127.0.0.1", + requestingUserId: "invitee-user", + requestEmailSnapshot: "invitee@example.com", + agentName: null, + adapterType: null, + capabilities: null, + agentDefaultsPayload: null, + claimSecretHash: null, + claimSecretExpiresAt: null, + claimSecretConsumedAt: null, + createdAgentId: null, + approvedByUserId: null, + approvedAt: null, + rejectedByUserId: null, + rejectedAt: null, + createdAt: new Date("2026-03-07T00:01:00.000Z"), + updatedAt: new Date("2026-03-07T00:01:00.000Z"), + }; + const replayedJoinRequest = { + ...pendingJoinRequest, + requestIp: "::ffff:127.0.0.1", + updatedAt: new Date("2026-03-07T00:06:00.000Z"), + }; + const approvedJoinRequest = { + ...replayedJoinRequest, + status: "approved", + approvedByUserId: "inviter-user", + approvedAt: new Date("2026-03-07T00:07:00.000Z"), + updatedAt: new Date("2026-03-07T00:07:00.000Z"), + }; + const selectResponses = [ + [invite], + [pendingJoinRequest], + [{ email: "invitee@example.com" }], + [pendingJoinRequest], + ]; + const updateResponses = [[replayedJoinRequest], [approvedJoinRequest]]; + + const db = { + select() { + return createQuery(selectResponses.shift() ?? []); + }, + update() { + return createQuery(updateResponses.shift() ?? [], { + onSet: (value) => updateValues.push(value), + }); + }, + insert: vi.fn(), + transaction(callback: (tx: unknown) => unknown) { + return callback(db); + }, + }; + + return { db, updateValues }; +} + describe("POST /invites/:token/accept", () => { beforeEach(() => { vi.clearAllMocks(); @@ -123,4 +314,126 @@ describe("POST /invites/:token/accept", () => { expect(res.body.error).toBe("You already belong to this company"); expect(updateMock).not.toHaveBeenCalled(); }); + + it("grants company access immediately for a human invite", async () => { + const { db, insertedValues, updateValues } = createDirectHumanInviteDbStub(); + const app = createAppWithActor(db, { + type: "board", + source: "session", + userId: "invitee-user", + companyIds: [], + memberships: [], + }); + + const res = await request(app) + .post("/api/invites/pcp_invite_test/accept") + .send({ requestType: "human" }); + + expect(res.status).toBe(202); + expect(res.body.status).toBe("approved"); + expect(insertedValues).toEqual([ + expect.objectContaining({ + inviteId: "invite-1", + companyId: "company-1", + requestType: "human", + status: "pending_approval", + requestingUserId: "invitee-user", + requestEmailSnapshot: "invitee@example.com", + }), + ]); + expect(updateValues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + acceptedAt: expect.any(Date), + }), + expect.objectContaining({ + status: "approved", + approvedByUserId: "inviter-user", + approvedAt: expect.any(Date), + }), + ]), + ); + expect(accessServiceMock.ensureMembership).toHaveBeenCalledWith( + "company-1", + "user", + "invitee-user", + "owner", + "active", + ); + expect(accessServiceMock.setPrincipalGrants).toHaveBeenCalledWith( + "company-1", + "user", + "invitee-user", + expect.arrayContaining([ + expect.objectContaining({ permissionKey: "users:invite" }), + expect.objectContaining({ permissionKey: "users:manage_permissions" }), + ]), + "inviter-user", + ); + expect(logActivityMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: "join.approved", + entityId: "join-1", + details: expect.objectContaining({ source: "human_invite_accept" }), + }), + ); + }); + + it("replays a consumed human invite for the same user and repairs company access", async () => { + const { db, updateValues } = createAcceptedHumanInviteReplayDbStub(); + const app = createAppWithActor(db, { + type: "board", + source: "session", + userId: "invitee-user", + companyIds: [], + memberships: [], + }); + + const res = await request(app) + .post("/api/invites/pcp_invite_test/accept") + .send({ requestType: "human" }); + + expect(res.status).toBe(202); + expect(res.body.status).toBe("approved"); + expect(updateValues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + requestIp: expect.any(String), + updatedAt: expect.any(Date), + }), + expect.objectContaining({ + status: "approved", + approvedByUserId: "inviter-user", + approvedAt: expect.any(Date), + }), + ]), + ); + expect(updateValues).not.toEqual( + expect.arrayContaining([expect.objectContaining({ acceptedAt: expect.any(Date) })]), + ); + expect(accessServiceMock.ensureMembership).toHaveBeenCalledWith( + "company-1", + "user", + "invitee-user", + "operator", + "active", + ); + expect(logActivityMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: "join.request_replayed", + entityId: "join-1", + details: expect.objectContaining({ inviteReplay: true }), + }), + ); + expect(logActivityMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: "join.approved", + entityId: "join-1", + details: expect.objectContaining({ source: "human_invite_accept" }), + }), + ); + }); }); diff --git a/server/src/__tests__/static-index-html.test.ts b/server/src/__tests__/static-index-html.test.ts new file mode 100644 index 00000000..1ad3c405 --- /dev/null +++ b/server/src/__tests__/static-index-html.test.ts @@ -0,0 +1,49 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import express from "express"; +import request from "supertest"; +import { afterEach, describe, expect, it } from "vitest"; +import { readBrandedStaticIndexHtml } from "../static-index-html.js"; + +describe("static SPA fallback HTML", () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("serves the current index.html instead of reusing stale asset hashes", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-static-index-")); + tempDirs.push(tempDir); + const indexPath = path.join(tempDir, "index.html"); + const app = express(); + app.get(/.*/, (_req, res) => { + res + .status(200) + .set("Content-Type", "text/html") + .set("Cache-Control", "no-cache") + .end(readBrandedStaticIndexHtml(tempDir)); + }); + + fs.writeFileSync( + indexPath, + '', + "utf8", + ); + await expect(request(app).get("/PAP/issues/PAP-9939")).resolves.toMatchObject({ + text: expect.stringContaining("/assets/index-old.js"), + }); + + fs.writeFileSync( + indexPath, + '', + "utf8", + ); + const res = await request(app).get("/PAP/issues/PAP-9939"); + expect(res.text).toContain("/assets/index-new.js"); + expect(res.text).not.toContain("/assets/index-old.js"); + }); +}); diff --git a/server/src/__tests__/worktree-config.test.ts b/server/src/__tests__/worktree-config.test.ts index 69a9c5ee..42a9955e 100644 --- a/server/src/__tests__/worktree-config.test.ts +++ b/server/src/__tests__/worktree-config.test.ts @@ -210,6 +210,56 @@ describe("worktree config repair", () => { expect(repairedConfig.database.embeddedPostgresPort).toBe(54331); }); + it("ignores stale migrated env paths when the dev runner resolved the local config", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-migrated-env-")); + const worktreeRoot = path.join(tempRoot, "PAP-9940-what-can-we-learn"); + const paperclipDir = path.join(worktreeRoot, ".paperclip"); + const configPath = path.join(paperclipDir, "config.json"); + const envPath = path.join(paperclipDir, ".env"); + const oldHome = "/old/home/.paperclip-worktrees"; + const isolatedHome = path.join(tempRoot, ".paperclip-worktrees"); + + await fs.mkdir(paperclipDir, { recursive: true }); + await fs.writeFile(configPath, JSON.stringify(buildLegacyConfig(oldHome), null, 2) + "\n", "utf8"); + await fs.writeFile( + envPath, + [ + "# Paperclip environment variables", + "PAPERCLIP_HOME=/old/home/.paperclip-worktrees", + "PAPERCLIP_INSTANCE_ID=pap-9940-what-can-we-learn", + "PAPERCLIP_CONFIG=/old/home/paperclip/.paperclip/worktrees/PAP-9940-what-can-we-learn/.paperclip/config.json", + "PAPERCLIP_CONTEXT=/old/home/.paperclip-worktrees/context.json", + "PAPERCLIP_IN_WORKTREE=true", + "PAPERCLIP_WORKTREE_NAME=PAP-9940-what-can-we-learn", + "", + ].join("\n"), + "utf8", + ); + + process.chdir(worktreeRoot); + process.env.PAPERCLIP_IN_WORKTREE = "true"; + process.env.PAPERCLIP_CONFIG = configPath; + process.env.PAPERCLIP_WORKTREES_DIR = isolatedHome; + delete process.env.PAPERCLIP_HOME; + delete process.env.PAPERCLIP_INSTANCE_ID; + delete process.env.PAPERCLIP_CONTEXT; + + const result = maybeRepairLegacyWorktreeConfigAndEnvFiles(); + const repairedConfig = JSON.parse(await fs.readFile(configPath, "utf8")); + const repairedEnv = await fs.readFile(envPath, "utf8"); + const instanceRoot = path.join(isolatedHome, "instances", "pap-9940-what-can-we-learn"); + + expect(result).toEqual({ + repairedConfig: true, + repairedEnv: true, + }); + expect(repairedConfig.database.embeddedPostgresDataDir).toBe(path.join(instanceRoot, "db")); + expect(repairedConfig.secrets.localEncrypted.keyFilePath).toBe(path.join(instanceRoot, "secrets", "master.key")); + expect(repairedEnv).toContain(`PAPERCLIP_HOME=${JSON.stringify(isolatedHome)}`); + expect(repairedEnv).toContain(`PAPERCLIP_CONFIG=${JSON.stringify(configPath)}`); + expect(repairedEnv).not.toContain("/old/home"); + }); + it("does not persist transient runtime home overrides over repo-local worktree env", async () => { const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-runtime-override-")); const isolatedHome = path.join(tempRoot, ".paperclip-worktrees"); @@ -508,6 +558,8 @@ describe("worktree config repair", () => { process.env.PAPERCLIP_HOME = isolatedHome; process.env.PAPERCLIP_INSTANCE_ID = "pap-878-create-a-mine-tab-in-inbox"; process.env.PAPERCLIP_CONFIG = configPath; + delete process.env.PORT; + delete process.env.DATABASE_URL; maybePersistWorktreeRuntimePorts({ serverPort: 3103, @@ -590,6 +642,8 @@ describe("worktree config repair", () => { process.env.PAPERCLIP_HOME = isolatedHome; process.env.PAPERCLIP_INSTANCE_ID = "pap-125-public-base-url"; process.env.PAPERCLIP_CONFIG = configPath; + delete process.env.PORT; + delete process.env.DATABASE_URL; maybePersistWorktreeRuntimePorts({ serverPort: 3103, diff --git a/server/src/app.ts b/server/src/app.ts index a7c9a4ed..b2348714 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -41,6 +41,7 @@ import { accessRoutes } from "./routes/access.js"; import { pluginRoutes } from "./routes/plugins.js"; import { adapterRoutes } from "./routes/adapters.js"; import { pluginUiStaticRoutes } from "./routes/plugin-ui-static.js"; +import { readBrandedStaticIndexHtml } from "./static-index-html.js"; import { applyUiBranding } from "./ui-branding.js"; import { logger } from "./middleware/logger.js"; import { DEFAULT_LOCAL_PLUGIN_DIR, pluginLoader } from "./services/plugin-loader.js"; @@ -328,7 +329,6 @@ export async function createApp( ]; const uiDist = candidates.find((p) => fs.existsSync(path.join(p, "index.html"))); if (uiDist) { - const indexHtml = applyUiBranding(fs.readFileSync(path.join(uiDist, "index.html"), "utf-8")); // Hashed asset files (Vite emits them under /assets/..) // never change once built, so they can be cached aggressively. app.use( @@ -368,7 +368,7 @@ export async function createApp( .status(200) .set("Content-Type", "text/html") .set("Cache-Control", "no-cache") - .end(indexHtml); + .end(readBrandedStaticIndexHtml(uiDist)); }); } else { console.warn("[paperclip] UI dist not found; running in API-only mode"); diff --git a/server/src/auth/better-auth.ts b/server/src/auth/better-auth.ts index 6eb20249..f7742b97 100644 --- a/server/src/auth/better-auth.ts +++ b/server/src/auth/better-auth.ts @@ -44,13 +44,26 @@ export function buildBetterAuthAdvancedOptions(input: { disableSecureCookies: bo }; } -export function shouldDisableSecureAuthCookies(config: Config): boolean { - const configuredPublicUrl = ( - process.env.PAPERCLIP_PUBLIC_URL?.trim() || - (config.authBaseUrlMode === "explicit" ? config.authPublicBaseUrl?.trim() : "") +export function shouldDisableSecureAuthCookies(input: { + deploymentMode: Config["deploymentMode"]; + deploymentExposure?: Config["deploymentExposure"]; + authBaseUrlMode: Config["authBaseUrlMode"]; + authPublicBaseUrl: string | undefined; + publicUrl?: string | undefined; +}): boolean { + const publicUrl = ( + input.publicUrl?.trim() || + (input.authBaseUrlMode === "explicit" ? input.authPublicBaseUrl?.trim() : "") + ); + if (publicUrl) return publicUrl.startsWith("http://"); + + return ( + input.deploymentMode === "authenticated" && + ( + (input.deploymentExposure === "private" && input.authBaseUrlMode === "auto") || + input.deploymentExposure === undefined + ) ); - if (!configuredPublicUrl) return true; - return configuredPublicUrl.startsWith("http://"); } function headersFromNodeHeaders(rawHeaders: IncomingHttpHeaders): Headers { @@ -101,6 +114,7 @@ export function deriveAuthTrustedOrigins(config: Config, opts?: { listenPort?: n export function createBetterAuthInstance(db: Db, config: Config, trustedOrigins: string[]): BetterAuthInstance { const baseUrl = config.authBaseUrlMode === "explicit" ? config.authPublicBaseUrl : undefined; + const publicUrl = process.env.PAPERCLIP_PUBLIC_URL?.trim() || baseUrl; const secret = process.env.BETTER_AUTH_SECRET ?? process.env.PAPERCLIP_AGENT_JWT_SECRET; if (!secret) { throw new Error( @@ -108,7 +122,13 @@ export function createBetterAuthInstance(db: Db, config: Config, trustedOrigins: "For local development, set BETTER_AUTH_SECRET=paperclip-dev-secret in your .env file.", ); } - const disableSecureCookies = shouldDisableSecureAuthCookies(config); + const disableSecureCookies = shouldDisableSecureAuthCookies({ + deploymentMode: config.deploymentMode, + deploymentExposure: config.deploymentExposure, + authBaseUrlMode: config.authBaseUrlMode, + authPublicBaseUrl: config.authPublicBaseUrl, + publicUrl, + }); const authConfig = { baseURL: baseUrl, diff --git a/server/src/dev-runner-worktree.ts b/server/src/dev-runner-worktree.ts index 4e2b5d8d..7779c4e2 100644 --- a/server/src/dev-runner-worktree.ts +++ b/server/src/dev-runner-worktree.ts @@ -1,4 +1,5 @@ import { existsSync, lstatSync, readFileSync } from "node:fs"; +import os from "node:os"; import path from "node:path"; function parseEnvFile(contents: string): Record { @@ -55,6 +56,45 @@ export function resolveWorktreeEnvFilePath(rootDir: string): string { return path.resolve(rootDir, ".paperclip", ".env"); } +function expandHomePrefix(value: string): string { + if (value === "~") return os.homedir(); + if (value.startsWith("~/")) return path.resolve(os.homedir(), value.slice(2)); + return value; +} + +function resolveHomeAwarePath(value: string): string { + return path.resolve(expandHomePrefix(value)); +} + +function resolveDefaultWorktreeHome(env: NodeJS.ProcessEnv): string { + return path.resolve(expandHomePrefix(env.PAPERCLIP_WORKTREES_DIR?.trim() || "~/.paperclip-worktrees")); +} + +function repairStaleMigratedWorktreeEnvEntries( + rootDir: string, + entries: Record, + env: NodeJS.ProcessEnv, +): Record { + const localConfigPath = path.resolve(rootDir, ".paperclip", "config.json"); + const configuredPath = entries.PAPERCLIP_CONFIG?.trim(); + if (!configuredPath) return entries; + + const resolvedConfiguredPath = resolveHomeAwarePath(configuredPath); + const staleConfigPath = + resolvedConfiguredPath !== localConfigPath && + !existsSync(resolvedConfiguredPath) && + existsSync(localConfigPath); + if (!staleConfigPath) return entries; + + const homeDir = resolveDefaultWorktreeHome(env); + return { + ...entries, + PAPERCLIP_HOME: homeDir, + PAPERCLIP_CONFIG: localConfigPath, + PAPERCLIP_CONTEXT: path.resolve(homeDir, "context.json"), + }; +} + export function bootstrapDevRunnerWorktreeEnv( rootDir: string, env: NodeJS.ProcessEnv = process.env, @@ -74,7 +114,11 @@ export function bootstrapDevRunnerWorktreeEnv( }; } - const entries = parseEnvFile(readFileSync(envPath, "utf8")); + const entries = repairStaleMigratedWorktreeEnvEntries( + rootDir, + parseEnvFile(readFileSync(envPath, "utf8")), + env, + ); for (const [key, value] of Object.entries(entries)) { if (typeof env[key] === "string" && env[key]!.trim().length > 0) continue; env[key] = value; diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index e9c07f87..44b1fbaf 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -2721,6 +2721,83 @@ export function accessRoutes( return { token, created, normalizedAgentMessage }; } + async function approveHumanJoinRequestFromInvite(input: { + req: Request; + invite: typeof invites.$inferSelect; + joinRequest: typeof joinRequests.$inferSelect; + companyId: string; + }) { + if (input.joinRequest.requestType !== "human") { + throw badRequest("Only human join requests can be approved through a human invite"); + } + if (!input.joinRequest.requestingUserId) { + throw conflict("Join request missing user identity"); + } + + const membershipRole = resolveHumanInviteRole( + input.invite.defaultsPayload as Record | null, + ); + await access.ensureMembership( + input.companyId, + "user", + input.joinRequest.requestingUserId, + membershipRole, + "active", + ); + const grants = humanJoinGrantsFromDefaults( + input.invite.defaultsPayload as Record | null, + membershipRole, + ); + await access.setPrincipalGrants( + input.companyId, + "user", + input.joinRequest.requestingUserId, + grants, + input.invite.invitedByUserId ?? null, + ); + + if (input.joinRequest.status === "approved") { + return input.joinRequest; + } + + const approvedAt = new Date(); + const approvedByUserId = + input.invite.invitedByUserId ?? (isLocalImplicit(input.req) ? "local-board" : null); + const approved = await db + .update(joinRequests) + .set({ + status: "approved", + approvedByUserId, + approvedAt, + updatedAt: approvedAt, + }) + .where(eq(joinRequests.id, input.joinRequest.id)) + .returning() + .then((rows) => rows[0] ?? null); + + await logActivity(db, { + companyId: input.companyId, + actorType: "user", + actorId: approvedByUserId ?? "board", + action: "join.approved", + entityType: "join_request", + entityId: input.joinRequest.id, + details: { + requestType: "human", + inviteId: input.invite.id, + source: "human_invite_accept", + }, + }); + + return approved ?? { + ...input.joinRequest, + status: "approved", + approvedByUserId, + approvedAt, + updatedAt: approvedAt, + }; + } + async function getInviteCompanyBranding( companyId: string | null, inviteToken: string | null = null, @@ -3255,9 +3332,26 @@ export function accessRoutes( } } + const actorEmail = + requestType === "human" ? await resolveActorEmail(db, req) : null; + const actorRequestingUserId = + requestType === "human" + ? req.actor.userId ?? "local-board" + : null; + const canReplayHumanInviteAccept = + inviteAlreadyAccepted && + requestType === "human" && + existingJoinRequestForInvite?.requestType === "human" && + Boolean( + findReusableHumanJoinRequest([existingJoinRequestForInvite], { + requestingUserId: actorRequestingUserId, + requestEmailSnapshot: actorEmail, + }) + ); const adapterType = req.body.adapterType ?? null; if ( inviteAlreadyAccepted && + !canReplayHumanInviteAccept && !canReplayOpenClawGatewayInviteAccept({ requestType, adapterType, @@ -3336,8 +3430,6 @@ export function accessRoutes( ? new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) : null; - const actorEmail = - requestType === "human" ? await resolveActorEmail(db, req) : null; const existingHumanJoinRequest = requestType === "human" ? findReusableHumanJoinRequest( @@ -3352,12 +3444,12 @@ export function accessRoutes( ) .orderBy(desc(joinRequests.createdAt)), { - requestingUserId: req.actor.userId ?? "local-board", + requestingUserId: actorRequestingUserId, requestEmailSnapshot: actorEmail } ) : null; - const created = !inviteAlreadyAccepted + let created = !inviteAlreadyAccepted ? existingHumanJoinRequest ? await db.transaction(async (tx) => { await tx @@ -3566,6 +3658,15 @@ export function accessRoutes( } }); + if (requestType === "human") { + created = await approveHumanJoinRequestFromInvite({ + req, + invite, + joinRequest: created, + companyId, + }); + } + const response = toJoinRequestResponse(created); if (claimSecret) { const companyBranding = await getInviteCompanyBranding(invite.companyId); diff --git a/server/src/services/cloud-upstreams.ts b/server/src/services/cloud-upstreams.ts index 765dd432..576d0daf 100644 --- a/server/src/services/cloud-upstreams.ts +++ b/server/src/services/cloud-upstreams.ts @@ -103,6 +103,7 @@ type UpstreamTransferManifest = { idempotencyKey: string; generatedAt: string; entityCount: number; + perEntityTypeCounts: Record; entities: UpstreamTransferEntityRecord[]; chunks: Array & { manifestHash: NormalizedSha256 }>; warnings: UpstreamTransferWarning[]; @@ -914,6 +915,7 @@ function buildLocalUpstreamExportBundle(input: { idempotencyKey: input.idempotencyKey, generatedAt: new Date(0).toISOString(), entityCount: entities.length, + perEntityTypeCounts: countEntityTypesForManifest(entities), entities: entities.map((entity) => entity.record), chunks: chunksWithoutManifestHash.map(({ payload: _payload, ...chunk }) => chunk), warnings: input.warnings ?? [], @@ -931,6 +933,15 @@ function buildLocalUpstreamExportBundle(input: { }; } +function countEntityTypesForManifest(entities: LocalUpstreamExportEntity[]): Record { + const counts: Record = {}; + for (const entity of entities) { + const entityType = entity.record.key.sourceEntityType; + counts[entityType] = (counts[entityType] ?? 0) + 1; + } + return counts; +} + function buildLocalChunks(entities: LocalUpstreamExportEntity[], maxEntitiesPerChunk: number): LocalUpstreamExportChunk[] { if (!Number.isInteger(maxEntitiesPerChunk) || maxEntitiesPerChunk < 1) { throw new Error("maxEntitiesPerChunk must be a positive integer"); diff --git a/server/src/services/execution-workspaces.ts b/server/src/services/execution-workspaces.ts index e5fa36a1..ee5eb48a 100644 --- a/server/src/services/execution-workspaces.ts +++ b/server/src/services/execution-workspaces.ts @@ -2,7 +2,7 @@ import { execFile } from "node:child_process"; import fs from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; -import { and, desc, eq, inArray } from "drizzle-orm"; +import { and, desc, eq, inArray, isNull } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { executionWorkspaces, issues, projects, projectWorkspaces, workspaceRuntimeServices } from "@paperclipai/db"; import type { @@ -344,12 +344,18 @@ function toExecutionWorkspace( }; } -function toExecutionWorkspaceSummary(row: Pick): ExecutionWorkspaceSummary { +function toExecutionWorkspaceSummary( + row: Pick, +): ExecutionWorkspaceSummary { return { id: row.id, name: row.name, mode: row.mode as ExecutionWorkspaceSummary["mode"], + status: row.status as ExecutionWorkspaceSummary["status"], + cwd: row.cwd ?? null, + branchName: row.branchName ?? null, projectWorkspaceId: row.projectWorkspaceId ?? null, + lastUsedAt: row.lastUsedAt, }; } @@ -412,6 +418,8 @@ export function executionWorkspaceService(db: Db) { } if (filters?.reuseEligible) { conditions.push(inArray(executionWorkspaces.status, ["active", "idle", "in_review"])); + conditions.push(isNull(executionWorkspaces.closedAt)); + conditions.push(inArray(executionWorkspaces.mode, ["isolated_workspace", "operator_branch", "adapter_managed", "cloud_sandbox"])); } return conditions; } @@ -452,7 +460,11 @@ export function executionWorkspaceService(db: Db) { id: executionWorkspaces.id, name: executionWorkspaces.name, mode: executionWorkspaces.mode, + status: executionWorkspaces.status, + cwd: executionWorkspaces.cwd, + branchName: executionWorkspaces.branchName, projectWorkspaceId: executionWorkspaces.projectWorkspaceId, + lastUsedAt: executionWorkspaces.lastUsedAt, }) .from(executionWorkspaces) .where(and(...conditions)) diff --git a/server/src/services/plugin-worker-manager.ts b/server/src/services/plugin-worker-manager.ts index 9847e879..71cca23a 100644 --- a/server/src/services/plugin-worker-manager.ts +++ b/server/src/services/plugin-worker-manager.ts @@ -559,7 +559,9 @@ export function createPluginWorkerHandle( (message as { paperclipInvocationId?: unknown }).paperclipInvocationId, ); if (!invocationId) { - return activeInvocations.size > 0 ? { invalidInvocationScope: true } : {}; + const hasActiveInvocation = activeInvocations.size > 0 || + Array.from(pendingRequests.values()).some((pending) => pending.invocationId); + return hasActiveInvocation ? { invalidInvocationScope: true } : {}; } const entry = activeInvocations.get(invocationId); if (!entry) return { invalidInvocationScope: true }; diff --git a/server/src/static-index-html.ts b/server/src/static-index-html.ts new file mode 100644 index 00000000..13fa592c --- /dev/null +++ b/server/src/static-index-html.ts @@ -0,0 +1,7 @@ +import fs from "node:fs"; +import path from "node:path"; +import { applyUiBranding } from "./ui-branding.js"; + +export function readBrandedStaticIndexHtml(uiDist: string): string { + return applyUiBranding(fs.readFileSync(path.join(uiDist, "index.html"), "utf-8")); +} diff --git a/server/src/worktree-config.ts b/server/src/worktree-config.ts index d4f3ccb1..3c230664 100644 --- a/server/src/worktree-config.ts +++ b/server/src/worktree-config.ts @@ -115,17 +115,23 @@ function resolveWorktreeRuntimeContext( const configPath = resolvePaperclipConfigPath(overrideConfigPath); const envPath = resolvePaperclipEnvPath(configPath); const persistedEnv = readEnvEntries(envPath); + const persistedConfigPath = nonEmpty(persistedEnv.PAPERCLIP_CONFIG); + const persistedConfigLooksStale = + persistedConfigPath !== null && + path.resolve(expandHomePrefix(persistedConfigPath)) !== path.resolve(configPath) && + !fs.existsSync(resolveHomeAwarePath(persistedConfigPath)); + const stablePersistedEnv = persistedConfigLooksStale ? {} : persistedEnv; const worktreeRoot = path.resolve(path.dirname(configPath), ".."); const worktreeName = - nonEmpty(persistedEnv.PAPERCLIP_WORKTREE_NAME) ?? + nonEmpty(stablePersistedEnv.PAPERCLIP_WORKTREE_NAME) ?? nonEmpty(env.PAPERCLIP_WORKTREE_NAME) ?? path.basename(worktreeRoot); const instanceId = - nonEmpty(persistedEnv.PAPERCLIP_INSTANCE_ID) ?? + nonEmpty(stablePersistedEnv.PAPERCLIP_INSTANCE_ID) ?? nonEmpty(env.PAPERCLIP_INSTANCE_ID) ?? sanitizeWorktreeInstanceId(worktreeName); const homeDir = resolveHomeAwarePath( - nonEmpty(persistedEnv.PAPERCLIP_HOME) ?? + nonEmpty(stablePersistedEnv.PAPERCLIP_HOME) ?? nonEmpty(env.PAPERCLIP_HOME) ?? nonEmpty(env.PAPERCLIP_WORKTREES_DIR) ?? "~/.paperclip-worktrees", diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 905804a9..f6d81f2b 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -30,10 +30,10 @@ import { Activity } from "./pages/Activity"; import { Inbox } from "./pages/Inbox"; import { CompanySettings } from "./pages/CompanySettings"; import { CompanyEnvironments } from "./pages/CompanyEnvironments"; -import { CompanySettingsPluginPage } from "./pages/CompanySettingsPluginPage"; -import { CompanyAccess, CompanyAccessLegacyRoute } from "./pages/CompanyAccess"; import { CloudUpstream } from "./pages/CloudUpstream"; import { CloudUpstreamUxLab } from "./pages/CloudUpstreamUxLab"; +import { CompanySettingsPluginPage } from "./pages/CompanySettingsPluginPage"; +import { CompanyAccess, CompanyAccessLegacyRoute } from "./pages/CompanyAccess"; import { CompanyInvites } from "./pages/CompanyInvites"; import { CompanySkills } from "./pages/CompanySkills"; import { Secrets } from "./pages/Secrets"; @@ -72,6 +72,7 @@ function boardRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/components/CompanySettingsSidebar.test.tsx b/ui/src/components/CompanySettingsSidebar.test.tsx index cdff2d8b..124725bb 100644 --- a/ui/src/components/CompanySettingsSidebar.test.tsx +++ b/ui/src/components/CompanySettingsSidebar.test.tsx @@ -1,6 +1,5 @@ // @vitest-environment jsdom -import { act } from "react"; import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -10,10 +9,10 @@ const sidebarNavItemMock = vi.hoisted(() => vi.fn()); const mockSidebarBadgesApi = vi.hoisted(() => ({ get: vi.fn(), })); -const mockUsePluginSlots = vi.hoisted(() => vi.fn()); const mockInstanceSettingsApi = vi.hoisted(() => ({ getExperimental: vi.fn(), })); +const mockUsePluginSlots = vi.hoisted(() => vi.fn()); vi.mock("@/lib/router", () => ({ Link: ({ @@ -65,22 +64,28 @@ vi.mock("@/api/sidebarBadges", () => ({ sidebarBadgesApi: mockSidebarBadgesApi, })); -vi.mock("@/plugins/slots", () => ({ - usePluginSlots: mockUsePluginSlots, -})); - vi.mock("@/api/instanceSettings", () => ({ instanceSettingsApi: mockInstanceSettingsApi, })); +vi.mock("@/plugins/slots", () => ({ + usePluginSlots: mockUsePluginSlots, +})); + // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; +async function act(callback: () => void | Promise) { + await callback(); + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); +} + async function flushReact() { - await act(async () => { + for (let i = 0; i < 3; i += 1) { await Promise.resolve(); await new Promise((resolve) => window.setTimeout(resolve, 0)); - }); + } } describe("CompanySettingsSidebar", () => { @@ -95,6 +100,9 @@ describe("CompanySettingsSidebar", () => { failedRuns: 0, joinRequests: 2, }); + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ + enableCloudSync: false, + }); mockUsePluginSlots.mockReturnValue({ slots: [], isLoading: false, @@ -130,6 +138,7 @@ describe("CompanySettingsSidebar", () => { expect(container.textContent).toContain("Company Settings"); expect(container.textContent).toContain("General"); expect(container.textContent).toContain("Environments"); + expect(container.textContent).not.toContain("Cloud upstream"); expect(container.textContent).toContain("Members"); expect(container.textContent).not.toContain("Cloud upstream"); expect(container.textContent).toContain("Invites"); @@ -176,6 +185,38 @@ describe("CompanySettingsSidebar", () => { }); }); + it("shows cloud upstream only when cloud sync is enabled", async () => { + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ + enableCloudSync: true, + }); + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + + expect(container.textContent).toContain("Cloud upstream"); + expect(sidebarNavItemMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: "/company/settings/cloud-upstream", + label: "Cloud upstream", + end: true, + }), + ); + + await act(async () => { + root.unmount(); + }); + }); + it("renders company settings pages contributed by ready plugins", async () => { mockUsePluginSlots.mockReturnValue({ slots: [ diff --git a/ui/src/components/EntityRow.tsx b/ui/src/components/EntityRow.tsx index 4c375fbd..de1c69e6 100644 --- a/ui/src/components/EntityRow.tsx +++ b/ui/src/components/EntityRow.tsx @@ -12,6 +12,7 @@ interface EntityRowProps { to?: string; onClick?: () => void; className?: string; + reserveSubtitleSpace?: boolean; } export function EntityRow({ @@ -24,6 +25,7 @@ export function EntityRow({ to, onClick, className, + reserveSubtitleSpace, }: EntityRowProps) { const isClickable = !!(to || onClick); const classes = cn( @@ -45,8 +47,13 @@ export function EntityRow({ )} {title} - {subtitle && ( - {subtitle} + {(subtitle || reserveSubtitleSpace) && ( + + {subtitle} + )} {trailing && {trailing}} diff --git a/ui/src/components/MarkdownBody.test.tsx b/ui/src/components/MarkdownBody.test.tsx index ebb46349..dd92af4b 100644 --- a/ui/src/components/MarkdownBody.test.tsx +++ b/ui/src/components/MarkdownBody.test.tsx @@ -450,8 +450,10 @@ describe("MarkdownBody", () => { expect(html).toContain("paperclip-markdown-codeblock"); expect(html).toContain("paperclip-markdown-codeblock-actions"); + expect(html).toContain("position:absolute;top:0.4rem;right:0.4rem;display:inline-flex"); expect(html).toContain("paperclip-markdown-codeblock-wrap"); expect(html).toContain('aria-label="Wrap lines"'); + expect(html).toContain("position:static;opacity:1;display:inline-flex"); expect(html).toContain("paperclip-markdown-codeblock-copy"); expect(html).toContain('aria-label="Copy code"'); expect(html).toContain("lucide-copy"); diff --git a/ui/src/components/MarkdownBody.tsx b/ui/src/components/MarkdownBody.tsx index 8c81de26..5d4fd886 100644 --- a/ui/src/components/MarkdownBody.tsx +++ b/ui/src/components/MarkdownBody.tsx @@ -84,6 +84,39 @@ const scrollableBlockStyle: React.CSSProperties = { overflowX: "auto", }; +const codeBlockActionsStyle: React.CSSProperties = { + position: "absolute", + top: "0.4rem", + right: "0.4rem", + display: "inline-flex", + alignItems: "center", + gap: "0.25rem", +}; + +const codeBlockActionStyle: React.CSSProperties = { + position: "static", + opacity: 1, + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + gap: "0.25rem", + minHeight: "1.55rem", + padding: "0.2rem 0.4rem", + borderRadius: "calc(var(--radius) - 4px)", + border: "1px solid color-mix(in oklab, var(--foreground) 14%, transparent)", + backgroundColor: "color-mix(in oklab, var(--muted) 92%, var(--background) 8%)", + color: "var(--muted-foreground)", + fontSize: "0.7rem", + lineHeight: 1, + cursor: "pointer", +}; + +const codeBlockWrapActionStyle: React.CSSProperties = { + ...codeBlockActionStyle, + width: "1.55rem", + paddingInline: 0, +}; + const tableCellWrapStyle: React.CSSProperties = { overflowWrap: "anywhere", wordBreak: "normal", @@ -426,6 +459,7 @@ function CodeBlock({ - {wrapLabel} diff --git a/ui/src/components/NewIssueDialog.test.tsx b/ui/src/components/NewIssueDialog.test.tsx index 7f986188..b2627f72 100644 --- a/ui/src/components/NewIssueDialog.test.tsx +++ b/ui/src/components/NewIssueDialog.test.tsx @@ -52,6 +52,7 @@ const mockIssuesApi = vi.hoisted(() => ({ const mockExecutionWorkspacesApi = vi.hoisted(() => ({ list: vi.fn(), + listSummaries: vi.fn(), })); const mockProjectsApi = vi.hoisted(() => ({ @@ -310,7 +311,9 @@ describe("NewIssueDialog", () => { mockIssuesApi.create.mockReset(); mockIssuesApi.upsertDocument.mockReset(); mockIssuesApi.uploadAttachment.mockReset(); - mockExecutionWorkspacesApi.list.mockResolvedValue([]); + mockExecutionWorkspacesApi.list.mockReset(); + mockExecutionWorkspacesApi.listSummaries.mockReset(); + mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([]); mockProjectsApi.list.mockResolvedValue([ { id: "project-1", @@ -382,13 +385,15 @@ describe("NewIssueDialog", () => { }, }, ]); - mockExecutionWorkspacesApi.list.mockResolvedValue([ + mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([ { id: "workspace-1", name: "Parent workspace", + mode: "isolated_workspace", status: "active", branchName: "feature/pap-1", cwd: "/tmp/workspace-1", + projectWorkspaceId: null, lastUsedAt: new Date("2026-04-06T16:00:00.000Z"), }, ]); @@ -406,6 +411,15 @@ describe("NewIssueDialog", () => { const { root } = renderDialog(container); await flush(); + await waitForAssertion(() => { + expect(mockExecutionWorkspacesApi.listSummaries).toHaveBeenCalledWith("company-1", { + projectId: "project-1", + projectWorkspaceId: undefined, + reuseEligible: true, + }); + }); + expect(mockExecutionWorkspacesApi.list).not.toHaveBeenCalled(); + const submitButton = Array.from(container.querySelectorAll("button")) .find((button) => button.textContent?.includes("Create Sub-Issue")); expect(submitButton).not.toBeUndefined(); @@ -494,7 +508,7 @@ describe("NewIssueDialog", () => { }, }, ]); - mockExecutionWorkspacesApi.list.mockResolvedValue([ + mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([ { id: "workspace-1", name: "PAP-100", @@ -502,6 +516,7 @@ describe("NewIssueDialog", () => { status: "active", branchName: "feature/pap-100", cwd: "/tmp/workspace-1", + projectWorkspaceId: "project-workspace-2", lastUsedAt: new Date("2026-04-06T16:00:00.000Z"), }, ]); @@ -809,21 +824,25 @@ describe("NewIssueDialog", () => { }, }, ]); - mockExecutionWorkspacesApi.list.mockResolvedValue([ + mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([ { id: "workspace-1", name: "Parent workspace", + mode: "isolated_workspace", status: "active", branchName: "feature/pap-1", cwd: "/tmp/workspace-1", + projectWorkspaceId: null, lastUsedAt: new Date("2026-04-06T16:00:00.000Z"), }, { id: "workspace-2", name: "Other workspace", + mode: "isolated_workspace", status: "active", branchName: "feature/pap-2", cwd: "/tmp/workspace-2", + projectWorkspaceId: null, lastUsedAt: new Date("2026-04-06T16:01:00.000Z"), }, ]); diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index 6e4d306b..9016adf8 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -469,13 +469,13 @@ export function NewIssueDialog() { enabled: !!effectiveCompanyId && newIssueOpen, }); const { data: reusableExecutionWorkspaces } = useQuery({ - queryKey: queryKeys.executionWorkspaces.list(effectiveCompanyId!, { + queryKey: queryKeys.executionWorkspaces.summaryList(effectiveCompanyId!, { projectId, projectWorkspaceId: projectWorkspaceId || undefined, reuseEligible: true, }), queryFn: () => - executionWorkspacesApi.list(effectiveCompanyId!, { + executionWorkspacesApi.listSummaries(effectiveCompanyId!, { projectId, projectWorkspaceId: projectWorkspaceId || undefined, reuseEligible: true, diff --git a/ui/src/index.css b/ui/src/index.css index f156858a..efac23df 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -736,7 +736,9 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before { .paperclip-markdown-codeblock-action { display: inline-flex; align-items: center; + justify-content: center; gap: 0.25rem; + min-height: 1.55rem; padding: 0.2rem 0.4rem; border-radius: calc(var(--radius) - 4px); border: 1px solid color-mix(in oklab, var(--foreground) 14%, transparent); @@ -745,7 +747,7 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before { font-size: 0.7rem; line-height: 1; cursor: pointer; - transition: background-color 0.12s ease, color 0.12s ease; + transition: background-color 0.12s ease, color 0.12s ease, border-color 0.12s ease; } .paperclip-markdown-codeblock:hover .paperclip-markdown-codeblock-actions, @@ -761,6 +763,7 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before { .paperclip-markdown-codeblock-action[data-active], .paperclip-markdown-codeblock-action[data-copied] { + border-color: color-mix(in oklab, var(--primary) 38%, transparent); color: var(--primary); } diff --git a/ui/src/pages/CloudUpstream.test.tsx b/ui/src/pages/CloudUpstream.test.tsx index cb6d0c0a..bbc67088 100644 --- a/ui/src/pages/CloudUpstream.test.tsx +++ b/ui/src/pages/CloudUpstream.test.tsx @@ -1,6 +1,5 @@ // @vitest-environment jsdom -import { act } from "react"; import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { CloudUpstreamRun, CloudUpstreamsState } from "@paperclipai/shared"; @@ -65,6 +64,12 @@ vi.mock("@/lib/router", () => ({ // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; +async function act(callback: () => void | Promise) { + await callback(); + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); +} + async function flushReact() { await act(async () => { await Promise.resolve(); diff --git a/ui/src/pages/CompanyAccess.tsx b/ui/src/pages/CompanyAccess.tsx index 9c43d5ae..920fe5ec 100644 --- a/ui/src/pages/CompanyAccess.tsx +++ b/ui/src/pages/CompanyAccess.tsx @@ -271,7 +271,7 @@ export function CompanyAccess() { Pending human joins - Review human join requests before they become active company members. + Review pending join requests before they become active company members. {pendingHumanJoinRequests.length} pending diff --git a/ui/src/pages/CompanyInvites.test.tsx b/ui/src/pages/CompanyInvites.test.tsx index 6133acba..dc2e3b3e 100644 --- a/ui/src/pages/CompanyInvites.test.tsx +++ b/ui/src/pages/CompanyInvites.test.tsx @@ -203,7 +203,11 @@ describe("CompanyInvites", () => { expect(clipboardWriteTextMock).toHaveBeenCalledWith("https://paperclip.local/invite/new-token"); expect(container.textContent).toContain("Latest invite link"); expect(container.textContent).toContain("This URL includes the current Paperclip domain returned by the server."); - expect(container.textContent).toContain("https://paperclip.local/invite/new-token"); + expect(container.querySelector('input[aria-label="Latest invite URL"]')).toHaveProperty( + "value", + "https://paperclip.local/invite/new-token", + ); + expect(container.textContent).toContain("Copy link"); expect(container.textContent).toContain("Open invite"); expect(pushToastMock).toHaveBeenCalledWith({ title: "Invite created", @@ -211,12 +215,12 @@ describe("CompanyInvites", () => { tone: "success", }); - const inviteFieldButton = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent?.includes("https://paperclip.local/invite/new-token"), + const copyLinkButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "Copy link", ); await act(async () => { - inviteFieldButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + copyLinkButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); @@ -235,6 +239,61 @@ describe("CompanyInvites", () => { }); }); + it("falls back to selectable text when browser clipboard access is unavailable", async () => { + Object.defineProperty(globalThis.navigator, "clipboard", { + configurable: true, + value: undefined, + }); + Object.defineProperty(document, "queryCommandSupported", { + configurable: true, + value: vi.fn((command: string) => command === "copy"), + }); + Object.defineProperty(document, "execCommand", { + configurable: true, + value: vi.fn(() => true), + }); + + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + + + , + ); + }); + await flushReact(); + await flushReact(); + + const createButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "Create invite", + ); + + await act(async () => { + createButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + await flushReact(); + + const inviteInput = container.querySelector('input[aria-label="Latest invite URL"]') as HTMLInputElement | null; + expect(inviteInput?.value).toBe("https://paperclip.local/invite/new-token"); + expect(document.execCommand).toHaveBeenCalledWith("copy"); + expect(pushToastMock).toHaveBeenCalledWith({ + title: "Invite created", + body: "Invite ready below and copied to clipboard.", + tone: "success", + }); + + await act(async () => { + root.unmount(); + }); + }); + it("ignores legacy cached invite arrays and refetches paginated history", async () => { const root = createRoot(container); const queryClient = new QueryClient({ diff --git a/ui/src/pages/CompanyInvites.tsx b/ui/src/pages/CompanyInvites.tsx index 5af4a05a..354ada00 100644 --- a/ui/src/pages/CompanyInvites.tsx +++ b/ui/src/pages/CompanyInvites.tsx @@ -1,6 +1,6 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { Check, ExternalLink, MailPlus } from "lucide-react"; +import { Check, Copy, ExternalLink, MailPlus } from "lucide-react"; import { accessApi } from "@/api/access"; import { ApiError } from "@/api/client"; import { Button } from "@/components/ui/button"; @@ -52,6 +52,7 @@ export function CompanyInvites() { const [humanRole, setHumanRole] = useState<"owner" | "admin" | "operator" | "viewer">("operator"); const [latestInviteUrl, setLatestInviteUrl] = useState(null); const [latestInviteCopied, setLatestInviteCopied] = useState(false); + const latestInviteInputRef = useRef(null); useEffect(() => { if (!latestInviteCopied) return; @@ -61,7 +62,12 @@ export function CompanyInvites() { return () => window.clearTimeout(timeout); }, [latestInviteCopied]); - async function copyText(text: string, unavailableBody: string) { + function selectLatestInviteUrl() { + latestInviteInputRef.current?.focus(); + latestInviteInputRef.current?.select(); + } + + async function copyText(text: string, unavailableBody: string, afterFallback?: () => void) { try { if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); @@ -71,6 +77,33 @@ export function CompanyInvites() { // Fall through to the unavailable message below. } + const canUseLegacyCopy = + typeof document !== "undefined" && + typeof document.execCommand === "function" && + (typeof document.queryCommandSupported !== "function" || document.queryCommandSupported("copy")); + if (canUseLegacyCopy) { + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", "true"); + textarea.style.position = "fixed"; + textarea.style.top = "0"; + textarea.style.left = "-9999px"; + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + textarea.setSelectionRange(0, textarea.value.length); + + try { + const copied = document.execCommand("copy"); + document.body.removeChild(textarea); + afterFallback?.(); + if (copied) return true; + } catch { + document.body.removeChild(textarea); + } + } + + afterFallback?.(); pushToast({ title: "Clipboard unavailable", body: unavailableBody, @@ -79,6 +112,10 @@ export function CompanyInvites() { return false; } + async function copyInviteUrl(url: string) { + return copyText(url, "The invite URL is selected. Copy it manually from the field.", selectLatestInviteUrl); + } + useEffect(() => { setBreadcrumbs([ { label: selectedCompany?.name ?? "Company", href: "/dashboard" }, @@ -225,7 +262,7 @@ export function CompanyInvites() { - Each invite link is single-use. The first successful use consumes the link and creates or reuses the matching join request before approval. + Each invite link is single-use. Human invitees get the selected role immediately after sign-in; agent invites still create a join request for approval. @@ -251,17 +288,31 @@ export function CompanyInvites() { This URL includes the current Paperclip domain returned by the server. - { - const copied = await copyText(latestInviteUrl, "Copy the invite URL manually from the field below."); - setLatestInviteCopied(copied); - }} - className="w-full rounded-md border border-border bg-muted/60 px-3 py-2 text-left text-sm break-all transition-colors hover:bg-background" - > - {latestInviteUrl} - + + Latest invite URL + event.currentTarget.select()} + onClick={(event) => event.currentTarget.select()} + className="w-full rounded-md border border-border bg-muted/60 px-3 py-2 text-sm text-foreground outline-none transition-colors selection:bg-primary selection:text-primary-foreground focus:border-ring" + aria-label="Latest invite URL" + /> + + { + const copied = await copyInviteUrl(latestInviteUrl); + setLatestInviteCopied(copied); + }} + > + + Copy link + diff --git a/ui/src/pages/InviteLanding.test.tsx b/ui/src/pages/InviteLanding.test.tsx index a800bd6f..450fb544 100644 --- a/ui/src/pages/InviteLanding.test.tsx +++ b/ui/src/pages/InviteLanding.test.tsx @@ -6,6 +6,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { MemoryRouter, Route, Routes } from "react-router-dom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { InviteLandingPage } from "./InviteLanding"; +import { queryKeys } from "../lib/queryKeys"; const getInviteMock = vi.hoisted(() => vi.fn()); const acceptInviteMock = vi.hoisted(() => vi.fn()); @@ -216,6 +217,11 @@ describe("InviteLandingPage", () => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); + queryClient.setQueryData(queryKeys.access.currentBoardAccess, { + userId: "user-1", + isInstanceAdmin: false, + companyIds: [], + }); await act(async () => { root.render( @@ -302,6 +308,11 @@ describe("InviteLandingPage", () => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); + queryClient.setQueryData(queryKeys.access.currentBoardAccess, { + userId: "user-1", + isInstanceAdmin: false, + companyIds: [], + }); await act(async () => { root.render( @@ -354,6 +365,11 @@ describe("InviteLandingPage", () => { }); expect(acceptInviteMock).toHaveBeenCalledWith("pcp_invite_test", { requestType: "human" }); expect(setSelectedCompanyIdMock).toHaveBeenCalledWith("company-1", { source: "manual" }); + expect(queryClient.getQueryState(queryKeys.access.currentBoardAccess)?.isInvalidated).toBe(true); + expect(queryClient.getQueryData(queryKeys.companies.all)).toMatchObject({ + companies: [], + unauthorized: false, + }); expect(localStorage.getItem("paperclip:pending-invite-token")).toBeNull(); await act(async () => { @@ -422,7 +438,7 @@ describe("InviteLandingPage", () => { }); }); - it("keeps the waiting-for-approval state on refresh for an accepted invite", async () => { + it("auto-completes a previously accepted human invite after sign-in", async () => { getInviteMock.mockResolvedValue({ id: "invite-1", companyId: "company-1", @@ -437,6 +453,12 @@ describe("InviteLandingPage", () => { joinRequestStatus: "pending_approval", joinRequestType: "human", }); + acceptInviteMock.mockResolvedValue({ + id: "join-1", + companyId: "company-1", + requestType: "human", + status: "approved", + }); getSessionMock.mockResolvedValue({ session: { id: "session-1", userId: "user-1" }, user: { @@ -447,6 +469,58 @@ describe("InviteLandingPage", () => { }, }); + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + queryClient.setQueryData(queryKeys.access.currentBoardAccess, { + userId: "user-1", + isInstanceAdmin: false, + companyIds: [], + }); + + await act(async () => { + root.render( + + + + } /> + + + , + ); + }); + await flushReact(); + await flushReact(); + await flushReact(); + await flushReact(); + + expect(acceptInviteMock).toHaveBeenCalledWith("pcp_invite_test", { requestType: "human" }); + expect(setSelectedCompanyIdMock).toHaveBeenCalledWith("company-1", { source: "manual" }); + expect(queryClient.getQueryState(queryKeys.access.currentBoardAccess)?.isInvalidated).toBe(true); + expect(localStorage.getItem("paperclip:pending-invite-token")).toBeNull(); + + await act(async () => { + root.unmount(); + }); + }); + + it("asks unauthenticated users to sign in before completing an accepted human invite", async () => { + getInviteMock.mockResolvedValue({ + id: "invite-1", + companyId: "company-1", + companyName: "Acme Robotics", + companyLogoUrl: "/api/invites/pcp_invite_test/logo", + companyBrandColor: "#114488", + inviteType: "company_join", + allowedJoinTypes: "human", + humanRole: "operator", + expiresAt: "2027-03-07T00:10:00.000Z", + inviteMessage: "Welcome aboard.", + joinRequestStatus: "pending_approval", + joinRequestType: "human", + }); + const root = createRoot(container); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, @@ -465,14 +539,11 @@ describe("InviteLandingPage", () => { }); await flushReact(); await flushReact(); - await flushReact(); expect(acceptInviteMock).not.toHaveBeenCalled(); - expect(container.querySelector('[data-testid="invite-pending-approval"]')).not.toBeNull(); - expect(container.textContent).toContain("Your request is still awaiting approval."); - expect(container.textContent).toContain( - "Ask them to visit Company Settings → Members to approve your request.", - ); + expect(container.querySelector('[data-testid="invite-inline-auth"]')).not.toBeNull(); + expect(container.textContent).toContain("Create your account"); + expect(container.querySelector('[data-testid="invite-pending-approval"]')).toBeNull(); await act(async () => { root.unmount(); @@ -551,6 +622,10 @@ describe("InviteLandingPage", () => { }); expect(acceptInviteMock).not.toHaveBeenCalled(); expect(setSelectedCompanyIdMock).toHaveBeenCalledWith("company-1", { source: "manual" }); + expect(queryClient.getQueryData(queryKeys.companies.all)).toMatchObject({ + companies: [{ id: "company-1", name: "Acme Robotics" }], + unauthorized: false, + }); expect(localStorage.getItem("paperclip:pending-invite-token")).toBeNull(); await act(async () => { @@ -558,6 +633,59 @@ describe("InviteLandingPage", () => { }); }); + it("shows invite details instead of auto-redirecting for signed-in existing members", async () => { + getSessionMock.mockResolvedValue({ + session: { id: "session-1", userId: "user-1" }, + user: { + id: "user-1", + name: "Jane Example", + email: "jane@example.com", + image: null, + }, + }); + listCompaniesMock.mockResolvedValue([{ id: "company-1", name: "Acme Robotics" }]); + + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + + } /> + + + , + ); + }); + await flushReact(); + await flushReact(); + + expect(container.textContent).toContain("Join Acme Robotics"); + expect(container.textContent).toContain("Already in this company"); + expect(container.textContent).toContain("This account already belongs to Acme Robotics."); + expect(acceptInviteMock).not.toHaveBeenCalled(); + + const openButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "Open company", + ); + expect(openButton).not.toBeNull(); + + await act(async () => { + openButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + expect(setSelectedCompanyIdMock).toHaveBeenCalledWith("company-1", { source: "manual" }); + + await act(async () => { + root.unmount(); + }); + }); + it("falls back to the generated company icon when the invite logo fails to load", async () => { const root = createRoot(container); const queryClient = new QueryClient({ @@ -594,6 +722,55 @@ describe("InviteLandingPage", () => { }); }); + it("normalizes the shared company cache envelope before checking membership", async () => { + acceptInviteMock.mockResolvedValue({ + id: "join-1", + companyId: "company-1", + requestType: "human", + status: "pending_approval", + }); + getSessionMock.mockResolvedValue({ + session: { id: "session-1", userId: "user-1" }, + user: { + id: "user-1", + name: "Jane Example", + email: "jane@example.com", + image: null, + }, + }); + + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + queryClient.setQueryData(queryKeys.companies.all, { + companies: [], + unauthorized: false, + }); + + await act(async () => { + root.render( + + + + } /> + + + , + ); + }); + await flushReact(); + await flushReact(); + await flushReact(); + + expect(acceptInviteMock).toHaveBeenCalledWith("pcp_invite_test", { requestType: "human" }); + expect(container.textContent).toContain("Request to join Acme Robotics"); + + await act(async () => { + root.unmount(); + }); + }); + it("waits for the membership check before showing invite acceptance to signed-in users", async () => { let resolveCompanies: ((value: Array<{ id: string; name: string }>) => void) | null = null; acceptInviteMock.mockResolvedValue({ diff --git a/ui/src/pages/InviteLanding.tsx b/ui/src/pages/InviteLanding.tsx index 15642ecc..bed72079 100644 --- a/ui/src/pages/InviteLanding.tsx +++ b/ui/src/pages/InviteLanding.tsx @@ -266,9 +266,8 @@ export function InviteLandingPage() { if (!list || !inviteQuery.data?.companyId) return; if (list.some((c) => c.id === inviteQuery.data!.companyId)) { clearPendingInviteToken(token); - navigate("/", { replace: true }); } - }, [companiesQuery.data, inviteQuery.data, token, navigate]); + }, [companiesQuery.data, inviteQuery.data, token]); const invite = inviteQuery.data; const isCheckingExistingMembership = @@ -287,6 +286,9 @@ export function InviteLandingPage() { const requestedHumanRole = formatHumanRole(invite?.humanRole); const inviteJoinRequestStatus = invite?.joinRequestStatus ?? null; const inviteJoinRequestType = invite?.joinRequestType ?? null; + const canCompleteAcceptedHumanInvite = + inviteJoinRequestType === "human" && + (inviteJoinRequestStatus === "pending_approval" || inviteJoinRequestStatus === "approved"); const requiresHumanAccount = healthQuery.data?.deploymentMode === "authenticated" && !sessionQuery.data && @@ -296,7 +298,7 @@ export function InviteLandingPage() { Boolean(sessionQuery.data) && !showsAgentForm && invite?.inviteType !== "bootstrap_ceo" && - !inviteJoinRequestStatus && + (!inviteJoinRequestStatus || canCompleteAcceptedHumanInvite) && !isCheckingExistingMembership && !isCurrentMember && !result && @@ -336,6 +338,7 @@ export function InviteLandingPage() { const asBootstrap = isBootstrapAcceptancePayload(payload); setResult({ kind: asBootstrap ? "bootstrap" : "join", payload }); await queryClient.invalidateQueries({ queryKey: queryKeys.auth.session }); + await queryClient.invalidateQueries({ queryKey: queryKeys.access.currentBoardAccess }); await queryClient.invalidateQueries({ queryKey: queryKeys.companies.all }); if (invite?.companyId && isApprovedHumanJoinPayload(payload, showsAgentForm)) { setSelectedCompanyId(invite.companyId, { source: "manual" }); @@ -370,6 +373,7 @@ export function InviteLandingPage() { setAuthFeedback(null); rememberPendingInviteToken(token); await queryClient.invalidateQueries({ queryKey: queryKeys.auth.session }); + await queryClient.invalidateQueries({ queryKey: queryKeys.access.currentBoardAccess }); const { companies: freshCompanies } = await queryClient.fetchQuery(companiesListQueryOptions); if (invite?.companyId && freshCompanies.some((company) => company.id === invite.companyId)) { @@ -404,10 +408,11 @@ export function InviteLandingPage() { const joinButtonLabel = useMemo(() => { if (!invite) return "Continue"; + if (isCurrentMember) return "Open company"; if (invite.inviteType === "bootstrap_ceo") return "Accept invite"; if (showsAgentForm) return "Submit request"; return sessionQuery.data ? "Accept invite" : "Continue"; - }, [invite, sessionQuery.data, showsAgentForm]); + }, [invite, isCurrentMember, sessionQuery.data, showsAgentForm]); if (!token) { return Invalid invite token.; @@ -442,7 +447,7 @@ export function InviteLandingPage() { return Opening company...; } - if (inviteJoinRequestStatus === "pending_approval") { + if (inviteJoinRequestStatus === "pending_approval" && !canCompleteAcceptedHumanInvite) { return ( @@ -778,19 +783,21 @@ export function InviteLandingPage() { - {shouldAutoAcceptHumanInvite - ? "Submitting join request" + {isCurrentMember + ? "Already in this company" + : shouldAutoAcceptHumanInvite + ? "Completing company access" : invite.inviteType === "bootstrap_ceo" ? "Accept bootstrap invite" : "Accept company invite"} {shouldAutoAcceptHumanInvite - ? `Submitting your join request for ${companyDisplayName}.` + ? `Granting your access to ${companyDisplayName}.` : isCurrentMember ? `This account already belongs to ${companyDisplayName}.` : `This will ${ - invite.inviteType === "bootstrap_ceo" ? "finish setting up Paperclip" : `submit or complete your join request for ${companyDisplayName}` + invite.inviteType === "bootstrap_ceo" ? "finish setting up Paperclip" : `grant or complete your access to ${companyDisplayName}` }.`} @@ -802,8 +809,16 @@ export function InviteLandingPage() { ) : ( acceptMutation.mutate()} + disabled={acceptMutation.isPending} + onClick={() => { + if (isCurrentMember && invite.companyId) { + clearPendingInviteToken(token); + setSelectedCompanyId(invite.companyId, { source: "manual" }); + navigate("/", { replace: true }); + return; + } + acceptMutation.mutate(); + }} > {acceptMutation.isPending ? "Working..." : joinButtonLabel} diff --git a/ui/src/pages/InviteUxLab.tsx b/ui/src/pages/InviteUxLab.tsx index 9a356d0a..38bcd24c 100644 --- a/ui/src/pages/InviteUxLab.tsx +++ b/ui/src/pages/InviteUxLab.tsx @@ -371,10 +371,10 @@ function AcceptInvitePreview({ Accept company invite {autoAccept - ? "Submitting your join request for Acme Robotics." + ? "Granting your access to Acme Robotics." : isCurrentMember ? "This account already belongs to Acme Robotics." - : "This will submit or complete your join request for Acme Robotics."} + : "This will grant or complete your access to Acme Robotics."} {error ? {error} : null} @@ -572,7 +572,7 @@ function CompanyInvitesPreview() { - Each invite link is single-use. The first successful use consumes the link and creates or reuses the matching join request before approval. + Each invite link is single-use. Human invitees get the selected role immediately after sign-in; agent invites still create a join request for approval. diff --git a/ui/src/pages/Projects.test.tsx b/ui/src/pages/Projects.test.tsx new file mode 100644 index 00000000..185db766 --- /dev/null +++ b/ui/src/pages/Projects.test.tsx @@ -0,0 +1,164 @@ +// @vitest-environment jsdom + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { Project } from "@paperclipai/shared"; +import { act, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Projects } from "./Projects"; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const mockProjectsApi = vi.hoisted(() => ({ + list: vi.fn(), +})); +const mockOpenNewProject = vi.hoisted(() => vi.fn()); +const mockSetBreadcrumbs = vi.hoisted(() => vi.fn()); + +vi.mock("../api/projects", () => ({ projectsApi: mockProjectsApi })); +vi.mock("@/lib/router", () => ({ + Link: ({ children, to }: { children?: ReactNode; to: string }) => {children}, +})); +vi.mock("../context/CompanyContext", () => ({ + useCompany: () => ({ selectedCompanyId: "company-1" }), +})); +vi.mock("../context/DialogContext", () => ({ + useDialogActions: () => ({ openNewProject: mockOpenNewProject }), +})); +vi.mock("../context/BreadcrumbContext", () => ({ + useBreadcrumbs: () => ({ setBreadcrumbs: mockSetBreadcrumbs }), +})); + +function project(overrides: Partial): Project { + const now = new Date("2026-05-01T00:00:00Z"); + return { + id: "project-1", + companyId: "company-1", + urlKey: "project-1", + goalId: null, + goalIds: [], + goals: [], + name: "Project", + description: null, + status: "in_progress", + leadAgentId: null, + targetDate: null, + color: "#14b8a6", + env: null, + pauseReason: null, + pausedAt: null, + executionWorkspacePolicy: null, + codebase: { + workspaceId: null, + repoUrl: null, + repoRef: null, + defaultRef: null, + repoName: null, + localFolder: null, + managedFolder: "/tmp/project-1", + effectiveLocalFolder: "/tmp/project-1", + origin: "managed_checkout", + }, + workspaces: [], + primaryWorkspace: null, + archivedAt: null, + createdAt: now, + updatedAt: now, + ...overrides, + }; +} + +async function renderProjects(container: HTMLElement) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + let root: Root | null = null; + + await act(async () => { + root = createRoot(container); + root.render( + + + , + ); + }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + return root; +} + +function projectLinkNames(container: HTMLElement): string[] { + return Array.from(container.querySelectorAll("a[href^='/projects/']")).map((link) => { + const title = link.querySelector("span.truncate"); + return title?.textContent ?? ""; + }); +} + +describe("Projects", () => { + let root: Root | null = null; + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + mockProjectsApi.list.mockResolvedValue([ + project({ + id: "project-bravo", + urlKey: "bravo", + name: "Bravo", + description: null, + updatedAt: new Date("2026-05-02T00:00:00Z"), + }), + project({ + id: "project-alpha", + urlKey: "alpha", + name: "Alpha", + description: "First project", + updatedAt: new Date("2026-05-01T00:00:00Z"), + }), + project({ + id: "project-charlie", + urlKey: "charlie", + name: "Charlie", + description: null, + updatedAt: new Date("2026-05-03T00:00:00Z"), + }), + ]); + }); + + afterEach(() => { + act(() => root?.unmount()); + root = null; + container.remove(); + vi.clearAllMocks(); + }); + + it("sorts projects by name by default and can switch sort mode", async () => { + root = await renderProjects(container); + + expect(projectLinkNames(container)).toEqual(["Alpha", "Bravo", "Charlie"]); + + const select = container.querySelector("select"); + expect(select).not.toBeNull(); + + await act(async () => { + select!.value = "updated"; + select!.dispatchEvent(new Event("change", { bubbles: true })); + }); + + expect(projectLinkNames(container)).toEqual(["Charlie", "Bravo", "Alpha"]); + }); + + it("reserves description line height for projects without descriptions", async () => { + root = await renderProjects(container); + + const bravoLink = Array.from(container.querySelectorAll("a")).find((link) => + link.textContent?.includes("Bravo"), + ); + const hiddenDescriptionLine = bravoLink?.querySelector("p[aria-hidden='true']"); + + expect(hiddenDescriptionLine).not.toBeNull(); + expect(hiddenDescriptionLine?.className).toContain("min-h-4"); + }); +}); diff --git a/ui/src/pages/Projects.tsx b/ui/src/pages/Projects.tsx index 08d246a8..a26c17dd 100644 --- a/ui/src/pages/Projects.tsx +++ b/ui/src/pages/Projects.tsx @@ -1,5 +1,6 @@ -import { useEffect, useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; +import type { Project } from "@paperclipai/shared"; import { projectsApi } from "../api/projects"; import { useCompany } from "../context/CompanyContext"; import { useDialogActions } from "../context/DialogContext"; @@ -11,12 +12,67 @@ import { EmptyState } from "../components/EmptyState"; import { PageSkeleton } from "../components/PageSkeleton"; import { formatDate, projectUrl } from "../lib/utils"; import { Button } from "@/components/ui/button"; -import { Hexagon, Plus } from "lucide-react"; +import { ArrowUpDown, Hexagon, Plus } from "lucide-react"; + +type ProjectSortMode = "name" | "updated" | "targetDate" | "status"; + +const PROJECT_SORT_LABELS: Record = { + name: "Name", + updated: "Recently updated", + targetDate: "Target date", + status: "Status", +}; + +const PROJECT_STATUS_RANK: Record = { + 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 { + 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 sortProjects(projects: Project[], sortMode: ProjectSortMode): Project[] { + return [...projects].sort((left, right) => { + if (sortMode === "updated") { + const updatedDiff = projectTime(right, "updatedAt") - projectTime(left, "updatedAt"); + return updatedDiff !== 0 ? updatedDiff : compareProjectNames(left, right); + } + 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); + }); +} export function Projects() { const { selectedCompanyId } = useCompany(); const { openNewProject } = useDialogActions(); const { setBreadcrumbs } = useBreadcrumbs(); + const [sortMode, setSortMode] = useState("name"); useEffect(() => { setBreadcrumbs([{ label: "Projects" }]); @@ -31,6 +87,10 @@ export function Projects() { () => (allProjects ?? []).filter((p) => !p.archivedAt), [allProjects], ); + const sortedProjects = useMemo( + () => sortProjects(projects, sortMode), + [projects, sortMode], + ); if (!selectedCompanyId) { return ; @@ -42,7 +102,22 @@ export function Projects() { return ( - + + + + Sort + setSortMode(event.target.value as ProjectSortMode)} + > + {(Object.keys(PROJECT_SORT_LABELS) as ProjectSortMode[]).map((value) => ( + + {PROJECT_SORT_LABELS[value]} + + ))} + + Add Project @@ -62,11 +137,12 @@ export function Projects() { {projects.length > 0 && ( - {projects.map((project) => ( + {sortedProjects.map((project) => ( diff --git a/ui/src/plugins/slots.test.ts b/ui/src/plugins/slots.test.ts new file mode 100644 index 00000000..5fb7b147 --- /dev/null +++ b/ui/src/plugins/slots.test.ts @@ -0,0 +1,87 @@ +// @vitest-environment jsdom + +import { createElement } from "react"; +import { flushSync } from "react-dom"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it } from "vitest"; +import { + PluginSlotMount, + _collectRegisterableExportNamesForTests, + _resetPluginModuleLoader, + registerPluginWebComponent, + type ResolvedPluginSlot, +} from "./slots"; + +let roots: Root[] = []; + +afterEach(() => { + for (const root of roots) { + flushSync(() => { + root.unmount(); + }); + } + roots = []; + _resetPluginModuleLoader(); +}); + +describe("plugin slot export registration", () => { + it("keeps declared missing exports visible for diagnostics", () => { + const exports = _collectRegisterableExportNamesForTests( + { Page: () => null }, + new Set(["Page", "MissingRouteSidebar"]), + ); + + expect([...exports]).toEqual(["Page", "MissingRouteSidebar"]); + }); + + it("registers component-like module exports even when the current contribution did not declare them", () => { + const exports = _collectRegisterableExportNamesForTests( + { + Page: () => null, + RouteSidebar: () => null, + webComponentTag: "paperclip-widget", + metadata: { ignored: true }, + count: 1, + default: () => null, + }, + new Set(["Page"]), + ); + + expect(exports).toEqual(new Set(["Page", "RouteSidebar", "webComponentTag"])); + }); + + it("updates an already-mounted placeholder when the slot export registers later", async () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + roots.push(root); + const slot: ResolvedPluginSlot = { + type: "routeSidebar", + id: "content-machine-sidebar", + displayName: "Content", + exportName: "ContentMachineRouteSidebar", + routePath: "content-machine", + pluginId: "content-machine-plugin", + pluginKey: "content-machine", + pluginDisplayName: "Content Machine", + pluginVersion: "1.0.0", + }; + + flushSync(() => { + root.render(createElement(PluginSlotMount, { + slot, + context: { companyId: "company-1", companyPrefix: "PAP" }, + missingBehavior: "placeholder", + })); + }); + + expect(container.textContent).toContain("Content Machine: Content"); + + flushSync(() => { + registerPluginWebComponent("content-machine", "ContentMachineRouteSidebar", "paperclip-test-sidebar"); + }); + + expect(container.textContent).not.toContain("Content Machine: Content"); + expect(container.querySelector("paperclip-test-sidebar")).not.toBeNull(); + }); +}); diff --git a/ui/src/plugins/slots.tsx b/ui/src/plugins/slots.tsx index ecc5f72d..82cbeb2c 100644 --- a/ui/src/plugins/slots.tsx +++ b/ui/src/plugins/slots.tsx @@ -124,11 +124,30 @@ type UsePluginSlotsResult = { * Keys are `${pluginKey}:${exportName}` to match manifest slot declarations. */ const registry = new Map(); +const registryListeners = new Set<() => void>(); function buildRegistryKey(pluginKey: string, exportName: string): string { return `${pluginKey}:${exportName}`; } +function notifyRegistryListeners(): void { + for (const listener of registryListeners) { + listener(); + } +} + +function usePluginRegistrySubscription(): void { + const [, forceRerender] = useState(0); + + useEffect(() => { + const listener = () => forceRerender((tick) => tick + 1); + registryListeners.add(listener); + return () => { + registryListeners.delete(listener); + }; + }, []); +} + function requiresEntityType(slotType: PluginUiSlotType): boolean { return slotType === "detailTab" || slotType === "taskDetailView" || slotType === "contextMenuItem" || slotType === "commentAnnotation" || slotType === "commentContextMenuItem" || slotType === "projectSidebarItem" || slotType === "toolbarButton"; } @@ -150,6 +169,7 @@ export function registerPluginReactComponent( kind: "react", component, }); + notifyRegistryListeners(); } /** @@ -164,6 +184,7 @@ export function registerPluginWebComponent( kind: "web-component", tagName, }); + notifyRegistryListeners(); } function resolveRegisteredComponent(slot: ResolvedPluginSlot): RegisteredPluginComponent | null { @@ -177,6 +198,24 @@ export function resolveRegisteredPluginComponent( return registry.get(buildRegistryKey(pluginKey, exportName)) ?? null; } +function isRegisterablePluginExport(exported: unknown): boolean { + return typeof exported === "function" || typeof exported === "string"; +} + +function collectRegisterableExportNames( + mod: Record, + declaredExports: Set, +): Set { + const exportNames = new Set(declaredExports); + for (const [exportName, exported] of Object.entries(mod)) { + if (exportName === "default") continue; + if (isRegisterablePluginExport(exported)) { + exportNames.add(exportName); + } + } + return exportNames; +} + // --------------------------------------------------------------------------- // Plugin module dynamic import loader // --------------------------------------------------------------------------- @@ -471,7 +510,8 @@ async function loadPluginModule(contribution: PluginUiContribution): Promise
{subtitle}
+ {subtitle} +
- Review human join requests before they become active company members. + Review pending join requests before they become active company members.
{shouldAutoAcceptHumanInvite - ? `Submitting your join request for ${companyDisplayName}.` + ? `Granting your access to ${companyDisplayName}.` : isCurrentMember ? `This account already belongs to ${companyDisplayName}.` : `This will ${ - invite.inviteType === "bootstrap_ceo" ? "finish setting up Paperclip" : `submit or complete your join request for ${companyDisplayName}` + invite.inviteType === "bootstrap_ceo" ? "finish setting up Paperclip" : `grant or complete your access to ${companyDisplayName}` }.`}
{autoAccept - ? "Submitting your join request for Acme Robotics." + ? "Granting your access to Acme Robotics." : isCurrentMember ? "This account already belongs to Acme Robotics." - : "This will submit or complete your join request for Acme Robotics."} + : "This will grant or complete your access to Acme Robotics."}
{error}