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

@@ -802,3 +802,28 @@
- **Status:** done
- **Completed:** 2026-02-15 — commit `e1ba4aa`, tagged `v1.57.1`
- **Description:** Session deletion doesn't persist — when deleting a session from the sidebar, it disappears visually but comes back on page reload. The deletion is only client-side/cosmetic and doesn't actually delete the session from the OpenClaw backend. Need to call the proper API endpoint to actually delete/archive the session server-side. (Feedback from Bardak)
## Item #75
- **Date:** 2026-02-15
- **Priority:** high
- **Status:** open
- **Description:** Session info tooltip not clickable — clicking inside the tooltip (e.g. to copy sessionKey) closes it immediately. The click event bubbles up to the parent button that toggles the tooltip. Need stopPropagation on the tooltip container. (Note: fix attempted in v1.63.3 but marlbot-chat had stale files)
## Item #76
- **Date:** 2026-02-15
- **Priority:** high
- **Status:** open
- **Description:** Code/payload copy button doesn't work — clicking the copy button on code blocks and tool call payloads does nothing. Investigate clipboard API calls in CodeBlock and ToolCall components.
## Item #78
- **Date:** 2026-02-15
- **Priority:** medium
- **Status:** open
- **Source:** Josh (Bardak)
- **Description:** Session renaming — allow users to rename sessions from the sidebar. OpenClaw supports `sessions.patch` with a `label` field. Add a rename action (double-click, right-click menu, or edit icon) on session names in the sidebar. Requires `operator.admin` scope (already added in v1.63.3).
## Item #77
- **Date:** 2026-02-15
- **Priority:** high
- **Status:** open
- **Description:** Compact button doesn't work — need to verify the correct OpenClaw API for triggering compaction. Current implementation sends `sessions.compact` which requires `operator.admin` scope (added in v1.63.3). If it still doesn't work, check OpenClaw docs for the correct method name and parameters. May need to use `/compact` slash command equivalent via WS.

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "pinchchat",
"version": "1.63.2",
"version": "1.64.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pinchchat",
"version": "1.63.2",
"version": "1.64.0",
"license": "MIT",
"dependencies": {
"@tailwindcss/vite": "^4.1.18",

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;
}
}