mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 11:20:42 +01:00
## 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>
103 lines
3.5 KiB
TypeScript
103 lines
3.5 KiB
TypeScript
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
|
import { ExternalLink, RefreshCw, Share2 } from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
|
import { useOptionalToastActions } from "../context/ToastContext";
|
|
import { CHROMELESS_DISPLAY_MODES, isChromelessDisplayMode } from "../lib/pwa-display-mode";
|
|
import { copyTextToClipboard } from "../lib/clipboard";
|
|
|
|
function ControlButton({
|
|
label,
|
|
children,
|
|
onClick,
|
|
}: {
|
|
label: string;
|
|
children: ReactNode;
|
|
onClick: () => void | Promise<void>;
|
|
}) {
|
|
return (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
className="size-8 text-muted-foreground hover:text-foreground"
|
|
aria-label={label}
|
|
onClick={() => void onClick()}
|
|
>
|
|
{children}
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{label}</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
export function StandaloneBrowserControls({ mobile }: { mobile: boolean }) {
|
|
const [chromeless, setChromeless] = useState(() =>
|
|
typeof window !== "undefined" && mobile ? isChromelessDisplayMode() : false,
|
|
);
|
|
const toastActions = useOptionalToastActions();
|
|
|
|
useEffect(() => {
|
|
if (!mobile || typeof window === "undefined") {
|
|
setChromeless(false);
|
|
return;
|
|
}
|
|
|
|
const update = () => setChromeless(isChromelessDisplayMode());
|
|
|
|
update();
|
|
if (typeof window.matchMedia !== "function") return;
|
|
|
|
const mediaQueries = CHROMELESS_DISPLAY_MODES.map((mode) => window.matchMedia(`(display-mode: ${mode})`));
|
|
if (mediaQueries.every((media) => typeof media.addEventListener === "function")) {
|
|
mediaQueries.forEach((media) => media.addEventListener("change", update));
|
|
return () => mediaQueries.forEach((media) => media.removeEventListener("change", update));
|
|
}
|
|
|
|
mediaQueries.forEach((media) => media.addListener(update));
|
|
return () => mediaQueries.forEach((media) => media.removeListener(update));
|
|
}, [mobile]);
|
|
|
|
const refresh = useCallback(() => {
|
|
window.location.reload();
|
|
}, []);
|
|
|
|
const share = useCallback(async () => {
|
|
const url = window.location.href;
|
|
try {
|
|
if (navigator.share) {
|
|
await navigator.share({ title: document.title || "Paperclip", url });
|
|
return;
|
|
}
|
|
await copyTextToClipboard(url);
|
|
toastActions?.pushToast({ title: "Link copied", tone: "success" });
|
|
} catch (error) {
|
|
if (error instanceof DOMException && error.name === "AbortError") return;
|
|
toastActions?.pushToast({ title: "Share failed", body: "Try opening the page in your browser.", tone: "error" });
|
|
}
|
|
}, [toastActions]);
|
|
|
|
const openInBrowser = useCallback(() => {
|
|
window.open(window.location.href, "_blank", "noopener,noreferrer");
|
|
}, []);
|
|
|
|
if (!mobile || !chromeless) return null;
|
|
|
|
return (
|
|
<div className="flex h-10 items-center justify-end gap-1 border-b border-border bg-background/95 px-3 backdrop-blur supports-[backdrop-filter]:bg-background/85">
|
|
<ControlButton label="Refresh" onClick={refresh}>
|
|
<RefreshCw className="h-4 w-4" />
|
|
</ControlButton>
|
|
<ControlButton label="Share" onClick={share}>
|
|
<Share2 className="h-4 w-4" />
|
|
</ControlButton>
|
|
<ControlButton label="Open in Browser" onClick={openInBrowser}>
|
|
<ExternalLink className="h-4 w-4" />
|
|
</ControlButton>
|
|
</div>
|
|
);
|
|
}
|