Files
PaperClipAI/ui/src/components/IssueContinuationHandoff.tsx
T
DottaandPaperclip 1fa36be353 fix(ui): use HTTP-safe clipboard copy everywhere (#10875)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Operators often open self-hosted Paperclip over plain HTTP on a LAN
or private network.
> - Browser Clipboard API writes are not reliable in that insecure
context.
> - Paperclip already has one shared helper with a legacy copy fallback,
but many current copy actions bypass it.
> - This pull request routes every core UI copy action and the
first-party workspace-diff plugin through the shared helper.
> - The benefit is consistent copy behavior on HTTPS, localhost, and
plain-HTTP private deployments.

## Linked Issues or Issue Description

Refs #3529.

This change supersedes the stale prior attempt in #3531. Current master
has more copy surfaces and a first-party plugin UI bridge that the prior
branch does not cover.

## What Changed

- Replaced direct Clipboard API writes and duplicate fallback
implementations across the current core UI with `copyTextToClipboard`.
- Added an HTTP-safe clipboard function to the plugin UI SDK and wired
the host bridge to the same implementation.
- Migrated the first-party workspace-diff plugin to the plugin SDK
clipboard function.
- Added unit coverage for native rejection fallback and plugin host
delegation.
- Added a source-level regression test that rejects new direct clipboard
writes outside the shared implementation.
- Documented the plugin UI clipboard function.

## Verification

- `NODE_ENV=test pnpm exec vitest run ...` for 14 affected suites: 164
tests passed.
- `pnpm exec vitest run tests/ui-clipboard.test.ts` in
`packages/plugins/sdk`: 1 test passed.
- `NODE_ENV=test pnpm -r typecheck`: passed for 31 workspace projects.
- `NODE_ENV=test pnpm test:run`: passed.
- `NODE_ENV=production pnpm build`: passed.
- `pnpm check:token-gates`: passed with all gates clean.

## Risks

Low risk. Secure contexts still use the modern Clipboard API. Plain HTTP
and rejected modern writes use the existing `execCommand("copy")`
fallback. That API is deprecated, but it is the compatibility path
required for insecure contexts. The change has no schema, API, or visual
design effect.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI Codex, `gpt-5.6-sol`. The runtime did not expose a context-window
size. Reasoning, tool use, repository editing, test execution, and
GitHub CLI access were enabled.

## Checklist

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

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-08-05 10:45:08 -05:00

114 lines
4.2 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from "react";
import type { IssueDocument } from "@paperclipai/shared";
import { ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY } from "@paperclipai/shared";
import { Button } from "@/components/ui/button";
import { cn, relativeTime } from "../lib/utils";
import { MarkdownBody, type MarkdownExternalReferenceMap } from "./MarkdownBody";
import { Check, ChevronDown, ChevronRight, Copy, History } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { copyTextToClipboard } from "@/lib/clipboard";
type IssueContinuationHandoffProps = {
document: IssueDocument | null | undefined;
focusSignal?: number;
externalReferences?: MarkdownExternalReferenceMap;
};
export function IssueContinuationHandoff({
document,
focusSignal = 0,
externalReferences,
}: IssueContinuationHandoffProps) {
const [expanded, setExpanded] = useState(false);
const [copied, setCopied] = useState(false);
const [highlighted, setHighlighted] = useState(false);
const rootRef = useRef<HTMLDivElement | null>(null);
const copiedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (copiedTimerRef.current) {
clearTimeout(copiedTimerRef.current);
}
};
}, []);
useEffect(() => {
if (!document || focusSignal <= 0) return;
setExpanded(true);
setHighlighted(true);
rootRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
const timer = setTimeout(() => setHighlighted(false), 3000);
return () => clearTimeout(timer);
}, [document, focusSignal]);
const copyBody = useCallback(async () => {
if (!document) return;
try {
await copyTextToClipboard(document.body);
} catch {
return;
}
setCopied(true);
if (copiedTimerRef.current) {
clearTimeout(copiedTimerRef.current);
}
copiedTimerRef.current = setTimeout(() => setCopied(false), 1500);
}, [document]);
if (!document) return null;
const title = document.title?.trim() || "Continuation handoff";
return (
<div
ref={rootRef}
id={`document-${ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY}`}
className={cn(
"mb-3 rounded-lg border border-border bg-accent/20 p-3 transition-colors duration-1000",
highlighted && "border-primary/50 bg-primary/5",
)}
>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
onClick={() => setExpanded((current) => !current)}
aria-label={expanded ? "Collapse continuation handoff" : "Expand continuation handoff"}
aria-expanded={expanded}
>
{expanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
</button>
<History className="h-4 w-4 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium text-foreground">{title}</span>
<Badge variant="outline" className="border-border font-mono text-(length:--text-nano) uppercase text-muted-foreground">
handoff
</Badge>
</div>
<div className="text-(length:--text-micro) text-muted-foreground">
Updated {relativeTime(document.updatedAt)}
{document.latestRevisionNumber > 0 ? ` - revision ${document.latestRevisionNumber}` : ""}
</div>
</div>
<Button variant="ghost" size="sm" onClick={copyBody} className="shrink-0">
{copied ? <Check className="mr-1.5 h-3.5 w-3.5" /> : <Copy className="mr-1.5 h-3.5 w-3.5" />}
{copied ? "Copied" : "Copy"}
</Button>
</div>
{expanded ? (
<div className="mt-3 rounded-md border border-border bg-background/80 p-3">
<MarkdownBody
className="paperclip-edit-in-place-content text-sm leading-6"
softBreaks={false}
externalReferences={externalReferences}
>
{document.body}
</MarkdownBody>
</div>
) : null}
</div>
);
}