diff --git a/doc/PRODUCT.md b/doc/PRODUCT.md index c858223d9a5..7c739557ec9 100644 --- a/doc/PRODUCT.md +++ b/doc/PRODUCT.md @@ -128,7 +128,7 @@ Paperclip’s core identity is a **control plane for autonomous AI companies**, - Do not make the core product a general chat app. The current product definition is explicitly task/comment-centric and “not a chatbot,” and that boundary is valuable. - Do not build a complete Jira/GitHub replacement. The repo/docs already position Paperclip as organization orchestration, not focused on pull-request review. - Do not build enterprise-grade RBAC first. Paperclip now has authenticated mode, company memberships, instance roles, and permission grants, but fine-grained enterprise governance should remain secondary to the core company control plane. -- Do not interpret agent-profile privacy flags as project/issue privacy. Work remains company-open by default, while explicitly private issues and projects use their own task ACLs. +- Do not interpret agent-level privacy flags as a project/issue privacy feature in V1; work visibility stays company-scoped. - Do not lead with raw bash logs and transcripts. Default view should be human-readable intent/progress, with raw detail beneath. - Do not force users to understand provider/API-key plumbing unless absolutely necessary. There are active onboarding/auth issues already; friction here is clearly real. diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 5b0ee4fc3ef..b4f0671acc8 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -10,9 +10,6 @@ import type { FeedbackTrace, FeedbackVote, Issue, - IssueAccessGrant, - IssueAccessGrantSubjectType, - IssueVisibility, IssueChanges, IssueAttachment, IssueCostSummary, @@ -589,23 +586,8 @@ export const issuesApi = { form, ); }, - deleteAttachment: (id: string) => api.delete<{ ok: true }>(`/attachments/${id}`), - // --- Privacy / sharing (PAP-16066) ------------------------------------- - // Enriched grants for the share sheet: implicit-by-source rows (assignment / - // project) plus explicit grants, each carrying subjectDisplayName / avatar / - // agentVisibility from the server enrichment pass. - listAccessGrants: (id: string, options?: RequestOptions) => - options - ? api.get(`/issues/${id}/access-grants`, options) - : api.get(`/issues/${id}/access-grants`), - createAccessGrant: ( - id: string, - data: { subjectType: IssueAccessGrantSubjectType; subjectId: string }, - ) => api.post(`/issues/${id}/access-grants`, data), - revokeAccessGrant: (id: string, grantId: string) => - api.post(`/issues/${id}/access-grants/${grantId}/revoke`, {}), - setVisibility: (id: string, visibility: IssueVisibility) => - api.patch(`/issues/${id}`, { visibility }), + deleteAttachment: (id: string) => + api.delete<{ ok: true }>(`/attachments/${id}`), listApprovals: (id: string) => api.get(`/issues/${id}/approvals`), linkApproval: (id: string, approvalId: string) => api.post(`/issues/${id}/approvals`, { approvalId }), diff --git a/ui/src/api/projects.ts b/ui/src/api/projects.ts index 35b5c33567f..32f296e31c4 100644 --- a/ui/src/api/projects.ts +++ b/ui/src/api/projects.ts @@ -1,6 +1,5 @@ import type { Project, - ProjectAccessMember, ProjectRepositoryOptions, ProjectWorkspace, WorkspaceOperation, @@ -33,17 +32,6 @@ export const projectsApi = { api.post(`/companies/${companyId}/projects`, data), update: (id: string, data: Record, companyId?: string) => api.patch(projectPath(id, companyId), data), - listAccessMembers: (id: string, companyId?: string) => - api.get(projectPath(id, companyId, "/access-members")), - addAccessMember: ( - id: string, - data: { subjectType: "user" | "agent"; subjectId: string }, - companyId?: string, - ) => api.post(projectPath(id, companyId, "/access-members"), data), - removeAccessMember: (id: string, memberId: string, companyId?: string) => - api.delete( - projectPath(id, companyId, `/access-members/${encodeURIComponent(memberId)}`), - ), listWorkspaces: (projectId: string, companyId?: string) => api.get(projectPath(projectId, companyId, "/workspaces")), createWorkspace: (projectId: string, data: Record, companyId?: string) => diff --git a/ui/src/components/IssueBlockedNotice.tsx b/ui/src/components/IssueBlockedNotice.tsx index b543c5948a8..0eff9d6e23e 100644 --- a/ui/src/components/IssueBlockedNotice.tsx +++ b/ui/src/components/IssueBlockedNotice.tsx @@ -14,7 +14,6 @@ import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb"; import { formatMonitorOffset } from "../lib/issue-monitor"; import { useRetryNowMutation } from "../hooks/useRetryNowMutation"; import { IssueLinkQuicklook } from "./IssueLinkQuicklook"; -import { LockedIssueChip, isLockedIssueStub } from "./LockedIssueChip"; import { RetryErrorBand } from "./IssueScheduledRetryCard"; import { isAssignedBacklogBlocker, @@ -545,18 +544,6 @@ export function IssueBlockedNotice({ const reopenSuppressedOtherCount = Math.max(unresolvedLeafBlockers.length - 1, 0); const renderBlockerChip = (blocker: IssueRelationIssueSummary) => { - // A private blocker arrives as a locked stub (no title/status). Show the - // locked chip plus the access note instead of a link into a 404. - if (isLockedIssueStub(blocker)) { - return ( - - - - Private — you don't have access - - - ); - } const issuePathId = blocker.identifier ?? blocker.id; const recoveryAction = blocker.activeRecoveryAction ?? null; return ( diff --git a/ui/src/components/IssuePrivacyActions.tsx b/ui/src/components/IssuePrivacyActions.tsx deleted file mode 100644 index a9590e45ce6..00000000000 --- a/ui/src/components/IssuePrivacyActions.tsx +++ /dev/null @@ -1,175 +0,0 @@ -import { useState } from "react"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { Globe, Lock, Users } from "lucide-react"; -import type { Issue, IssueVisibility } from "@paperclipai/shared"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; -import { issuesApi } from "@/api/issues"; -import { useToastActions } from "@/context/ToastContext"; -import { queryKeys } from "@/lib/queryKeys"; -import { cn } from "@/lib/utils"; -import { IssueShareSheet, type ShareSheetImplicitPrincipal } from "./IssueShareSheet"; - -const MENU_ITEM_CLASS = - "flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent"; - -const NON_SETTER_TOOLTIP = - "Only the task owner or an admin can change who can see this task."; - -/** - * Privacy actions for the task `⋯` menu. Renders the menu buttons plus the - * two dialogs. The render prop keeps this component outside the menu's - * unmounting content, so dialogs survive the menu closing. `closeMenu` collapses the parent popover when an item that - * opens a dialog is clicked. - * - * Setter rules (locked decision, server-gated via - * `resolveIssuePrivacyManagementRoot`): only the responsible user + admins can - * change visibility or grants. Non-setters see the items disabled with a - * tooltip. Make-**public** always confirms (one-way disclosure); make-private - * never does. - */ -export function IssuePrivacyActions({ - issue, - companyId, - canManage, - closeMenu, - implicitPrincipals = [], - children, -}: { - issue: Pick; - companyId: string; - canManage: boolean; - closeMenu: () => void; - implicitPrincipals?: ShareSheetImplicitPrincipal[]; - children: (menuItems: React.ReactNode) => React.ReactNode; -}) { - const queryClient = useQueryClient(); - const { pushToast } = useToastActions(); - const [shareOpen, setShareOpen] = useState(false); - const [makePublicOpen, setMakePublicOpen] = useState(false); - const isPrivate = issue.visibility === "private"; - - const visibilityMutation = useMutation({ - mutationFn: (visibility: IssueVisibility) => issuesApi.setVisibility(issue.id, visibility), - onSuccess: (_result, visibility) => { - queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issue.id) }); - if (issue.identifier) { - queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issue.identifier) }); - } - queryClient.invalidateQueries({ queryKey: queryKeys.issues.accessGrants(issue.id) }); - setMakePublicOpen(false); - pushToast({ - title: visibility === "private" ? "Task is now private" : "Task is now public", - tone: "success", - }); - }, - onError: (error) => { - pushToast({ title: "Couldn't change visibility", body: (error as Error).message, tone: "error" }); - }, - }); - - function withTooltip(node: React.ReactNode) { - if (canManage) return node; - return ( - - - {node} - - {NON_SETTER_TOOLTIP} - - ); - } - - return ( - - {children(isPrivate ? ( - <> - {withTooltip( - , - )} - {withTooltip( - , - )} - - ) : ( - withTooltip( - , - ) - ))} - - - - - - - Make this task public? - - Everyone in the company will be able to read this task, its comments, documents, and - run history. Existing private subtasks keep their privacy.{" "} - Content already seen by others cannot be taken back. - - - - Keep private - { - event.preventDefault(); - visibilityMutation.mutate("open"); - }} - disabled={visibilityMutation.isPending} - > - {visibilityMutation.isPending ? "Making public…" : "Make public"} - - - - - - ); -} diff --git a/ui/src/components/IssueReferencePill.tsx b/ui/src/components/IssueReferencePill.tsx index e47c8fb142f..e376db9444d 100644 --- a/ui/src/components/IssueReferencePill.tsx +++ b/ui/src/components/IssueReferencePill.tsx @@ -1,9 +1,8 @@ import { X } from "lucide-react"; import type { ReactNode } from "react"; -import type { IssueLockedStub, IssueRelationIssueSummary } from "@paperclipai/shared"; +import type { IssueRelationIssueSummary } from "@paperclipai/shared"; import { Link } from "@/lib/router"; import { cn } from "../lib/utils"; -import { LockedIssueChip, isLockedIssueStub } from "./LockedIssueChip"; import { badgeVariants } from "./ui/badge"; import { StatusIcon } from "./StatusIcon"; @@ -15,10 +14,8 @@ export function IssueReferencePill({ onRemove, variant = "mention", }: { - issue: - | (Pick & - { status?: string }) - | IssueLockedStub; + issue: Pick & + { status?: string }; strikethrough?: boolean; variant?: "mention" | "property"; className?: string; @@ -26,10 +23,6 @@ export function IssueReferencePill({ /** Reserves space for a separate hover/focus action without moving the task link. */ onRemove?: (issueId: string) => void; }) { - // Private issue referenced from a visible edge — never leak a title or a link. - if (isLockedIssueStub(issue)) { - return ; - } const issueLabel = issue.identifier ?? issue.title; const classNames = cn( variant === "property" || onRemove diff --git a/ui/src/components/IssueShareSheet.test.tsx b/ui/src/components/IssueShareSheet.test.tsx deleted file mode 100644 index 20dbeeac4be..00000000000 --- a/ui/src/components/IssueShareSheet.test.tsx +++ /dev/null @@ -1,157 +0,0 @@ -// @vitest-environment jsdom - -import { flushSync } from "react-dom"; -import { createRoot, type Root } from "react-dom/client"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { IssueAccessGrant } from "@paperclipai/shared"; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; - -function act(callback: () => void | Promise) { - let result: void | Promise | undefined; - flushSync(() => { - result = callback(); - }); - return result; -} - -const listAccessGrants = vi.fn(); -const createAccessGrant = vi.fn(); -const revokeAccessGrant = vi.fn(); - -vi.mock("@/api/issues", () => ({ - issuesApi: { - listAccessGrants: (...args: unknown[]) => listAccessGrants(...args), - createAccessGrant: (...args: unknown[]) => createAccessGrant(...args), - revokeAccessGrant: (...args: unknown[]) => revokeAccessGrant(...args), - }, -})); -vi.mock("@/api/access", () => ({ - accessApi: { listUserDirectory: vi.fn().mockResolvedValue({ users: [] }) }, -})); -vi.mock("@/api/agents", () => ({ - agentsApi: { list: vi.fn().mockResolvedValue([]) }, -})); -vi.mock("@/context/ToastContext", () => ({ - useToastActions: () => ({ pushToast: vi.fn() }), -})); - -import { IssueShareSheet } from "./IssueShareSheet"; - -function grant(overrides: Partial): IssueAccessGrant { - return { - id: "g", - issueId: "i1", - subjectType: "user", - subjectId: "u1", - source: "explicit", - grantedByUserId: null, - grantedByAgentId: null, - createdAt: new Date("2026-08-01T00:00:00Z"), - revokedAt: null, - subjectDisplayName: "Ada", - subjectAvatarUrl: null, - subjectInitials: "A", - agentVisibility: null, - ...overrides, - }; -} - -// React Query resolves its mocked promises on the microtask/macrotask queue, -// outside flushSync. Interleave real awaits with flushSync to let the observer -// receive data and re-render. -async function settle(times = 6) { - for (let i = 0; i < times; i += 1) { - await Promise.resolve(); - await new Promise((resolve) => setTimeout(resolve, 0)); - flushSync(() => {}); - } -} - -describe("IssueShareSheet", () => { - let container: HTMLDivElement; - let root: Root | null; - - beforeEach(() => { - container = document.createElement("div"); - document.body.appendChild(container); - root = null; - listAccessGrants.mockReset(); - createAccessGrant.mockReset(); - revokeAccessGrant.mockReset(); - }); - - afterEach(() => { - if (root) act(() => root?.unmount()); - container.remove(); - document.body.innerHTML = ""; - }); - - async function renderSheet(canManage = true) { - const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - root = createRoot(container); - act(() => { - root!.render( - - {}} - /> - , - ); - }); - await settle(); - } - - it("renders a source badge per grant and gates Revoke by source", async () => { - listAccessGrants.mockResolvedValue([ - grant({ id: "g1", source: "explicit", subjectDisplayName: "Ada" }), - grant({ id: "g2", source: "assignment", subjectType: "agent", subjectId: "a1", subjectDisplayName: "Helper" }), - grant({ id: "g3", source: "project", subjectDisplayName: "Proj User" }), - ]); - await renderSheet(true); - - // Body is portalled to document.body by Radix Dialog. - const scope = document.body; - expect(scope.querySelector('[data-testid="grant-source-badge-explicit"]')).not.toBeNull(); - expect(scope.querySelector('[data-testid="grant-source-badge-assignment"]')).not.toBeNull(); - expect(scope.querySelector('[data-testid="grant-source-badge-project"]')).not.toBeNull(); - - const revokeButtons = [...scope.querySelectorAll("button")].filter((b) => - b.textContent?.trim() === "Revoke", - ); - // explicit + assignment are revocable; project is not. - expect(revokeButtons).toHaveLength(2); - expect(scope.textContent).toContain("project-managed"); - }); - - it("hides Revoke entirely for non-setters", async () => { - listAccessGrants.mockResolvedValue([grant({ id: "g1", source: "explicit" })]); - await renderSheet(false); - const revokeButtons = [...document.body.querySelectorAll("button")].filter((b) => - b.textContent?.trim() === "Revoke", - ); - expect(revokeButtons).toHaveLength(0); - }); - - it("shows the empty state when there are no grants", async () => { - listAccessGrants.mockResolvedValue([]); - await renderSheet(true); - expect(document.body.querySelector('[data-testid="share-sheet-empty"]')?.textContent).toContain( - "Only you can see this task", - ); - }); - - it("ignores revoked grants", async () => { - listAccessGrants.mockResolvedValue([ - grant({ id: "g1", source: "explicit", revokedAt: new Date("2026-08-01T01:00:00Z") }), - ]); - await renderSheet(true); - expect(document.body.querySelector('[data-testid="share-sheet-empty"]')).not.toBeNull(); - }); -}); diff --git a/ui/src/components/IssueShareSheet.tsx b/ui/src/components/IssueShareSheet.tsx deleted file mode 100644 index 60200cd80c1..00000000000 --- a/ui/src/components/IssueShareSheet.tsx +++ /dev/null @@ -1,500 +0,0 @@ -import { useMemo, useState } from "react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { - AlertTriangle, - Check, - Folder, - Lock, - RotateCcw, - UserPlus, -} from "lucide-react"; -import type { IssueAccessGrant, IssueAccessGrantSource } from "@paperclipai/shared"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { SearchableSelect, type SearchableSelectGroup } from "@/components/SearchableSelect"; -import { Skeleton } from "@/components/ui/skeleton"; -import { agentsApi } from "@/api/agents"; -import { accessApi } from "@/api/access"; -import { issuesApi } from "@/api/issues"; -import { useToastActions } from "@/context/ToastContext"; -import { queryKeys } from "@/lib/queryKeys"; -import { timeAgo } from "@/lib/timeAgo"; -import { cn } from "@/lib/utils"; -import { - agentVisibilityFromPermissions, - grantIsRevocable, - isSharedAgentVisibility, -} from "@/lib/issuePrivacy"; - -/** - * A role-based principal that can read the task by virtue of *who they are* - * (responsible user, creator, current assignee) rather than an explicit grant. - * Implicit principals are shown with a muted role badge and have NO revoke — - * you change their access by changing the role, not from the share sheet. - * The caller derives these (it holds the agent/user directories); the sheet - * only renders them. - */ -export interface ShareSheetImplicitPrincipal { - id: string; - displayName: string; - roleLabel: string; - subtitle?: string | null; - avatarUrl?: string | null; - initials?: string | null; -} - -interface SourceBadgeSpec { - label: string; - icon: typeof UserPlus; - className: string; -} - -// Source badges map 1:1 to `grant.source`. Colors mirror the approved -// wireframe: explicit = blue, assignment = green, project = violet. -const SOURCE_BADGES: Record = { - owner: { label: "task owner", icon: Lock, className: "border-border bg-muted text-muted-foreground" }, - explicit: { - label: "shared directly", - icon: UserPlus, - className: - "border-blue-300/70 bg-blue-50 text-blue-800 dark:border-blue-500/30 dark:bg-blue-500/10 dark:text-blue-300", - }, - assignment: { - label: "via assignment", - icon: Check, - className: - "border-emerald-300/70 bg-emerald-50 text-emerald-800 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-300", - }, - project: { - label: "via project", - icon: Folder, - className: - "border-violet-300/70 bg-violet-50 text-violet-800 dark:border-violet-500/30 dark:bg-violet-500/10 dark:text-violet-300", - }, -}; - -function SourceBadge({ source }: { source: IssueAccessGrantSource }) { - const spec = SOURCE_BADGES[source]; - const Icon = spec.icon; - return ( - - - ); -} - -function PrincipalAvatar({ - displayName, - avatarUrl, - initials, -}: { - displayName: string; - avatarUrl?: string | null; - initials?: string | null; -}) { - return ( - - {avatarUrl ? : null} - {initials ?? displayName.slice(0, 2).toUpperCase()} - - ); -} - -function RowShell({ - avatar, - title, - subtitle, - badge, - action, -}: { - avatar: React.ReactNode; - title: React.ReactNode; - subtitle?: React.ReactNode; - badge?: React.ReactNode; - action?: React.ReactNode; -}) { - return ( -
- {avatar} -
-
- {title} - {badge} -
- {subtitle ?
{subtitle}
: null} -
- {action ?
{action}
: null} -
- ); -} - -export function IssueShareSheet({ - issueId, - companyId, - canManage, - open, - onOpenChange, - aclDescription = "Sharing grants access to this task and its descendants, including comments, documents, and run history. Parents and siblings need separate access.", - implicitPrincipals = [], - initialView = "list", - initialAddSelection = "", -}: { - issueId: string; - companyId: string; - canManage: boolean; - open: boolean; - onOpenChange: (open: boolean) => void; - aclDescription?: string; - implicitPrincipals?: ShareSheetImplicitPrincipal[]; - /** Initial panel — `add` opens straight into the add flow (stories/tests). */ - initialView?: "list" | "add"; - /** Pre-selected add subject (`type:id`), used by stories/tests to show the caution. */ - initialAddSelection?: string; -}) { - const queryClient = useQueryClient(); - const { pushToast } = useToastActions(); - const [view, setView] = useState<"list" | "add">(initialView); - const [addSelection, setAddSelection] = useState(initialAddSelection); - const [revokeTarget, setRevokeTarget] = useState(null); - - const grantsQuery = useQuery({ - queryKey: queryKeys.issues.accessGrants(issueId), - queryFn: () => issuesApi.listAccessGrants(issueId), - enabled: open, - }); - const directoryQuery = useQuery({ - queryKey: queryKeys.access.companyUserDirectory(companyId), - queryFn: () => accessApi.listUserDirectory(companyId), - enabled: open && view === "add" && canManage, - }); - const agentsQuery = useQuery({ - queryKey: queryKeys.agents.list(companyId), - queryFn: () => agentsApi.list(companyId), - enabled: open && view === "add" && canManage, - }); - - const activeGrants = useMemo( - () => (grantsQuery.data ?? []).filter((grant) => grant.revokedAt === null), - [grantsQuery.data], - ); - const grantedSubjectKeys = useMemo( - () => new Set(activeGrants.map((grant) => `${grant.subjectType}:${grant.subjectId}`)), - [activeGrants], - ); - - const addMutation = useMutation({ - mutationFn: (payload: { subjectType: "user" | "agent"; subjectId: string }) => - issuesApi.createAccessGrant(issueId, payload), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.issues.accessGrants(issueId) }); - setAddSelection(""); - setView("list"); - pushToast({ title: "Access granted", tone: "success" }); - }, - onError: (error) => { - pushToast({ title: "Couldn't add access", body: (error as Error).message, tone: "error" }); - }, - }); - - const revokeMutation = useMutation({ - mutationFn: (grant: IssueAccessGrant) => issuesApi.revokeAccessGrant(issueId, grant.id), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.issues.accessGrants(issueId) }); - setRevokeTarget(null); - pushToast({ title: "Access revoked", tone: "success" }); - }, - onError: (error) => { - pushToast({ title: "Couldn't revoke access", body: (error as Error).message, tone: "error" }); - }, - }); - - // Candidate subjects for the "Add someone" combobox, minus anyone who already - // holds an active grant. Value encodes both type and id: `type:id`. - const addGroups = useMemo(() => { - const users = (directoryQuery.data?.users ?? []) - .filter((entry) => entry.user && !grantedSubjectKeys.has(`user:${entry.user.id}`)) - .map((entry) => ({ - key: `user:${entry.user!.id}`, - value: `user:${entry.user!.id}`, - label: entry.user!.name ?? entry.user!.email ?? "Unknown user", - searchText: [entry.user!.name, entry.user!.email].filter(Boolean).join(" "), - })); - const agents = (agentsQuery.data ?? []) - .filter((agent) => !grantedSubjectKeys.has(`agent:${agent.id}`)) - .map((agent) => ({ - key: `agent:${agent.id}`, - value: `agent:${agent.id}`, - label: agent.name, - searchText: agent.name, - })); - const groups: SearchableSelectGroup[] = []; - if (users.length > 0) groups.push({ id: "people", label: "People", options: users }); - if (agents.length > 0) groups.push({ id: "agents", label: "Agents", options: agents }); - return groups; - }, [directoryQuery.data, agentsQuery.data, grantedSubjectKeys]); - - // Is the currently-selected add subject a *shared* agent? Drives the caution. - const selectedSharedAgentName = useMemo(() => { - if (!addSelection.startsWith("agent:")) return null; - const agentId = addSelection.slice("agent:".length); - const agent = (agentsQuery.data ?? []).find((candidate) => candidate.id === agentId); - if (!agent) return null; - return isSharedAgentVisibility(agentVisibilityFromPermissions(agent.permissions)) - ? agent.name - : null; - }, [addSelection, agentsQuery.data]); - - function resetAndClose(next: boolean) { - if (!next) { - setView("list"); - setAddSelection(""); - setRevokeTarget(null); - } - onOpenChange(next); - } - - function submitAdd() { - if (!addSelection) return; - const [subjectType, subjectId] = addSelection.split(/:(.+)/) as ["user" | "agent", string]; - if (!subjectId) return; - addMutation.mutate({ subjectType, subjectId }); - } - - const isLoading = grantsQuery.isLoading; - const isError = grantsQuery.isError; - const isEmpty = !isLoading && !isError && activeGrants.length === 0 && implicitPrincipals.length === 0; - - return ( - - - - - - {aclDescription} - - - {view === "list" ? ( -
- {isLoading ? ( -
- {[0, 1, 2].map((row) => ( -
- -
- - -
-
- ))} -
- ) : isError ? ( -
- - - -
- ) : isEmpty ? ( -

- Only you can see this task. -

- ) : ( -
- {implicitPrincipals.filter(principal => !activeGrants.some(grant => grant.subjectId === principal.id || `${grant.subjectType}:${grant.subjectId}` === principal.id)).map((principal) => ( - - } - title={principal.displayName} - subtitle={principal.subtitle ?? undefined} - badge={ - - {principal.roleLabel} - - } - /> - ))} - {activeGrants.map((grant) => { - const displayName = grant.subjectDisplayName ?? "Unknown"; - const revocable = canManage && !grant.inherited && grantIsRevocable(grant.source); - const granter = grant.source === "explicit" ? "Shared" : "Granted"; - return ( - - } - title={displayName} - subtitle={grant.inherited ? "Inherited access — managed at its source" : `${granter} ${timeAgo(grant.createdAt)}`} - badge={} - action={ - revocable ? ( - - ) : grant.source === "project" ? ( - - project-managed - - ) : null - } - /> - ); - })} -
- )} - - {canManage ? ( -
- -
- ) : null} -
- ) : ( -
- setAddSelection(value)} - placeholder="Choose a person or agent…" - searchPlaceholder="Search people and agents…" - loading={directoryQuery.isLoading || agentsQuery.isLoading} - loadingMessage="Loading directory…" - emptyMessage="No one left to add." - triggerClassName="w-full" - /> - - {selectedSharedAgentName ? ( -
-
- ) : null} - -
-
- -
- - -
-
- )} -
- - { - if (!next) setRevokeTarget(null); - }} - > - - - - Revoke {revokeTarget?.subjectDisplayName ?? "this subject"}'s access? - - - This removes this grant and the access it supplies to this task's descendants. - Ownership, current assignment, project membership, inherited access, or another grant - can still provide access. Revoking access does not erase - content already seen or retained in an agent's memory. - - - - Cancel - { - event.preventDefault(); - if (revokeTarget) revokeMutation.mutate(revokeTarget); - }} - disabled={revokeMutation.isPending} - > - {revokeMutation.isPending ? "Revoking…" : "Revoke access"} - - - - -
- ); -} diff --git a/ui/src/components/LockedIssueChip.test.tsx b/ui/src/components/LockedIssueChip.test.tsx deleted file mode 100644 index f7c001b2bfd..00000000000 --- a/ui/src/components/LockedIssueChip.test.tsx +++ /dev/null @@ -1,68 +0,0 @@ -// @vitest-environment jsdom - -import { flushSync } from "react-dom"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { LockedIssueChip, isLockedIssueStub } from "./LockedIssueChip"; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; - -function act(callback: () => void) { - flushSync(callback); -} - -describe("LockedIssueChip", () => { - let container: HTMLDivElement; - let root: Root | null; - - beforeEach(() => { - container = document.createElement("div"); - document.body.appendChild(container); - root = null; - }); - - afterEach(() => { - if (root) act(() => root?.unmount()); - container.remove(); - }); - - function renderChip(node: React.ReactNode) { - root = createRoot(container); - act(() => root!.render(node)); - } - - it("shows the identifier and a lock icon, never a title, and is not a link", () => { - renderChip(); - const chip = container.querySelector('[data-testid="locked-issue-chip"]'); - expect(chip).not.toBeNull(); - expect(chip?.tagName).toBe("SPAN"); - expect(container.querySelector("a")).toBeNull(); - expect(chip?.querySelector("svg")).not.toBeNull(); - expect(chip?.textContent).toContain("PAP-1234"); - expect(chip?.getAttribute("aria-label")).toContain("private"); - }); - - it("falls back to a neutral Private label when the identifier is withheld", () => { - renderChip(); - const chip = container.querySelector('[data-testid="locked-issue-chip"]'); - expect(chip?.textContent).toContain("Private"); - }); - - it("uses a dashed, muted border (design-token classes)", () => { - renderChip(); - const chip = container.querySelector('[data-testid="locked-issue-chip"]'); - expect(chip?.className).toContain("border-dashed"); - expect(chip?.className).toContain("text-muted-foreground"); - }); -}); - -describe("isLockedIssueStub", () => { - it("recognizes only objects with locked === true", () => { - expect(isLockedIssueStub({ id: "x", identifier: "PAP-1", locked: true })).toBe(true); - expect(isLockedIssueStub({ id: "x", identifier: "PAP-1", title: "t", status: "todo" })).toBe(false); - expect(isLockedIssueStub({ locked: false })).toBe(false); - expect(isLockedIssueStub(null)).toBe(false); - expect(isLockedIssueStub(undefined)).toBe(false); - }); -}); diff --git a/ui/src/components/LockedIssueChip.tsx b/ui/src/components/LockedIssueChip.tsx deleted file mode 100644 index a21ef082b01..00000000000 --- a/ui/src/components/LockedIssueChip.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { Lock } from "lucide-react"; -import type { IssueLockedStub } from "@paperclipai/shared"; -import { cn } from "../lib/utils"; - -/** - * Existence-only chip for a private issue referenced from a surface the current - * viewer *can* see (a visible blocker/relation edge, or a mention in a visible - * comment). Renders the mono identifier + a lock icon and nothing else — no - * title ever leaks, and the chip is intentionally non-interactive (a private - * issue 404s on direct fetch, so there is nowhere to link to). - * - * Feed it the exact `{ id, identifier, locked: true }` stub the server emits in - * place of an `IssueRelationIssueSummary`; use `isLockedIssueStub` to branch. - */ -export function isLockedIssueStub(value: unknown): value is IssueLockedStub { - return ( - typeof value === "object" && - value !== null && - (value as { locked?: unknown }).locked === true - ); -} - -export function LockedIssueChip({ - identifier, - className, -}: { - identifier: string | null; - className?: string; -}) { - // Identifier is the only viewer-safe field on the stub. When the server - // withheld even that, fall back to a neutral "Private" label so the chip - // still reads as a locked reference rather than an empty box. - const label = identifier ?? "Private"; - return ( - - - ); -} diff --git a/ui/src/components/MarkdownBody.test.tsx b/ui/src/components/MarkdownBody.test.tsx index bf371699baf..0e8572fbdca 100644 --- a/ui/src/components/MarkdownBody.test.tsx +++ b/ui/src/components/MarkdownBody.test.tsx @@ -277,32 +277,6 @@ describe("MarkdownBody", () => { expect(html).not.toContain("paperclip-mention-chip--issue"); }); - it("holds the quicklook preview until an issue mention is confirmed readable (PAP-16070)", () => { - // While the fetch is in flight we don't know if the viewer can read the - // issue. The mention stays a clickable link, but the IssueLinkQuicklook - // hover preview (a Radix Popover portal) must NOT mount yet: a private - // mention 404s straight to the locked chip, and mounting then tearing down - // that portal to swap in the chip is the element churn that crashed the - // chat transcript renderer. - const html = renderMarkdown("Depends on PAP-1271 for the hover state."); - - expect(html).toContain('data-mention-pending="true"'); - expect(html).toContain('data-mention-kind="issue"'); - expect(html).toContain("paperclip-markdown-issue-ref"); - // Clickability is preserved through the loading state. - expect(html).toContain('href="/issues/PAP-1271"'); - expect(html).toContain(">PAP-1271<"); - }); - - it("drops the pending marker once a seeded (readable) issue mention resolves", () => { - const html = renderMarkdown("Depends on PAP-1271 for the hover state.", [ - { identifier: "PAP-1271", status: "done" }, - ]); - - expect(html).toContain('href="/issues/PAP-1271"'); - expect(html).not.toContain('data-mention-pending="true"'); - }); - it("uses concise issue aria labels until a distinct title is available", () => { const html = renderMarkdown("Depends on PAP-1271 and PAP-1272.", [ { identifier: "PAP-1271", status: "done" }, diff --git a/ui/src/components/MarkdownBody.tsx b/ui/src/components/MarkdownBody.tsx index 5ae65efe59e..24b9772854a 100644 --- a/ui/src/components/MarkdownBody.tsx +++ b/ui/src/components/MarkdownBody.tsx @@ -10,8 +10,6 @@ import { useTheme } from "../context/ThemeContext"; import { useOptionalCompany } from "../context/CompanyContext"; import { mentionChipInlineStyle, parseMentionChipHref } from "../lib/mention-chips"; import { issuesApi } from "../api/issues"; -import { ApiError } from "../api/client"; -import { LockedIssueChip } from "./LockedIssueChip"; import { queryKeys } from "../lib/queryKeys"; import { parseIssueReferenceFromHref, remarkLinkIssueReferences } from "../lib/issue-reference"; import { remarkLinkCaseReferences } from "../lib/case-reference"; @@ -114,46 +112,21 @@ function MarkdownIssueLink({ issuePathId: string; children: ReactNode; }) { - const { data, error } = useQuery({ + const { data } = useQuery({ queryKey: queryKeys.issues.detail(issuePathId), queryFn: () => issuesApi.get(issuePathId), staleTime: 60_000, - // A private issue 404s on direct fetch (indistinguishable from deleted, by - // design). Don't burn retries on it — settle straight to the locked chip. - retry: (failureCount, err) => - !(err instanceof ApiError && err.status === 404) && failureCount < 3, }); - // Mention of an issue this viewer can't read → existence-only locked chip - // (never a title or a link), matching the locked-stub treatment on edges. - if (error instanceof ApiError && error.status === 404) { - return ; - } - const identifier = data?.identifier ?? issuePathId; const title = data?.title ?? identifier; const status = data?.status; const issueLabel = title !== identifier ? `Issue ${identifier}: ${title}` : `Issue ${identifier}`; - // Until the fetch settles we don't yet know whether this viewer can read the - // issue. Keep the mention a plain link (clickable, styled as today) but hold - // off mounting the IssueLinkQuicklook hover preview — a Radix Popover portal — - // until `data` confirms the issue is readable. - // - Correctness (PAP-16070): a private mention 404s straight to the locked - // chip. Mounting the quicklook popover during the loading `` only to - // tear the portal down and swap in a plain `` chip on the 404 is the - // element churn that crashed the chat transcript's primary renderer (it - // fell through to the safe fallback). Loading link → chip stays portal-free. - // - Privacy: don't prefetch / hover-preview an issue of unconfirmed - // readability. `data-mention-pending` marks the transient state for tests. - const pending = !data; - return ( ({ })); const mockIssuesApi = vi.hoisted(() => ({ - get: vi.fn(), create: vi.fn(), upsertDocument: vi.fn(), uploadAttachment: vi.fn(), @@ -232,12 +231,8 @@ vi.mock("@/components/ui/button", () => ({ })); vi.mock("@/components/ui/toggle-switch", () => ({ - ToggleSwitch: ({ checked, onCheckedChange, ...props }: { - checked: boolean; - onCheckedChange: (checked: boolean) => void; - "aria-label"?: string; - }) => ( - + ToggleSwitch: ({ checked, onCheckedChange }: { checked: boolean; onCheckedChange: () => void }) => ( + ), })); @@ -349,7 +344,6 @@ describe("NewIssueDialog", () => { mockIssuesApi.create.mockReset(); mockIssuesApi.upsertDocument.mockReset(); mockIssuesApi.uploadAttachment.mockReset(); - mockIssuesApi.get.mockResolvedValue({ id: "issue-1", visibility: "open" }); mockExecutionWorkspacesApi.list.mockReset(); mockExecutionWorkspacesApi.listSummaries.mockReset(); mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([]); @@ -523,74 +517,6 @@ describe("NewIssueDialog", () => { act(() => root.unmount()); }); - it("restores private visibility from a saved draft without a project", async () => { - localStorage.setItem("paperclip:issue-draft", JSON.stringify({ title: "Private draft", description: "", status: "todo", priority: "", projectId: "", isPrivate: true })); - const { root } = renderDialog(container); - await flush(); - const toggle = container.querySelector('button[aria-label="Private task"]')!; - await waitForAssertion(() => expect(toggle.getAttribute("aria-pressed")).toBe("true")); - const submit = Array.from(container.querySelectorAll("button")).find(button => button.textContent?.includes("Create Task"))!; - await act(async () => submit.click()); - await flush(); - expect(mockIssuesApi.create).toHaveBeenCalledWith("company-1", expect.objectContaining({ title: "Private draft", visibility: "private" })); - act(() => root.unmount()); - }); - - it("locks inherited privacy and submits the child as private", async () => { - mockIssuesApi.get.mockResolvedValue({ id: "issue-1", visibility: "private" }); - dialogState.newIssueDefaults = { parentId: "issue-1", title: "Private child" }; - const { root } = renderDialog(container); - await flush(); - await flush(); - const toggle = container.querySelector('button[aria-label="Private task"]')!; - await waitForAssertion(() => expect(toggle.getAttribute("aria-pressed")).toBe("true")); - expect(toggle.disabled).toBe(true); - expect(container.textContent).toContain("inherits private access from its parent"); - const submit = Array.from(container.querySelectorAll("button")).find(button => button.textContent?.includes("Create Sub-Task"))!; - await act(async () => submit.click()); - await flush(); - expect(mockIssuesApi.create).toHaveBeenCalledWith("company-1", expect.objectContaining({ parentId: "issue-1", visibility: "private" })); - act(() => root.unmount()); - }); - - it("defaults a private task to the current user's personal private project", async () => { - mockProjectsApi.list.mockResolvedValue([ - { - id: "personal-project", - companyId: "company-1", - name: "My private tasks", - visibility: "private", - personalOwnerUserId: "user-1", - archivedAt: null, - }, - ]); - dialogState.newIssueDefaults = { title: "Private scratch task" }; - - const { root } = renderDialog(container); - await flush(); - await flush(); - const privateToggle = container.querySelector('button[aria-label="Private task"]'); - expect(privateToggle).not.toBeNull(); - await act(async () => privateToggle!.click()); - await flush(); - - const submitButton = Array.from(container.querySelectorAll("button")) - .find((button) => button.textContent?.includes("Create Task")); - await waitForAssertion(() => expect(submitButton?.hasAttribute("disabled")).toBe(false)); - await act(async () => submitButton!.click()); - await flush(); - - expect(mockIssuesApi.create).toHaveBeenCalledWith( - "company-1", - expect.objectContaining({ - title: "Private scratch task", - visibility: "private", - projectId: "personal-project", - }), - ); - act(() => root.unmount()); - }); - it("does not show user-secret warnings when the draft will not run an env binding that needs them", async () => { const { root } = renderDialog(container); await flush(); diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index b701f52e422..858b86695e1 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -158,7 +158,6 @@ function useVisualViewportLayout(enabled: boolean) { } interface IssueDraft { - isPrivate?: boolean; title: string; description: string; status: string; @@ -517,7 +516,6 @@ export function NewIssueDialog() { const [executionWorkspaceMode, setExecutionWorkspaceMode] = useState("shared_workspace"); const [selectedExecutionWorkspaceId, setSelectedExecutionWorkspaceId] = useState(""); const [workMode, setWorkMode] = useState("standard"); - const [isPrivate, setIsPrivate] = useState(false); const [expanded, setExpanded] = useState(false); const [dialogCompanyId, setDialogCompanyId] = useState(null); const [stagedFiles, setStagedFiles] = useState([]); @@ -600,15 +598,6 @@ export function NewIssueDialog() { userId: currentUserId, }); - const { data: privacyParent } = useQuery({ - queryKey: queryKeys.issues.detail(newIssueDefaults.parentId ?? ""), - queryFn: () => issuesApi.get(newIssueDefaults.parentId!), - enabled: newIssueOpen && Boolean(newIssueDefaults.parentId), - }); - const inheritsPrivateAccess = privacyParent?.visibility === "private" || privacyParent?.project?.visibility === "private"; - const effectivePrivate = isPrivate || inheritsPrivateAccess - || orderedProjects.some(project => project.id === projectId && project.visibility === "private"); - const selectedAssignee = useMemo(() => parseAssigneeValue(assigneeValue), [assigneeValue]); const selectedAssigneeAgentId = selectedAssignee.assigneeAgentId; const selectedAssigneeUserId = selectedAssignee.assigneeUserId; @@ -753,12 +742,10 @@ export function NewIssueDialog() { executionWorkspaceMode, selectedExecutionWorkspaceId, workMode, - isPrivate: effectivePrivate, }); }, [ newIssueOpen, scheduleSave, - effectivePrivate, status, priority, assigneeValue, @@ -888,7 +875,6 @@ export function NewIssueDialog() { const hasExplicitProjectWorkspaceId = newIssueDefaults.projectWorkspaceId !== undefined; const hasExplicitExecutionWorkspaceId = newIssueDefaults.executionWorkspaceId !== undefined; const hasExplicitExecutionWorkspaceMode = newIssueDefaults.executionWorkspaceMode !== undefined; - setIsPrivate(draft.isPrivate ?? false); setIssueText(draft.title, draft.description); setStatus(draft.status || "todo"); setPriority(draft.priority); @@ -1014,7 +1000,6 @@ export function NewIssueDialog() { setExecutionWorkspaceMode("shared_workspace"); setSelectedExecutionWorkspaceId(""); setWorkMode("standard"); - setIsPrivate(false); setExpanded(false); setDialogCompanyId(null); setStagedFiles([]); @@ -1098,7 +1083,6 @@ export function NewIssueDialog() { status, priority: priority || "medium", workMode, - ...(effectivePrivate ? { visibility: "private" } : {}), ...(selectedAssigneeAgentId ? { assigneeAgentId: selectedAssigneeAgentId } : {}), ...(selectedAssigneeUserId ? { assigneeUserId: selectedAssigneeUserId } : {}), ...(newIssueDefaults.parentId ? { parentId: newIssueDefaults.parentId } : {}), @@ -1297,7 +1281,6 @@ export function NewIssueDialog() { if (nextProjectId) trackRecentProject(nextProjectId); setProjectId(nextProjectId); const nextProject = orderedProjects.find((project) => project.id === nextProjectId); - if (nextProject?.visibility === "private") setIsPrivate(true); executionWorkspaceDefaultProjectId.current = nextProjectId || null; setProjectWorkspaceId(defaultProjectWorkspaceIdForProject(nextProject)); setExecutionWorkspaceMode(defaultExecutionWorkspaceModeForProject(nextProject)); @@ -2036,34 +2019,6 @@ export function NewIssueDialog() { )} - {/* Private task toggle */} -
-
-
Private task
-
- {inheritsPrivateAccess ? "This subtask inherits private access from its parent." : "Only you and people you share with can read it. Subtasks stay private too."} -
-
- { - setIsPrivate(checked); - if (!checked) { - const selectedProject = orderedProjects.find((project) => project.id === projectId); - if (selectedProject?.visibility === "private") handleProjectChange(""); - return; - } - if (projectId || !currentUserId) return; - const personalProject = orderedProjects.find( - (project) => project.personalOwnerUserId === currentUserId, - ); - if (personalProject) handleProjectChange(personalProject.id); - }} - aria-label="Private task" - /> -
- {/* Description */}
projectsApi.listAccessMembers(project.id, project.companyId), - enabled: open, - }); - const directoryQuery = useQuery({ - queryKey: queryKeys.access.companyUserDirectory(project.companyId), - queryFn: () => accessApi.listUserDirectory(project.companyId), - enabled: open, - }); - const agentsQuery = useQuery({ - queryKey: queryKeys.agents.list(project.companyId), - queryFn: () => agentsApi.list(project.companyId), - enabled: open, - }); - const activeKeys = useMemo( - () => new Set((membersQuery.data ?? []).map((member) => `${member.subjectType}:${member.subjectId}`)), - [membersQuery.data], - ); - const groups = useMemo(() => { - const people = (directoryQuery.data?.users ?? []) - .filter((entry) => entry.user && !activeKeys.has(`user:${entry.user.id}`)) - .map((entry) => ({ - key: `user:${entry.user!.id}`, - value: `user:${entry.user!.id}`, - label: entry.user!.name ?? entry.user!.email ?? "Unknown user", - searchText: entry.user!.email ?? "", - })); - const agentOptions = (agentsQuery.data ?? []) - .filter((agent) => !activeKeys.has(`agent:${agent.id}`)) - .map((agent) => ({ key: `agent:${agent.id}`, value: `agent:${agent.id}`, label: agent.name })); - return [ - ...(people.length ? [{ id: "people", label: "People", options: people }] : []), - ...(agentOptions.length ? [{ id: "agents", label: "Agents", options: agentOptions }] : []), - ]; - }, [activeKeys, agentsQuery.data, directoryQuery.data]); - const addMember = useMutation({ - mutationFn: async () => { - const [subjectType, subjectId] = selection.split(/:(.+)/) as ["user" | "agent", string]; - return projectsApi.addAccessMember(project.id, { subjectType, subjectId }, project.companyId); - }, - onSuccess: () => { - setSelection(""); - queryClient.invalidateQueries({ queryKey: membersKey }); - pushToast({ title: "Project access added", tone: "success" }); - }, - onError: (error) => pushToast({ title: "Couldn't add project access", body: (error as Error).message, tone: "error" }), - }); - const removeMember = useMutation({ - mutationFn: (memberId: string) => projectsApi.removeAccessMember(project.id, memberId, project.companyId), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: membersKey }); - pushToast({ title: "Project access removed", tone: "success" }); - }, - onError: (error) => pushToast({ title: "Couldn't remove project access", body: (error as Error).message, tone: "error" }), - }); - - return ( - <> - - - - - Private project access - - Members can discover this project and read every task in it. A task can still be shared directly. - - -
- {directoryQuery.isError || agentsQuery.isError ? ( -

Couldn't load the company access directory.

- ) : null} -
- setSelection(value)} - placeholder="Add a person or agent" - searchPlaceholder="Search people and agents" - emptyMessage="No more company members to add" - loading={directoryQuery.isLoading || agentsQuery.isLoading} - className="min-w-0 flex-1" - /> - -
-
- {(membersQuery.data ?? []).map((member) => { - const isPersonalOwner = project.personalOwnerUserId === member.subjectId && member.subjectType === "user"; - return ( -
-
-
{member.subjectDisplayName ?? "Unknown member"}
-
- {isPersonalOwner ? "Owner" : member.subjectType === "agent" ? "Agent" : "Person"} -
-
- {!isPersonalOwner ? ( - - ) : null} -
- ); - })} - {membersQuery.isLoading ?

Loading access…

: null} - {membersQuery.isError ? ( -

Couldn't load project access members.

- ) : null} -
-
-
-
- - ); -} diff --git a/ui/src/components/ProjectProperties.tsx b/ui/src/components/ProjectProperties.tsx index ffa7fb66c55..5d99cbc3921 100644 --- a/ui/src/components/ProjectProperties.tsx +++ b/ui/src/components/ProjectProperties.tsx @@ -22,7 +22,6 @@ import { DraftInput } from "./agent-config-primitives"; import { InlineEditor } from "./InlineEditor"; import { EnvironmentVariablesEditor } from "./environment-variables-editor"; import { Badge } from "@/components/ui/badge"; -import { ProjectAccessMembers } from "./ProjectAccessMembers"; interface ProjectPropertiesProps { project: Project; @@ -39,7 +38,6 @@ export type ProjectConfigFieldKey = | "name" | "description" | "status" - | "visibility" | "goals" | "env" | "execution_workspace_enabled" @@ -453,25 +451,6 @@ export function ProjectProperties({ project, repositories, onUpdate, onFieldUpda

)} - } alignStart> -
- -

