fix: acknowledge desktop copy completion

Copy actions that did not use CopyButton could succeed silently, leaving users unsure whether the clipboard changed. Show localized success toasts for H5 URL, QR link, and workspace path copies, and report a generic copy failure when the shared clipboard helper cannot write.

Constraint: Copy must keep working across macOS, Linux, and Windows Tauri WebViews, so the existing web Clipboard API plus textarea execCommand fallback remains the platform-neutral mechanism.

Rejected: Add native OS-specific clipboard calls | unnecessary surface area when the current web fallback already covers the desktop WebView contexts.

Confidence: high

Scope-risk: narrow

Directive: Copy surfaces that cannot visibly change their own button text should emit a toast on success or failure.

Tested: cd desktop && bun run test -- --run src/__tests__/generalSettings.test.tsx src/components/workspace/WorkspacePanel.test.tsx src/i18n/index.test.tsx

Tested: bun run check:desktop

Tested: bun run verify (passed=10 failed=0 skipped=0)

Not-tested: Manual OS matrix on Linux and Windows; behavior uses platform-neutral browser clipboard APIs inside Tauri.
This commit is contained in:
程序员阿江(Relakkes) 2026-05-14 23:05:14 +08:00
parent 9ddaf29b4e
commit 0b1eb2c5b8
6 changed files with 39 additions and 3 deletions

View File

