Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion packages/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,28 +13,31 @@
},
"dependencies": {
"@codingcode/core": "workspace:*",
"@tanstack/react-virtual": "^3.12.6",
"immer": "^10.1.1",
"lucide-react": "^1.17.0",
"prismjs": "^1.30.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^2.1.7",
"@tanstack/react-virtual": "^3.12.6",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"simple-git": "^3.27.0",
"zustand": "^5.0.5"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.8",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^22.0.0",
"@types/react": "^19.1.6",
"@types/react-dom": "^19.1.5",
"@vitejs/plugin-react": "^4.4.1",
"electron": "^35.5.0",
"electron-builder": "^25.1.8",
"electron-vite": "^3.1.0",
"jsdom": "^29.1.1",
"tailwindcss": "^4.1.8",
"typescript": "^5.8.3"
}
Expand Down
18 changes: 18 additions & 0 deletions packages/desktop/src/hooks/useCopyToClipboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { useState, useCallback } from 'react';

export function useCopyToClipboard(resetDelay = 1500) {
const [copiedId, setCopiedId] = useState<string | null>(null);

const copy = useCallback(
async (text: string, id: string) => {
await navigator.clipboard.writeText(text);
setCopiedId(id);
setTimeout(() => {
setCopiedId((prev) => (prev === id ? null : prev));
}, resetDelay);
},
[resetDelay]
);

return { copiedId, copy };
}
45 changes: 43 additions & 2 deletions packages/desktop/src/shared/CodeBlock.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useEffect, useRef } from 'react';
import Prism from 'prismjs';
import { Copy, Check } from 'lucide-react';
import 'prismjs/components/prism-typescript';
import 'prismjs/components/prism-javascript';
import 'prismjs/components/prism-python';
Expand All @@ -14,6 +15,7 @@ import 'prismjs/components/prism-rust';
import 'prismjs/components/prism-java';
import 'prismjs/components/prism-c';
import 'prismjs/components/prism-cpp';
import { useCopyToClipboard } from '../hooks/useCopyToClipboard';

