Files
PaperClipAI/ui/src/components/IssueShareSheet.tsx
T
39279aa34f feat(ui): shared error, connectivity and query foundation for disconnects (#15754)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The board reads API data and keeps the current task on screen.
> - A restart or temporary network failure can interrupt those reads.
> - Separate page error handlers can hide cached data or expose raw
server codes.
> - The app needs one connection state, readable error copy and a
consistent read policy.
> - This change provides that foundation and moves affected read
surfaces onto it.
> - The dependent write layer in #17960 preserves edits and submissions
across the same failures.

## Linked Issues or Issue Description

**What happened?**

Temporary API failures could replace loaded content with errors.
Different surfaces handled the same outage differently. Some showed raw
server codes. A successful query to an unrelated service could also
falsely report that Paperclip had recovered.

**Expected behavior**

Keep cached content visible. Show one connection banner during sustained
trouble. Confirm recovery through the Paperclip health probe or its
WebSocket. Retry transient reads and show readable errors for permanent
failures.

**Steps to reproduce**

1. Open a populated task or workspace page.
2. Interrupt API requests with a 503 or a dropped connection.
3. Keep an unrelated external request in flight.
4. Confirm that cached content remains and that the external result
cannot clear the outage.
5. Restore the API and check that reads refresh.

The write layer first merged into this feature branch in #15769 now has
its own dependent PR, #17960. This PR contains the shared foundation and
read changes. Merge this PR first.

## What Changed

- Classify API errors and provide readable error copy, including
non-JSON proxy responses and Retry-After handling.
- Use one connection store and health probe with bounded backoff.
Confirm recovery from the server health probe or live-updates WebSocket.
- Pause reads while disconnected, retry transient errors, and refresh
active reads after recovery.
- Keep cached data visible through shared query views and affected page,
panel and plugin read surfaces.
- Add one app-level connection banner and support the pending-write
count consumed by the dependent write layer.
- Add a query-error rendering guard. Call it directly from the existing
CI step. Update development documentation.

## Verification

- Historical-chat lookup now follows the same ready/stale roster state
as rendering. Three regressions fail before the fix; the six new cases
pass after it, including working Retry, fresh data, no-cache error,
permanent 403 and company isolation. All six focused query/recovery
suites pass 200 tests. Final-commit CI passes and Apex is 5/5 with no
unresolved findings. Canary Dry Run initially refused a preview version
that was already on npm. It passed after one rerun with fresh registry
metadata; no check was disabled.

- Integrated current master and retained both transcript interest
registration and connectivity reporting on socket open. The seven
focused recovery/query suites pass 288 tests, and UI typecheck and
production build pass. All 9,451 UI tests across 799 files pass on the
merge commit. Final-commit CI passes and Apex is 5/5 with no unresolved
findings.

- Current head `70d9335d732219352ef0f98414439766bbcb33df`: all hosted CI
checks pass. Apex is **5/5** on that head with no unresolved findings.
The branch has no merge conflict. GitHub still requires code-owner
approval.

- The foundation passes 9,430 UI tests in 804 files. After integrating
current master page paths, 190 focused page tests pass.
- Two unrelated-success regressions fail before the latest fix. With the
fix, 62 focused UI tests, all six guard tests and UI TypeScript pass.
- The composed foundation and write layer pass **9,505 UI tests in 808
files**, UI TypeScript, a production UI build, and query-error, token
and map guards.
- A real Chromium journey on the composed source injects a 503 during
title save. The loaded task stays visible; one reconnect banner appears;
the edit saves after recovery; another task stays unchanged; the saved
title survives reload.
- Full recursive local checks were not repeated because Runner
compilation exhausted local disk during another repair. Hosted checks
must pass before merge. No new staging deployment is claimed.

- Latest review follow-up: a cached CaseDetail now survives a failed
HTTP 429 refresh. Its page regression fails before the fix and passes
after it. Eighty focused foundation tests, UI TypeScript, build and
guards pass after that change.
- Earlier CI evidence is retained: several foundation jobs received a
runner shutdown signal together. The write-layer head also failed an
unchanged Runner descriptor close test with `provider_transport_failed`.
The same Runner source passed that suite in the foundation run. New CI
runs cover the updated heads; no check or assertion was weakened.

## Risks

- Shared retry defaults change the time before transient read errors
appear. Permanent errors still fail promptly.
- ApiError.message now contains readable copy. Callers that compare raw
codes must use ApiError.code.
- Cache success can come from another service or local data. It must not
confirm Paperclip recovery.
- Durable writes are reviewed separately in #17960. This PR does not
change database or mutation contracts.

## Model Used

Anthropic Claude Opus 5.5 (`claude-opus-5-5`) produced the original work
with tool use. OpenAI Codex, GPT-6 family, assisted with restoration,
integration, tests and review fixes. The Codex session does not expose
its exact serving model ID or context window size.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: devinfoley <139239+devinfoley@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Amy (Opus) <claude@users.noreply.github.com>
2026-10-27 21:22:07 -07:00

505 lines
19 KiB
TypeScript

import { AgentAvatar } from "./AgentAvatar";
import { Identity } from "./Identity";
import { renderAccessIdentity, type AccessSelectOption } from "./AccessSelectIdentity";
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 { QueryErrorState, useQueryView } from "@/components/QueryView";
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 labels and icons distinguish access reasons; colors use semantic tokens.
const SOURCE_BADGES: Record<IssueAccessGrantSource, SourceBadgeSpec> = {
owner: { label: "task owner", icon: Lock, className: "border-border bg-muted text-muted-foreground" },
explicit: {
label: "shared directly",
icon: UserPlus,
className:
"border-primary/30 bg-primary/10 text-primary",
},
assignment: {
label: "via assignment",
icon: Check,
className:
"border-border bg-secondary text-secondary-foreground",
},
project: {
label: "via project",
icon: Folder,
className:
"border-border bg-accent text-accent-foreground",
},
};
function SourceBadge({ source }: { source: IssueAccessGrantSource }) {
const spec = SOURCE_BADGES[source];
const Icon = spec.icon;
return (
<span
data-testid={`grant-source-badge-${source}`}
data-source={source}
className={cn(
"inline-flex shrink-0 items-center gap-1 rounded-md border px-1.5 py-0.5 text-(length:--text-nano) font-medium leading-tight",
spec.className,
)}
>
<Icon className="h-3 w-3 shrink-0" aria-hidden="true" />
{spec.label}
</span>
);
}
function PrincipalAvatar({
displayName,
avatarUrl,
initials,
}: {
displayName: string;
avatarUrl?: string | null;
initials?: string | null;
}) {
return (
<Avatar size="sm" className="mt-0.5" aria-hidden="true">
{avatarUrl ? <AvatarImage src={avatarUrl} alt="" /> : null}
<AvatarFallback>{initials ?? displayName.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
);
}
// The named type ladder in index.css explicitly prefers Tailwind text-xs and
// text-sm for its 12px and 14px tiers; these are the shared scale tokens.
function RowShell({
avatar,
title,
subtitle,
badge,
action,
}: {
avatar: React.ReactNode;
title: React.ReactNode;
subtitle?: React.ReactNode;
badge?: React.ReactNode;
action?: React.ReactNode;
}) {
return (
<div className="flex items-start gap-2.5 py-2">
{avatar}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<span className="truncate text-sm font-medium">{title}</span>
{badge}
</div>
{subtitle ? <div className="truncate text-xs text-muted-foreground">{subtitle}</div> : null}
</div>
{action ? <div className="shrink-0 self-center">{action}</div> : null}
</div>
);
}
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<string>(initialAddSelection);
const [revokeTarget, setRevokeTarget] = useState<IssueAccessGrant | null>(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.filter((grant) => !grant.inherited && grant.source !== "project")
.map((grant) => `${grant.subjectType}:${grant.subjectId}`)),
[activeGrants],
);
const addMutation = useMutation({
mutationFn: (payload: { subjectType: "user" | "agent"; subjectId: string }) =>
issuesApi.createAccessGrant(issueId, payload),
onSuccess: () => {
// A task grant also changes descendant access and cached task discovery.
queryClient.invalidateQueries({ queryKey: ["issues"] });
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: () => {
// A task grant also changes descendant access and cached task discovery.
queryClient.invalidateQueries({ queryKey: ["issues"] });
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 a direct grant on this task. Inherited/project access can be supplemented
// with an independent child grant before broader access is removed. Value encodes both type and id: `type:id`.
const addGroups = useMemo<SearchableSelectGroup<string, AccessSelectOption>[]>(() => {
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(" "),
identity: <Identity name={entry.user!.name ?? entry.user!.email ?? "Unknown user"} avatarUrl={entry.user!.image} size="sm" />,
}));
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,
identity: <><AgentAvatar agent={agent} size={24} /><span className="truncate">{agent.name}</span></>,
}));
const groups: SearchableSelectGroup<string, AccessSelectOption>[] = [];
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 grantsView = useQueryView(grantsQuery);
const isLoading = grantsQuery.isLoading || grantsView.kind === "reconnecting";
const loadFailed = grantsView.kind === "error";
const isEmpty = !isLoading && !loadFailed && activeGrants.length === 0 && implicitPrincipals.length === 0;
return (
<Dialog open={open} onOpenChange={resetAndClose}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Lock className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
Who can access this task
</DialogTitle>
<DialogDescription>{aclDescription}</DialogDescription>
</DialogHeader>
{view === "list" ? (
<div className="space-y-1">
{isLoading ? (
<div className="space-y-2 py-1" data-testid="share-sheet-loading">
{[0, 1, 2].map((row) => (
<div key={row} className="flex items-center gap-2.5 py-1">
<Skeleton className="h-6 w-6 rounded-full" />
<div className="flex-1 space-y-1.5">
<Skeleton className="h-3 w-32" />
<Skeleton className="h-2.5 w-20" />
</div>
</div>
))}
</div>
) : loadFailed ? (
<div data-testid="share-sheet-error">
<QueryErrorState
error={grantsQuery.error}
action="load access"
onRetry={grantsView.retry}
retrying={grantsView.isFetching}
/>
</div>
) : isEmpty ? (
<p className="py-3 text-sm text-muted-foreground" data-testid="share-sheet-empty">
Only you can see this task.
</p>
) : (
<div className="divide-y divide-border/60">
{implicitPrincipals.filter(principal => !activeGrants.some(grant => grant.subjectId === principal.id || `${grant.subjectType}:${grant.subjectId}` === principal.id)).map((principal) => (
<RowShell
key={`implicit:${principal.id}`}
avatar={
<PrincipalAvatar
displayName={principal.displayName}
avatarUrl={principal.avatarUrl}
initials={principal.initials}
/>
}
title={principal.displayName}
subtitle={principal.subtitle ?? undefined}
badge={
<span className="inline-flex shrink-0 items-center rounded-md border border-border bg-muted/50 px-1.5 py-0.5 text-(length:--text-nano) font-medium text-muted-foreground">
{principal.roleLabel}
</span>
}
/>
))}
{activeGrants.map((grant) => {
const displayName = grant.subjectDisplayName ?? "Unknown";
const implicitPrincipal = implicitPrincipals.find(principal =>
principal.id === `${grant.subjectType}:${grant.subjectId}`);
const revocable = canManage && !grant.inherited && grantIsRevocable(grant.source);
const granter = grant.source === "explicit" ? "Shared" : "Granted";
return (
<RowShell
key={grant.id}
avatar={
<PrincipalAvatar
displayName={displayName}
avatarUrl={grant.subjectAvatarUrl}
initials={grant.subjectInitials}
/>
}
title={displayName}
subtitle={implicitPrincipal ? `${implicitPrincipal.roleLabel} — access remains while this role applies` : grant.inherited ? "Inherited access — managed at its source" : `${granter} ${timeAgo(grant.createdAt)}`}
badge={<SourceBadge source={grant.source} />}
action={
revocable ? (
<Button
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-destructive"
onClick={() => setRevokeTarget(grant)}
>
{implicitPrincipal ? "Remove saved grant" : "Revoke"}
</Button>
) : grant.source === "project" ? (
<span className="text-(length:--text-nano) text-muted-foreground">
project-managed
</span>
) : null
}
/>
);
})}
</div>
)}
{canManage ? (
<div className="pt-2">
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => setView("add")}
disabled={isLoading}
>
<UserPlus className="h-3.5 w-3.5" aria-hidden="true" /> Add someone
</Button>
</div>
) : null}
</div>
) : (
<div className="space-y-3">
<SearchableSelect
value={addSelection}
groups={addGroups}
renderValue={(option) => option ? renderAccessIdentity(option) : "Choose a person or agent…"}
renderOption={renderAccessIdentity}
onValueChange={(value) => 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 ? (
<div
role="note"
aria-label="Shared agent caution"
data-testid="shared-agent-caution"
className="flex items-start gap-2 rounded-md border border-border bg-muted px-3 py-2 text-xs leading-relaxed text-foreground"
>
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<span>
<span className="font-semibold">Shared agent.</span> {selectedSharedAgentName}'s
memory &amp; workspace may carry residual private context from this task into its
later runs for other people. Grant only if that's acceptable.
</span>
</div>
) : null}
<div className="flex items-center justify-between gap-2 pt-1">
<Button
variant="ghost"
size="sm"
onClick={() => {
setView("list");
setAddSelection("");
}}
>
Back
</Button>
<Button size="sm" onClick={submitAdd} disabled={!addSelection || addMutation.isPending}>
{addMutation.isPending ? "Adding…" : "Add"}
</Button>
</div>
</div>
)}
</DialogContent>
<AlertDialog
open={revokeTarget !== null}
onOpenChange={(next) => {
if (!next) setRevokeTarget(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Remove {revokeTarget?.subjectDisplayName ?? "this subject"}'s saved grant?
</AlertDialogTitle>
<AlertDialogDescription>
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 <span className="font-semibold">not</span> erase
content already seen or retained in an agent's memory.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={(event) => {
event.preventDefault();
if (revokeTarget) revokeMutation.mutate(revokeTarget);
}}
disabled={revokeMutation.isPending}
>
{revokeMutation.isPending ? "Removing…" : "Remove grant"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Dialog>
);
}