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:
25
FEEDBACK.md
25
FEEDBACK.md
@@ -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
4
package-lock.json
generated
@@ -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",
|
||||
|
||||
12
src/App.tsx
12
src/App.tsx
@@ -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 */}
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
35
src/lib/clipboard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user