interface CodeBlockProps {
code: string;
Expand All @@ -22,6 +24,9 @@ interface CodeBlockProps {

export default function CodeBlock({ code, language }: CodeBlockProps) {
const codeRef = useRef<HTMLElement>(null);
const { copiedId, copy } = useCopyToClipboard();
const codeKey = `code-${code.slice(0, 20)}`;
const isCopied = copiedId === codeKey;

useEffect(() => {
if (codeRef.current && language && Prism.languages[language]) {
Expand All @@ -34,10 +39,46 @@ export default function CodeBlock({ code, language }: CodeBlockProps) {
return (
<div className="relative group my-1 rounded overflow-hidden bg-[var(--bg-card)] border border-[var(--border-strong)]">
{language && (
<div className="px-3 py-1 text-xs text-[var(--text-muted)] bg-[var(--border-default)] border-b border-[var(--border-strong)] font-mono">
{language}
<div className="flex items-center justify-between px-3 py-1 text-xs text-[var(--text-muted)] bg-[var(--border-default)] border-b border-[var(--border-strong)] font-mono">
<span>{language}</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
copy(code, codeKey);
}}
aria-label="复制代码"
title="复制"
className={`flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] transition-all opacity-0 group-hover:opacity-100 ${
isCopied
? 'bg-[var(--accent-success)] text-[var(--text-inverse)]'
: 'text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-hover)]'
}`}
>
{isCopied ? <Check size={12} /> : <Copy size={12} />}
{isCopied ? '已复制' : '复制'}
</button>
</div>
)}
{!language && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
copy(code, codeKey);
}}
aria-label="复制代码"
title="复制"
className={`absolute top-1 right-1 z-10 flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] transition-all opacity-0 group-hover:opacity-100 ${
isCopied
? 'bg-[var(--accent-success)] text-[var(--text-inverse)]'
: 'bg-[var(--bg-hover)] text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-active)]'
}`}
>
{isCopied ? <Check size={12} /> : <Copy size={12} />}
{isCopied ? '已复制' : '复制'}
</button>
)}
<pre className="p-3 text-[13px] font-mono overflow-x-auto leading-relaxed whitespace-pre-wrap break-all m-0">
<code ref={codeRef} className={`language-${lang}`}>
{code}
Expand Down
51 changes: 48 additions & 3 deletions packages/desktop/src/shared/MessageItem.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { useState, useRef, useLayoutEffect, memo } from 'react';
import { Copy, Check } from 'lucide-react';
import type { Item } from '@shared/types';
import ToolCallCard from './ToolCallCard';
import DiffBlock from './DiffBlock';
import ToolSummary from './ToolSummary';
import MarkdownRenderer from './MarkdownRenderer';
import { useCopyToClipboard } from '../hooks/useCopyToClipboard';

interface MessageItemProps {
item: Item;
Expand Down Expand Up @@ -34,10 +36,13 @@ const MessageItem = memo(function MessageItem({
const rollbackBtnRef = useRef<HTMLButtonElement>(null);
const rollbackMenuRef = useRef<HTMLDivElement>(null);
const [menuFlip, setMenuFlip] = useState<{ vertical?: boolean; horizontal?: boolean }>({});
const { copiedId, copy } = useCopyToClipboard();

const messageContent = item.type === 'message' ? item.content : null;
const isAssistant = item.type === 'message' && item.role === 'assistant';

const isCopied = copiedId === `msg-${item.id}`;

// Dynamically flip menu if it would overflow the viewport
useLayoutEffect(() => {
if (!rollbackMenuOpen || !rollbackMenuRef.current || !rollbackBtnRef.current) return;
Expand All @@ -62,8 +67,8 @@ const MessageItem = memo(function MessageItem({

if (isUser) {
return (
<div className="flex justify-end mb-4 mt-4">
<div className="relative max-w-[78%] px-4 py-3 rounded-2xl rounded-br-sm bg-[var(--border-card)] text-[var(--text-title)] text-[15px] leading-relaxed whitespace-pre-wrap break-words group">
<div className="flex flex-col items-end mb-4 mt-4 group">
<div className="relative max-w-[78%] px-4 py-3 rounded-2xl rounded-br-sm bg-[var(--border-card)] text-[var(--text-title)] text-[15px] leading-relaxed whitespace-pre-wrap break-words">
{content}
{hasRollback && (
<div className="absolute -right-1 -bottom-1">
Expand Down Expand Up @@ -114,12 +119,31 @@ const MessageItem = memo(function MessageItem({
</div>
)}
</div>
<div className="mt-1.5 mr-1 flex items-center gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
copy(content, `msg-${item.id}`);
}}
aria-label="复制消息"
title="复制"
className={`flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] transition-colors ${
isCopied
? 'bg-[var(--accent-success)] text-[var(--text-inverse)]'
: 'text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-hover)]'
}`}
>
{isCopied ? <Check size={12} /> : <Copy size={12} />}
{isCopied ? '已复制' : '复制'}
</button>
</div>
</div>
);
}

return (
<div className="flex justify-start mb-1 pl-8">
<div className="flex flex-col items-start mb-1 pl-8 group">
<div className="max-w-[80%] text-[15px] text-[var(--text-primary)] leading-relaxed">
{isAssistant && messageContent != null && (
<MarkdownRenderer content={messageContent} />
Expand All @@ -128,6 +152,27 @@ const MessageItem = memo(function MessageItem({
<span className="inline-block w-1.5 h-[1.1em] bg-[var(--accent-primary)] animate-pulse ml-0.5 align-middle" />
)}
</div>
{isAssistant && !item.partial && messageContent != null && (
<div className="max-w-[80%] mt-1.5 flex items-center gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
copy(messageContent || '', `msg-${item.id}`);
}}
aria-label="复制消息"
title="复制"
className={`flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] transition-colors ${
isCopied
? 'bg-[var(--accent-success)] text-[var(--text-inverse)]'
: 'text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-hover)]'
}`}
>
{isCopied ? <Check size={12} /> : <Copy size={12} />}
{isCopied ? '已复制' : '复制'}
</button>
</div>
)}
</div>
);
}
Expand Down
Loading
Loading