@ -446,6 +446,10 @@ describe('Settings > General tab', () => {
expect(clipboardMock.copyTextToClipboard).toHaveBeenCalledWith(
'http://192.168.0.102:3456/?serverUrl=http%3A%2F%2F192.168.0.102%3A3456&h5Token=h5_default_generated_token',
)
expect(useUIStore.getState().toasts[useUIStore.getState().toasts.length - 1]).toMatchObject({
type: 'success',
message: 'QR link copied.',
})
})
it('guides enabled H5 users to generate a token before the QR code exists', async () => {
@ -517,6 +521,10 @@ describe('Settings > General tab', () => {
})
expect(clipboardMock.copyTextToClipboard).toHaveBeenCalledWith('https://phone.example/app')
expect(useUIStore.getState().toasts[useUIStore.getState().toasts.length - 1]).toMatchObject({
type: 'success',
message: 'H5 URL copied.',
})
})
it('shows the H5-specific store error when the H5 settings load failed', () => {

View File

@ -245,6 +245,7 @@ import { useSettingsStore } from '../../stores/settingsStore'
import { useChatStore } from '../../stores/chatStore'
import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore'
import { useWorkspacePanelStore } from '../../stores/workspacePanelStore'
import { useUIStore } from '../../stores/uiStore'
import { WorkspacePanel } from './WorkspacePanel'
describe('WorkspacePanel', () => {
@ -1258,6 +1259,10 @@ describe('WorkspacePanel', () => {
expect(execCommand).toHaveBeenCalledWith('copy')
})
expect(writeText).toHaveBeenCalledWith('/repo/src/App.tsx')
expect(useUIStore.getState().toasts[useUIStore.getState().toasts.length - 1]).toMatchObject({
type: 'success',
message: 'Path copied.',
})
} finally {
Object.defineProperty(document, 'execCommand', {
configurable: true,

View File

@ -16,6 +16,7 @@ import {
} from '../../stores/workspacePanelStore'
import { useChatStore } from '../../stores/chatStore'
import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore'
import { useUIStore } from '../../stores/uiStore'
import { copyTextToClipboard } from '../chat/clipboard'
import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
import {
@ -888,6 +889,7 @@ function TreeNode({
export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
const t = useTranslation()
const addToast = useUIStore((state) => state.addToast)
const [filterQuery, setFilterQuery] = useState('')
const [isViewMenuOpen, setIsViewMenuOpen] = useState(false)
const [previewTabContextMenu, setPreviewTabContextMenu] = useState<{ tabId: string; x: number; y: number } | null>(null)
@ -1069,8 +1071,12 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
const copyWorkspacePath = async (path: string) => {
const resolvedPath = resolveWorkspaceAttachmentPath(status?.workDir, path)
await copyTextToClipboard(resolvedPath)
const copied = await copyTextToClipboard(resolvedPath)
setFileContextMenu(null)
addToast({
type: copied ? 'success' : 'error',
message: copied ? t('workspace.pathCopied') : t('common.copyFailed'),
})
}
const renderChangedView = () => {

View File

@ -14,6 +14,7 @@ export const en = {
'common.enable': 'Enable',
'common.disable': 'Disable',
'common.active': 'ACTIVE',
'common.copyFailed': 'Copy failed.',
// ─── Sidebar ──────────────────────────────────────
'sidebar.newSession': 'New session',
@ -92,6 +93,7 @@ export const en = {
'workspace.addToChat': 'Add to chat',
'workspace.addSelectionToChat': 'Add to chat',
'workspace.copyPath': 'Copy path',
'workspace.pathCopied': 'Path copied.',
'workspace.localComment': 'Local comment',
'workspace.commentLine': 'Comment line {line}',
'workspace.commentLineTarget': 'Line {line}',
@ -760,6 +762,8 @@ export const en = {
'settings.general.h5AccessCopy': 'Copy',
'settings.general.h5AccessCopyUrl': 'Copy H5 URL',
'settings.general.h5AccessCopyLaunchUrl': 'Copy QR link',
'settings.general.h5AccessUrlCopied': 'H5 URL copied.',
'settings.general.h5AccessLaunchUrlCopied': 'QR link copied.',
'settings.general.h5AccessHideToken': 'Hide token',
'settings.general.h5AccessTokenNotAvailable': 'Regenerate to show a new token.',
'settings.general.h5AccessPublicUrl': 'Public URL',

View File

@ -16,6 +16,7 @@ export const zh: Record<TranslationKey, string> = {
'common.enable': '启用',
'common.disable': '禁用',
'common.active': '已激活',
'common.copyFailed': '复制失败。',
// ─── Sidebar ──────────────────────────────────────
'sidebar.newSession': '新建会话',
@ -94,6 +95,7 @@ export const zh: Record<TranslationKey, string> = {
'workspace.addToChat': '添加到聊天',
'workspace.addSelectionToChat': '添加至对话',
'workspace.copyPath': '复制路径',
'workspace.pathCopied': '路径已复制。',
'workspace.localComment': '本地评论',
'workspace.commentLine': '评论第 {line} 行',
'workspace.commentLineTarget': '第 {line} 行',
@ -762,6 +764,8 @@ export const zh: Record<TranslationKey, string> = {
'settings.general.h5AccessCopy': '复制',
'settings.general.h5AccessCopyUrl': '复制 H5 链接',
'settings.general.h5AccessCopyLaunchUrl': '复制扫码链接',
'settings.general.h5AccessUrlCopied': 'H5 链接已复制。',
'settings.general.h5AccessLaunchUrlCopied': '扫码链接已复制。',
'settings.general.h5AccessHideToken': '隐藏令牌',
'settings.general.h5AccessTokenNotAvailable': '重新生成后可显示新的令牌。',
'settings.general.h5AccessPublicUrl': '公开访问 URL',

View File

@ -1835,6 +1835,7 @@ function H5AccessSettings() {
updateH5AccessSettings,
} = useSettingsStore()
const t = useTranslation()
const addToast = useUIStore((s) => s.addToast)
const [h5PublicBaseUrlDraft, setH5PublicBaseUrlDraft] = useState(h5Access.publicBaseUrl ?? '')
const [h5GeneratedToken, setH5GeneratedToken] = useState<string | null>(null)
const [h5TokenVisible, setH5TokenVisible] = useState(false)
@ -1895,12 +1896,20 @@ function H5AccessSettings() {
const handleH5UrlCopy = async () => {
if (!h5AccessUrl) return
await copyTextToClipboard(h5AccessUrl)
const copied = await copyTextToClipboard(h5AccessUrl)
addToast({
type: copied ? 'success' : 'error',
message: copied ? t('settings.general.h5AccessUrlCopied') : t('common.copyFailed'),
})
}
const handleH5LaunchUrlCopy = async () => {
if (!h5LaunchUrl) return
await copyTextToClipboard(h5LaunchUrl)
const copied = await copyTextToClipboard(h5LaunchUrl)
addToast({
type: copied ? 'success' : 'error',
message: copied ? t('settings.general.h5AccessLaunchUrlCopied') : t('common.copyFailed'),
})
}
const handleH5EnableConfirm = async () => {