fix: clipboard fallback for insecure contexts, session rename via sessions.patch

- Add copyToClipboard utility with execCommand fallback for HTTP deployments (#76)
- Replace all navigator.clipboard.writeText calls with copyToClipboard
- Session rename now persists server-side via sessions.patch (#78)
- Pass onRename callback from App to Sidebar
This commit is contained in:
Nicolas Varrot
2026-02-15 20:07:33 +00:00
parent e05e17acd3
commit 7606a09ba9
9 changed files with 102 additions and 15 deletions

View File

@@ -59,6 +59,17 @@ export default function App() {
}
}, [getClient, switchSession]);
const handleRename = useCallback(async (key: string, label: string): Promise<boolean> => {
const client = getClient();
if (!client) return false;
try {
await client.send('sessions.patch', { key, label });
return true;
} catch {
return false;
}
}, [getClient]);
// Split pane drag
useEffect(() => {
if (!splitDragging) return;
@@ -165,6 +176,7 @@ export default function App() {
splitSession={splitSession}
open={sidebarOpen}
onClose={() => setSidebarOpen(false)}
onRename={handleRename}
/>
<div ref={splitContainerRef} className="flex-1 flex min-w-0" aria-hidden={sidebarOpen ? true : undefined}>
{/* Primary pane */}

View File

@@ -10,6 +10,7 @@ import { CodeBlock } from './CodeBlock';
import { ToolCall } from './ToolCall';
import { ImageBlock } from './ImageBlock';
import { buildImageSrc } from '../lib/image';
import { copyToClipboard } from '../lib/clipboard';
import { Bot, User, Wrench, Copy, Check, CheckCheck, RefreshCw, Zap, Info, Webhook, Braces, Clock, AlertCircle, Bookmark, ChevronDown, Reply } from 'lucide-react';
import { t, getLocale } from '../lib/i18n';
import { useLocale } from '../hooks/useLocale';
@@ -379,9 +380,11 @@ function RawJsonPanel({ message }: { message: ChatMessageType }) {
const [copied, setCopied] = useState(false);
const json = JSON.stringify(message, null, 2);
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(json).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
copyToClipboard(json).then((ok) => {
if (ok) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
});
}, [json]);
@@ -557,7 +560,7 @@ export const ChatMessageComponent = memo(function ChatMessageComponent({ message
<div className={`flex flex-nowrap gap-0.5 justify-end mt-1.5 -mb-1 opacity-0 group-hover:opacity-100 transition-all`}>
{!isUser && !message.isStreaming && getPlainText(message).trim() && (
<button
onClick={() => { navigator.clipboard.writeText(getPlainText(message)); }}
onClick={() => { copyToClipboard(getPlainText(message)); }}
className="h-6 w-6 rounded-md flex items-center justify-center text-pc-text-faint hover:text-pc-accent-light transition-colors"
title={t('message.copy')}
aria-label={t('message.copy')}

View File

@@ -1,5 +1,6 @@
import { useState, useCallback, type HTMLAttributes, type ReactElement } from 'react';
import { Check, Copy, Hash, WrapText, AlignLeft, ChevronDown, ChevronUp } from 'lucide-react';
import { copyToClipboard } from '../lib/clipboard';
/** Extract the language from the nested <code> element's className (e.g. "language-ts"). */
function extractLanguage(children: React.ReactNode): string | null {
@@ -68,9 +69,11 @@ export function CodeBlock(props: HTMLAttributes<HTMLPreElement>) {
const handleCopy = useCallback(() => {
if (typeof code === 'string') {
navigator.clipboard.writeText(code).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
copyToClipboard(code).then((ok) => {
if (ok) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
});
}
}, [code]);

View File

@@ -3,6 +3,7 @@ import { Menu, Sparkles, LogOut, Cpu, Bot, Download, Minimize2, Info, Copy, Chec
import type { ConnectionStatus, Session, ChatMessage } from '../types';
import { useT } from '../hooks/useLocale';
import { SettingsModal } from './SettingsModal';
import { copyToClipboard } from '../lib/clipboard';
import { sessionDisplayName } from '../lib/sessionName';
import { messagesToMarkdown, downloadFile } from '../lib/exportChat';
@@ -160,7 +161,7 @@ function CopyField({ value }: { value: string }) {
return (
<button
className="ml-auto p-0.5 rounded hover:bg-[var(--pc-hover)] text-pc-text-faint hover:text-pc-text-secondary transition-colors"
onClick={() => { navigator.clipboard.writeText(value).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1500); }); }}
onClick={() => { copyToClipboard(value).then((ok) => { if (ok) { setCopied(true); setTimeout(() => setCopied(false), 1500); } }); }}
title="Copy"
>
{copied ? <Check size={11} className="text-emerald-400" /> : <Copy size={11} />}

View File

@@ -166,9 +166,10 @@ interface Props {
splitSession?: string | null;
open: boolean;
onClose: () => void;
onRename?: (key: string, label: string) => Promise<boolean>;
}
export function Sidebar({ sessions, activeSession, onSwitch, onDelete, onSplit, splitSession, open, onClose }: Props) {
export function Sidebar({ sessions, activeSession, onSwitch, onDelete, onSplit, splitSession, open, onClose, onRename }: Props) {
const t = useT();
const [filter, setFilter] = useState('');
const [focusIdx, setFocusIdx] = useState(-1);
@@ -259,9 +260,13 @@ export function Sidebar({ sessions, activeSession, onSwitch, onDelete, onSplit,
saveCustomNames(next);
return next;
});
// Also persist server-side via sessions.patch
if (onRename && trimmed) {
onRename(renamingKey, trimmed).catch(() => { /* best effort */ });
}
setRenamingKey(null);
setRenameValue('');
}, [renamingKey, renameValue]);
}, [renamingKey, renameValue, onRename]);
const cancelRename = useCallback(() => {
setRenamingKey(null);

View File

@@ -1,6 +1,7 @@
import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
import { ChevronRight, ChevronDown, Check, Copy, WrapText, AlignLeft } from 'lucide-react';
import hljs from '../lib/highlight';
import { copyToClipboard } from '../lib/clipboard';
import { useT } from '../hooks/useLocale';
import { useTheme } from '../hooks/useTheme';
import { ImageBlock } from './ImageBlock';
@@ -153,9 +154,11 @@ function WrapToggle({ wrap, onToggle }: { wrap: boolean; onToggle: () => void })
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(text).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
copyToClipboard(text).then((ok) => {
if (ok) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
});
}, [text]);

35
src/lib/clipboard.ts Normal file
View File

@@ -0,0 +1,35 @@
/**
* Copy text to clipboard with fallback for insecure contexts.
* navigator.clipboard requires HTTPS or localhost; falls back to
* a temporary textarea + execCommand('copy') for HTTP deployments.
*/
export async function copyToClipboard(text: string): Promise<boolean> {
// Try the modern API first (requires secure context)
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
// Fall through to legacy method
}
}
// Fallback: temporary textarea + execCommand
try {
const textarea = document.createElement('textarea');
textarea.value = text;
// Move off-screen to avoid visual flash
textarea.style.position = 'fixed';
textarea.style.left = '-9999px';
textarea.style.top = '-9999px';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
const ok = document.execCommand('copy');
document.body.removeChild(textarea);
return ok;
} catch {
return false;
}
}