From e42a05d248a542b075f55d73d74f6ec137117f4b Mon Sep 17 00:00:00 2001 From: moehaje Date: Wed, 14 Jan 2026 22:07:00 +0100 Subject: [PATCH 1/3] feat: add copy thread action Add a main header button to copy the active thread transcript with animated feedback. --- src/App.tsx | 24 ++++++++++++ src/components/MainHeader.tsx | 50 ++++++++++++++++++++++++- src/hooks/useLayoutNodes.tsx | 3 ++ src/styles/main.css | 56 ++++++++++++++++++++++++++++ src/utils/threadText.ts | 70 +++++++++++++++++++++++++++++++++++ 5 files changed, 202 insertions(+), 1 deletion(-) create mode 100644 src/utils/threadText.ts diff --git a/src/App.tsx b/src/App.tsx index a69309e1f..25315672b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -53,6 +53,7 @@ import { useWorktreePrompt } from "./hooks/useWorktreePrompt"; import { useUiScaleShortcuts } from "./hooks/useUiScaleShortcuts"; import { useWorkspaceSelection } from "./hooks/useWorkspaceSelection"; import { useNewAgentShortcut } from "./hooks/useNewAgentShortcut"; +import { buildThreadTranscript } from "./utils/threadText"; import type { AccessMode, DiffLineReference, QueuedMessage, WorkspaceInfo } from "./types"; function useWindowLabel() { @@ -257,6 +258,28 @@ function MainApp() { customPrompts: prompts, onMessageActivity: refreshGitStatus }); + + const handleCopyThread = useCallback(async () => { + if (!activeItems.length) { + return; + } + const transcript = buildThreadTranscript(activeItems); + if (!transcript) { + return; + } + try { + await navigator.clipboard.writeText(transcript); + } catch (error) { + addDebugEntry({ + id: `${Date.now()}-client-copy-thread-error`, + timestamp: Date.now(), + source: "error", + label: "thread/copy error", + payload: error instanceof Error ? error.message : String(error), + }); + } + }, [activeItems, addDebugEntry]); + const { activeImages, attachImages, @@ -687,6 +710,7 @@ function MainApp() { branches, onCheckoutBranch: handleCheckoutBranch, onCreateBranch: handleCreateBranch, + onCopyThread: handleCopyThread, centerMode, onExitDiff: () => { setCenterMode("chat"); diff --git a/src/components/MainHeader.tsx b/src/components/MainHeader.tsx index 3f2616663..130293184 100644 --- a/src/components/MainHeader.tsx +++ b/src/components/MainHeader.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from "react"; -import { Copy } from "lucide-react"; +import { Check, Copy } from "lucide-react"; import { revealItemInDir } from "@tauri-apps/plugin-opener"; import type { BranchInfo, WorkspaceInfo } from "../types"; @@ -14,6 +14,8 @@ type MainHeaderProps = { branches: BranchInfo[]; onCheckoutBranch: (name: string) => Promise | void; onCreateBranch: (name: string) => Promise | void; + canCopyThread?: boolean; + onCopyThread?: () => void | Promise; }; export function MainHeader({ @@ -27,12 +29,16 @@ export function MainHeader({ branches, onCheckoutBranch, onCreateBranch, + canCopyThread = false, + onCopyThread, }: MainHeaderProps) { const [menuOpen, setMenuOpen] = useState(false); const [infoOpen, setInfoOpen] = useState(false); const [isCreating, setIsCreating] = useState(false); const [newBranch, setNewBranch] = useState(""); const [error, setError] = useState(null); + const [copyFeedback, setCopyFeedback] = useState(false); + const copyTimeoutRef = useRef(null); const menuRef = useRef(null); const infoRef = useRef(null); @@ -66,6 +72,32 @@ export function MainHeader({ }; }, [infoOpen, menuOpen]); + useEffect(() => { + return () => { + if (copyTimeoutRef.current) { + window.clearTimeout(copyTimeoutRef.current); + } + }; + }, []); + + const handleCopyClick = async () => { + if (!onCopyThread) { + return; + } + try { + await onCopyThread(); + setCopyFeedback(true); + if (copyTimeoutRef.current) { + window.clearTimeout(copyTimeoutRef.current); + } + copyTimeoutRef.current = window.setTimeout(() => { + setCopyFeedback(false); + }, 1200); + } catch { + // Errors are handled upstream in the copy handler. + } + }; + return (
@@ -243,6 +275,22 @@ export function MainHeader({ )}
+
+ +
); } diff --git a/src/hooks/useLayoutNodes.tsx b/src/hooks/useLayoutNodes.tsx index 1056ca68c..4184b22bb 100644 --- a/src/hooks/useLayoutNodes.tsx +++ b/src/hooks/useLayoutNodes.tsx @@ -98,6 +98,7 @@ type LayoutNodesOptions = { branches: BranchInfo[]; onCheckoutBranch: (name: string) => Promise; onCreateBranch: (name: string) => Promise; + onCopyThread: () => void | Promise; centerMode: "chat" | "diff"; onExitDiff: () => void; activeTab: "projects" | "codex" | "git" | "log"; @@ -315,6 +316,8 @@ export function useLayoutNodes(options: LayoutNodesOptions): LayoutNodesResult { branches={options.branches} onCheckoutBranch={options.onCheckoutBranch} onCreateBranch={options.onCreateBranch} + canCopyThread={options.activeItems.length > 0} + onCopyThread={options.onCopyThread} /> ) : null; diff --git a/src/styles/main.css b/src/styles/main.css index 78085b38f..c591e170c 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -16,6 +16,7 @@ } .main-header { + width: 100%; display: flex; justify-content: space-between; align-items: center; @@ -30,6 +31,61 @@ min-width: 0; } +.main-header-actions { + display: flex; + align-items: center; + gap: 8px; + -webkit-app-region: no-drag; +} + +.main-header-action { + padding: 6px; + border-radius: 8px; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.main-header-icon { + position: relative; + width: 14px; + height: 14px; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.main-header-icon svg { + position: absolute; + inset: 0; + transition: opacity 160ms ease, transform 160ms ease, filter 160ms ease; +} + +.main-header-icon-copy { + opacity: 1; + transform: scale(1); + filter: blur(0); +} + +.main-header-icon-check { + opacity: 0; + transform: scale(0.82); + filter: blur(2px); + transition-delay: 60ms; +} + +.main-header-action.is-copied .main-header-icon-copy { + opacity: 0; + transform: scale(0.82); + filter: blur(2px); +} + +.main-header-action.is-copied .main-header-icon-check { + opacity: 1; + transform: scale(1); + filter: blur(0); +} + .main-topbar { display: flex; justify-content: space-between; diff --git a/src/utils/threadText.ts b/src/utils/threadText.ts new file mode 100644 index 000000000..39b8c9b4f --- /dev/null +++ b/src/utils/threadText.ts @@ -0,0 +1,70 @@ +import type { ConversationItem } from "../types"; + +function formatMessage(item: Extract) { + const roleLabel = item.role === "user" ? "User" : "Assistant"; + return `${roleLabel}: ${item.text}`; +} + +function formatReasoning(item: Extract) { + const parts = ["Reasoning:"]; + if (item.summary) { + parts.push(item.summary); + } + if (item.content) { + parts.push(item.content); + } + return parts.join("\n"); +} + +function formatTool(item: Extract) { + const parts = [`Tool: ${item.title}`]; + if (item.detail) { + parts.push(item.detail); + } + if (item.status) { + parts.push(`Status: ${item.status}`); + } + if (item.output) { + parts.push(item.output); + } + if (item.changes && item.changes.length > 0) { + parts.push( + "Changes:\n" + + item.changes + .map((change) => `- ${change.path}${change.kind ? ` (${change.kind})` : ""}`) + .join("\n"), + ); + } + return parts.join("\n"); +} + +function formatDiff(item: Extract) { + const header = `Diff: ${item.title}`; + const status = item.status ? `Status: ${item.status}` : null; + return [header, status, item.diff].filter(Boolean).join("\n"); +} + +function formatReview(item: Extract) { + return `Review (${item.state}): ${item.text}`; +} + +export function buildThreadTranscript(items: ConversationItem[]) { + return items + .map((item) => { + switch (item.kind) { + case "message": + return formatMessage(item); + case "reasoning": + return formatReasoning(item); + case "tool": + return formatTool(item); + case "diff": + return formatDiff(item); + case "review": + return formatReview(item); + } + return ""; + }) + .filter((value) => value.trim().length > 0) + .join("\n\n"); +} From cfb8393883d9b09d01a0830c78041d608e008127 Mon Sep 17 00:00:00 2001 From: moehaje Date: Thu, 15 Jan 2026 00:27:35 +0100 Subject: [PATCH 2/3] feat: add per-message copy button Add hover-only copy controls on chat bubbles with animated check feedback. --- src/components/Messages.tsx | 41 ++++++++++++++++++++++- src/styles/messages.css | 65 +++++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 1 deletion(-) diff --git a/src/components/Messages.tsx b/src/components/Messages.tsx index 8dd0be50e..6ec7937a5 100644 --- a/src/components/Messages.tsx +++ b/src/components/Messages.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from "react"; +import { Check, Copy } from "lucide-react"; import type { ConversationItem } from "../types"; import { Markdown } from "./Markdown"; import { DiffBlock } from "./DiffBlock"; @@ -206,6 +207,8 @@ export function Messages({ }: MessagesProps) { const bottomRef = useRef(null); const [expandedItems, setExpandedItems] = useState>(new Set()); + const [copiedMessageId, setCopiedMessageId] = useState(null); + const copyTimeoutRef = useRef(null); const [elapsedMs, setElapsedMs] = useState(0); const scrollKey = scrollKeyForItems(items); const toggleExpanded = (id: string) => { @@ -222,6 +225,29 @@ export function Messages({ const visibleItems = items; + useEffect(() => { + return () => { + if (copyTimeoutRef.current) { + window.clearTimeout(copyTimeoutRef.current); + } + }; + }, []); + + const handleCopyMessage = async (item: Extract) => { + try { + await navigator.clipboard.writeText(item.text); + setCopiedMessageId(item.id); + if (copyTimeoutRef.current) { + window.clearTimeout(copyTimeoutRef.current); + } + copyTimeoutRef.current = window.setTimeout(() => { + setCopiedMessageId(null); + }, 1200); + } catch { + // No-op: clipboard errors can occur in restricted contexts. + } + }; + useEffect(() => { if (!bottomRef.current) { return undefined; @@ -275,10 +301,23 @@ export function Messages({ > {visibleItems.map((item) => { if (item.kind === "message") { + const isCopied = copiedMessageId === item.id; return (
-
+
+
); diff --git a/src/styles/messages.css b/src/styles/messages.css index 83cff4ef8..2e8656e8a 100644 --- a/src/styles/messages.css +++ b/src/styles/messages.css @@ -127,6 +127,71 @@ word-break: break-word; } +.message-bubble { + position: relative; +} + +.message-copy-button { + display: inline-flex; + align-items: center; + justify-content: center; + position: absolute; + right: 6px; + bottom: -12px; + padding: 4px; + border-radius: 999px; + background: var(--surface-card-strong); + border: 1px solid var(--border-strong); + opacity: 0; + transform: translateY(4px); + transition: opacity 160ms ease, transform 160ms ease; +} + +.message:hover .message-copy-button { + opacity: 1; + transform: translateY(0); +} + +.message-copy-icon { + position: relative; + width: 14px; + height: 14px; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.message-copy-icon svg { + position: absolute; + inset: 0; + transition: opacity 160ms ease, transform 160ms ease, filter 160ms ease; +} + +.message-copy-icon-copy { + opacity: 1; + transform: scale(1); + filter: blur(0); +} + +.message-copy-icon-check { + opacity: 0; + transform: scale(0.82); + filter: blur(2px); + transition-delay: 60ms; +} + +.message-copy-button.is-copied .message-copy-icon-copy { + opacity: 0; + transform: scale(0.82); + filter: blur(2px); +} + +.message-copy-button.is-copied .message-copy-icon-check { + opacity: 1; + transform: scale(1); + filter: blur(0); +} + .message.user .bubble { background: var(--surface-bubble-user); } From 27acd66f135e624e39256db51f8f106655c9b64c Mon Sep 17 00:00:00 2001 From: Thomas Ricouard Date: Thu, 15 Jan 2026 06:38:24 +0100 Subject: [PATCH 3/3] Refactor thread copy logic into useCopyThread hook Moved the thread copying functionality from MainApp into a new reusable useCopyThread hook. This improves code organization and reusability by encapsulating the clipboard and error handling logic. --- src/App.tsx | 26 +++++--------------------- src/hooks/useCopyThread.ts | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 21 deletions(-) create mode 100644 src/hooks/useCopyThread.ts diff --git a/src/App.tsx b/src/App.tsx index 25315672b..d0dd78364 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -53,7 +53,7 @@ import { useWorktreePrompt } from "./hooks/useWorktreePrompt"; import { useUiScaleShortcuts } from "./hooks/useUiScaleShortcuts"; import { useWorkspaceSelection } from "./hooks/useWorkspaceSelection"; import { useNewAgentShortcut } from "./hooks/useNewAgentShortcut"; -import { buildThreadTranscript } from "./utils/threadText"; +import { useCopyThread } from "./hooks/useCopyThread"; import type { AccessMode, DiffLineReference, QueuedMessage, WorkspaceInfo } from "./types"; function useWindowLabel() { @@ -259,26 +259,10 @@ function MainApp() { onMessageActivity: refreshGitStatus }); - const handleCopyThread = useCallback(async () => { - if (!activeItems.length) { - return; - } - const transcript = buildThreadTranscript(activeItems); - if (!transcript) { - return; - } - try { - await navigator.clipboard.writeText(transcript); - } catch (error) { - addDebugEntry({ - id: `${Date.now()}-client-copy-thread-error`, - timestamp: Date.now(), - source: "error", - label: "thread/copy error", - payload: error instanceof Error ? error.message : String(error), - }); - } - }, [activeItems, addDebugEntry]); + const { handleCopyThread } = useCopyThread({ + activeItems, + onDebug: addDebugEntry, + }); const { activeImages, diff --git a/src/hooks/useCopyThread.ts b/src/hooks/useCopyThread.ts new file mode 100644 index 000000000..1f8c86abe --- /dev/null +++ b/src/hooks/useCopyThread.ts @@ -0,0 +1,33 @@ +import { useCallback } from "react"; +import { buildThreadTranscript } from "../utils/threadText"; +import type { ConversationItem, DebugEntry } from "../types"; + +type CopyThreadOptions = { + activeItems: ConversationItem[]; + onDebug: (entry: DebugEntry) => void; +}; + +export function useCopyThread({ activeItems, onDebug }: CopyThreadOptions) { + const handleCopyThread = useCallback(async () => { + if (!activeItems.length) { + return; + } + const transcript = buildThreadTranscript(activeItems); + if (!transcript) { + return; + } + try { + await navigator.clipboard.writeText(transcript); + } catch (error) { + onDebug({ + id: `${Date.now()}-client-copy-thread-error`, + timestamp: Date.now(), + source: "error", + label: "thread/copy error", + payload: error instanceof Error ? error.message : String(error), + }); + } + }, [activeItems, onDebug]); + + return { handleCopyThread }; +}