- Only access members can discover this project. Tasks shared directly remain readable on their own. -

- {project.visibility === "private" ? : null} -
-
{repositories ?? } } diff --git a/ui/src/components/issue-properties/relation-controls.tsx b/ui/src/components/issue-properties/relation-controls.tsx index a5e1c6d6f2a..8dbc36f9a51 100644 --- a/ui/src/components/issue-properties/relation-controls.tsx +++ b/ui/src/components/issue-properties/relation-controls.tsx @@ -1,7 +1,6 @@ import { useState, type MouseEvent } from "react"; -import type { Issue, IssueLockedStub } from "@paperclipai/shared"; +import type { Issue } from "@paperclipai/shared"; import { Link } from "@/lib/router"; -import { LockedIssueChip, isLockedIssueStub } from "../LockedIssueChip"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -27,31 +26,19 @@ export function RemovableIssueReferencePill({ onRemove, isMobile = false, }: { - issue: NonNullable[number] | IssueLockedStub; + issue: NonNullable[number]; onRemove: (issueId: string) => void; isMobile?: boolean; }) { const [isConfirmOpen, setIsConfirmOpen] = useState(false); - const locked = isLockedIssueStub(issue); - const issueLabel = locked - ? issue.identifier ?? "Private task" - : issue.identifier ?? issue.title; - const confirmLabel = locked - ? issue.identifier ?? "this private task" - : issue.identifier - ? `${issue.identifier}: ${issue.title}` - : issue.title; + const issueLabel = issue.identifier ?? issue.title; + const confirmLabel = issue.identifier ? `${issue.identifier}: ${issue.title}` : issue.title; const chipClassName = cn( "paperclip-mention-chip paperclip-mention-chip--issue", "inline-flex items-center gap-1 rounded-full border border-border px-2 py-0.5 text-xs no-underline", - !locked && issue.identifier && "hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-(length:--rad-3) focus-visible:ring-ring", + issue.identifier && "hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-(length:--rad-3) focus-visible:ring-ring", ); - // A private blocker referenced from a task you *can* see: show the locked chip - // (never a title/link) but keep the detach affordance so you aren't stuck - // blocked by something invisible. - const content = locked ? ( - - ) : ( + const content = ( <> {issueLabel} @@ -79,14 +66,14 @@ export function RemovableIssueReferencePill({ type="button" data-mention-kind="issue" className={chipClassName} - title={locked ? "Private task" : issue.title} + title={issue.title} aria-label={`Actions for blocker ${issueLabel}`} > {content} - {!locked && issue.identifier ? ( + {issue.identifier ? ( @@ -111,9 +98,7 @@ export function RemovableIssueReferencePill({ > - {locked ? ( - content - ) : issue.identifier ? ( + {issue.identifier ? ( { - it("reads the nested authorizationPolicy.agentVisibility.mode", () => { - expect( - agentVisibilityFromPermissions({ authorizationPolicy: { agentVisibility: { mode: "discoverable" } } }), - ).toBe("discoverable"); - expect( - agentVisibilityFromPermissions({ authorizationPolicy: { agentVisibility: { mode: "private" } } }), - ).toBe("private"); - }); - - it("returns null for malformed / missing shapes", () => { - expect(agentVisibilityFromPermissions(null)).toBeNull(); - expect(agentVisibilityFromPermissions({})).toBeNull(); - expect(agentVisibilityFromPermissions({ authorizationPolicy: {} })).toBeNull(); - expect( - agentVisibilityFromPermissions({ authorizationPolicy: { agentVisibility: { mode: "bogus" } } }), - ).toBeNull(); - }); -}); - -describe("isSharedAgentVisibility", () => { - it("treats anything not explicitly private as shared", () => { - expect(isSharedAgentVisibility("discoverable")).toBe(true); - expect(isSharedAgentVisibility("private")).toBe(false); - expect(isSharedAgentVisibility(null)).toBe(false); - }); -}); - -describe("grantIsSharedAgent", () => { - const base: IssueAccessGrant = { - id: "g1", - issueId: "i1", - subjectType: "agent", - subjectId: "a1", - source: "explicit", - grantedByUserId: null, - grantedByAgentId: null, - createdAt: new Date(), - revokedAt: null, - subjectDisplayName: "Helper", - subjectAvatarUrl: null, - subjectInitials: "H", - agentVisibility: "discoverable", - }; - - it("is true only for non-private agents", () => { - expect(grantIsSharedAgent(base)).toBe(true); - expect(grantIsSharedAgent({ ...base, agentVisibility: "private" })).toBe(false); - expect(grantIsSharedAgent({ ...base, subjectType: "user", agentVisibility: null })).toBe(false); - }); -}); - -describe("grantIsRevocable", () => { - it("allows explicit + assignment, never project", () => { - expect(grantIsRevocable("explicit")).toBe(true); - expect(grantIsRevocable("assignment")).toBe(true); - expect(grantIsRevocable("project")).toBe(false); - }); -}); diff --git a/ui/src/lib/issuePrivacy.ts b/ui/src/lib/issuePrivacy.ts deleted file mode 100644 index b84e4adef17..00000000000 --- a/ui/src/lib/issuePrivacy.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { - IssueAccessGrant, - IssueAccessGrantAgentVisibility, - IssueAccessGrantSource, -} from "@paperclipai/shared"; - -/** - * Client mirror of the server's `issueGrantAgentVisibility` - * (server/src/routes/issues.ts) — reads an agent's visibility mode out of its - * loosely-typed `permissions.authorizationPolicy.agentVisibility.mode`. Used to - * preview the shared-agent caution *before* a grant exists (the enriched grant - * carries `agentVisibility`, but the add flow only has the candidate agent). - */ -export function agentVisibilityFromPermissions( - permissions: unknown, -): IssueAccessGrantAgentVisibility | null { - if (!permissions || typeof permissions !== "object" || Array.isArray(permissions)) return null; - const authorizationPolicy = (permissions as Record).authorizationPolicy; - if (!authorizationPolicy || typeof authorizationPolicy !== "object" || Array.isArray(authorizationPolicy)) { - return null; - } - const agentVisibility = (authorizationPolicy as Record).agentVisibility; - if (!agentVisibility || typeof agentVisibility !== "object" || Array.isArray(agentVisibility)) return null; - const mode = (agentVisibility as Record).mode; - return mode === "private" || mode === "discoverable" ? mode : null; -} - -/** - * Locked decision `trust-agent`: a *shared* agent is any agent not explicitly - * scoped to `private`. Sharing a private task with one means its memory & - * workspace may carry residual private context into later runs for other - * people, so the amber caution fires. Users and private/dedicated agents never - * trip it. - */ -export function isSharedAgentVisibility( - agentVisibility: IssueAccessGrantAgentVisibility | null, -): boolean { - return agentVisibility !== null && agentVisibility !== "private"; -} - -export function grantIsSharedAgent(grant: IssueAccessGrant): boolean { - return grant.subjectType === "agent" && isSharedAgentVisibility(grant.agentVisibility); -} - -/** Only explicit + assignment grants are revocable from the share sheet; project grants are managed on the project. */ -export function grantIsRevocable(source: IssueAccessGrantSource): boolean { - return source === "explicit" || source === "assignment"; -} diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index c9e9ff27700..9f50ea25003 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -364,9 +364,10 @@ export const queryKeys = { options.excludeRoot ? (["issues", "cost-summary", issueId, "exclude-root"] as const) : (["issues", "cost-summary", issueId] as const), - accessGrants: (issueId: string) => ["issues", "access-grants", issueId] as const, - attachments: (issueId: string) => ["issues", "attachments", issueId] as const, - attachmentPreview: (attachmentId: string) => ["issues", "attachment-preview", attachmentId] as const, + attachments: (issueId: string) => + ["issues", "attachments", issueId] as const, + attachmentPreview: (attachmentId: string) => + ["issues", "attachment-preview", attachmentId] as const, documents: (issueId: string) => ["issues", "documents", issueId] as const, document: (issueId: string, key: string) => ["issues", "document", issueId, key] as const, @@ -518,7 +519,6 @@ export const queryKeys = { { includeArchived: opts.includeArchived === true }, ] as const, detail: (id: string) => ["projects", "detail", id] as const, - accessMembers: (id: string) => ["projects", "access-members", id] as const, }, cases: { list: (companyId: string) => ["cases", companyId] as const, diff --git a/ui/src/pages/DesignGuide.tsx b/ui/src/pages/DesignGuide.tsx index 62fa5b9c54f..db488b86f2a 100644 --- a/ui/src/pages/DesignGuide.tsx +++ b/ui/src/pages/DesignGuide.tsx @@ -168,7 +168,6 @@ import { PageSkeleton } from "@/components/PageSkeleton"; import { Identity } from "@/components/Identity"; import { AppLogo } from "@/pages/apps/AppLogo"; import { IssueReferencePill } from "@/components/IssueReferencePill"; -import { LockedIssueChip } from "@/components/LockedIssueChip"; import { MembershipAction } from "@/components/MembershipAction"; import { IssueOutputSection } from "@/components/issue-output/IssueOutputSection"; import { EnvironmentVariablesEditor } from "@/components/environment-variables-editor"; @@ -890,19 +889,6 @@ export function DesignGuide() {
- - -

- Existence-only reference to a private task shown from a surface the viewer can see - (a blocker edge, a mention). Mono id + lock, dashed muted border,{" "} - no title, never a link. Falls back to "Private" when even the - identifier is withheld. -

-
- - -
-
{/* ============================================================ */} diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index f731e3625b2..848ef5dfcbe 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -334,8 +334,6 @@ import { SlidersHorizontal, } from "lucide-react"; import { Badge } from "@/components/ui/badge"; -import { IssuePrivacyActions } from "@/components/IssuePrivacyActions"; -import type { ShareSheetImplicitPrincipal } from "@/components/IssueShareSheet"; import { deriveOriginatingActor, isClosedIsolatedExecutionWorkspace, @@ -3497,44 +3495,6 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS () => buildCompanyUserLabelMap(companyMembers?.users), [companyMembers?.users], ); - // Privacy setter gate (UI mirror of the server's - // `resolveIssuePrivacyManagementRoot`): responsible user + board managers / - // admins. The server is authoritative; this only decides what to enable. - const canManagePrivacy = Boolean( - issue && - (canManageBoardRuntime || (currentUserId && issue.responsibleUserId === currentUserId)), - ); - // Role-based principals for the share sheet's implicit rows (no revoke). - const privacyImplicitPrincipals = useMemo(() => { - if (!issue) return []; - const list: ShareSheetImplicitPrincipal[] = []; - const seen = new Set(); - const pushUser = (userId: string | null, roleLabel: string) => { - if (!userId || seen.has(`user:${userId}`)) return; - seen.add(`user:${userId}`); - const profile = userProfileMap.get(userId); - list.push({ - id: `user:${userId}`, - displayName: profile?.label ?? userId.slice(0, 5), - roleLabel, - avatarUrl: profile?.image ?? null, - }); - }; - const pushAgent = (agentId: string | null, roleLabel: string) => { - if (!agentId || seen.has(`agent:${agentId}`)) return; - seen.add(`agent:${agentId}`); - const agent = agentMap.get(agentId); - list.push({ - id: `agent:${agentId}`, - displayName: agent?.name ?? agentId.slice(0, 8), - roleLabel, - }); - }; - pushUser(issue.responsibleUserId, "Owner"); - pushAgent(issue.assigneeAgentId, "Assignee"); - pushUser(issue.assigneeUserId, "Assignee"); - return list; - }, [issue, userProfileMap, agentMap]); const mentionOptions = useMemo(() => { return buildMarkdownMentionOptions({ agents, @@ -7244,14 +7204,6 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS "absolute right-0 top-0 flex h-7 items-center", )} > - setMoreOpen(false)} - implicitPrincipals={privacyImplicitPrincipals} -> - {(privacyMenuItems) => (