diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 04fede7f8..183ae8e9e 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -105,6 +105,8 @@ import { extractPromptFromParts } from "@/utils/prompt" import { formatServerError, isLocalSessionNotFoundError, isSessionNotFoundError } from "@/utils/server-errors" import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route" import { postRouteInfo } from "@/utils/amicode-route-info" +import { setSessionCopyProvider } from "@/utils/global-clipboard" +import { serializeSession } from "@/utils/serialize-session" import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs" import { createSessionOwnership } from "./session/session-ownership" import { createSessionLineage } from "./session/session-lineage" @@ -2034,6 +2036,16 @@ export default function Page() { makeEventListener(document, "keydown", handleKeyDown) }) + // Register the session copy provider so Cmd+A → Cmd+C copies the full + // session from the data model (not limited by DOM virtualization). + setSessionCopyProvider(() => { + const id = params.id + if (!id) return "" + const messages = sync().data.message[id] ?? [] + return serializeSession(messages, (msgId) => sync().data.part[msgId] ?? []) + }) + onCleanup(() => setSessionCopyProvider(undefined)) + onCleanup(() => { if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame) if (todoFrame !== undefined) cancelAnimationFrame(todoFrame) diff --git a/packages/app/src/utils/global-clipboard.test.ts b/packages/app/src/utils/global-clipboard.test.ts index 609deb83c..a2144cd61 100644 --- a/packages/app/src/utils/global-clipboard.test.ts +++ b/packages/app/src/utils/global-clipboard.test.ts @@ -4,6 +4,7 @@ import { insertTextAtSelection, installGlobalClipboardFallback, isEditableTarget, + setSessionCopyProvider, } from "./global-clipboard" // Every install/listener attaches to the real happy-dom window or document, so @@ -12,6 +13,7 @@ const cleanups: Array<() => void> = [] afterEach(() => { while (cleanups.length) cleanups.pop()!() document.body.innerHTML = "" + window.getSelection()?.removeAllRanges() }) // A framed window: the real happy-dom window's event plumbing (so DOM events @@ -314,6 +316,8 @@ describe("installGlobalClipboardFallback", () => { test("mod+C with nothing selected posts nothing and leaves the event alone", () => { const bridge = framedWindow() install(bridge.win) + // Clear any lingering document selection from prior tests + window.getSelection()?.removeAllRanges() const el = field("text", "abc", 1, 1) const event = keydown(el, "c") @@ -322,16 +326,48 @@ describe("installGlobalClipboardFallback", () => { expect(bridge.posted).toHaveLength(0) }) - test("non-editable targets are never intercepted", () => { + test("non-editable targets: mod+C/X with a selection bridges the text to clipboard", () => { + const bridge = framedWindow() + install(bridge.win) + const div = document.createElement("div") + div.textContent = "chat message" + document.body.appendChild(div) + // Place a document selection on the div's text + const range = document.createRange() + range.selectNodeContents(div) + const selection = window.getSelection()! + selection.removeAllRanges() + selection.addRange(range) + + const event = keydown(div, "c") + + expect(event.defaultPrevented).toBe(true) + expect(bridge.posted).toEqual([{ source: "amicode", kind: "clipboard-write", text: "chat message" }]) + }) + + test("non-editable targets: mod+C with no selection is a no-op", () => { const bridge = framedWindow() install(bridge.win) const button = document.createElement("button") document.body.appendChild(button) + // Explicitly clear any lingering selection + window.getSelection()?.removeAllRanges() + + const event = keydown(button, "c") - for (const key of ["v", "c", "x"]) { - const event = keydown(button, key) - expect(event.defaultPrevented).toBe(false) - } + expect(event.defaultPrevented).toBe(false) + expect(bridge.posted).toHaveLength(0) + }) + + test("non-editable targets: mod+V is a no-op (nothing to paste into)", () => { + const bridge = framedWindow() + install(bridge.win) + const button = document.createElement("button") + document.body.appendChild(button) + + const event = keydown(button, "v") + + expect(event.defaultPrevented).toBe(false) expect(bridge.posted).toHaveLength(0) }) @@ -412,6 +448,180 @@ describe("installGlobalClipboardFallback", () => { expect(el.selectionEnd).toBe(11) }) + test("mod+A on an EMPTY prompt-input selects the timeline (full session intent)", () => { + const bridge = framedWindow() + install(bridge.win) + // Simulate the DOM structure: a timeline container + a prompt input + const timeline = document.createElement("div") + timeline.setAttribute("data-timeline-virtual-content", "") + timeline.textContent = "Assistant: Here is the answer." + document.body.appendChild(timeline) + const prompt = document.createElement("div") + prompt.setAttribute("data-component", "prompt-input") + prompt.setAttribute("contenteditable", "true") + prompt.textContent = "" + document.body.appendChild(prompt) + + const event = keydown(prompt, "a") + + expect(event.defaultPrevented).toBe(true) + const selection = window.getSelection()! + expect(selection.toString()).toBe("Assistant: Here is the answer.") + }) + + test("mod+A on a NON-EMPTY prompt-input selects the prompt text (standard behavior)", () => { + const bridge = framedWindow() + install(bridge.win) + const timeline = document.createElement("div") + timeline.setAttribute("data-timeline-virtual-content", "") + timeline.textContent = "chat messages" + document.body.appendChild(timeline) + const prompt = document.createElement("div") + prompt.setAttribute("data-component", "prompt-input") + prompt.setAttribute("contenteditable", "true") + prompt.textContent = "my draft message" + document.body.appendChild(prompt) + + const event = keydown(prompt, "a") + + expect(event.defaultPrevented).toBe(true) + const selection = window.getSelection()! + // Selects the prompt text, not the timeline + expect(selection.toString()).toBe("my draft message") + }) + + test("mod+A then mod+C on an EMPTY prompt-input bridges the timeline content to clipboard", () => { + const bridge = framedWindow() + install(bridge.win) + // Simulate the DOM structure: a timeline container + a prompt input + const timeline = document.createElement("div") + timeline.setAttribute("data-timeline-virtual-content", "") + timeline.textContent = "User: hello\nAssistant: world" + document.body.appendChild(timeline) + const prompt = document.createElement("div") + prompt.setAttribute("data-component", "prompt-input") + prompt.setAttribute("contenteditable", "true") + prompt.textContent = "" + document.body.appendChild(prompt) + + // Cmd+A selects the timeline (prompt is empty) + keydown(prompt, "a") + // Cmd+C copies it via bridge + const event = keydown(prompt, "c") + + expect(event.defaultPrevented).toBe(true) + expect(bridge.posted).toEqual([ + { source: "amicode", kind: "clipboard-write", text: "User: hello\nAssistant: world" }, + ]) + }) + + // --- Session copy provider (data-model full copy) --- + + test("mod+A then mod+C with a session copy provider uses the provider text", () => { + const bridge = framedWindow() + install(bridge.win) + const timeline = document.createElement("div") + timeline.setAttribute("data-timeline-virtual-content", "") + timeline.textContent = "visible portion only" + document.body.appendChild(timeline) + const prompt = document.createElement("div") + prompt.setAttribute("data-component", "prompt-input") + prompt.setAttribute("contenteditable", "true") + prompt.textContent = "" + document.body.appendChild(prompt) + + // Register a provider that returns the FULL session (as if from the store) + setSessionCopyProvider(() => "User:\nWhat is 2+2?\n\nAssistant:\n4") + cleanups.push(() => setSessionCopyProvider(undefined)) + + // Cmd+A arms the flag, Cmd+C reads from the provider + keydown(prompt, "a") + const event = keydown(prompt, "c") + + expect(event.defaultPrevented).toBe(true) + expect(bridge.posted).toEqual([ + { source: "amicode", kind: "clipboard-write", text: "User:\nWhat is 2+2?\n\nAssistant:\n4" }, + ]) + }) + + test("provider text is preferred over DOM selection (virtualised content)", () => { + const bridge = framedWindow() + install(bridge.win) + const timeline = document.createElement("div") + timeline.setAttribute("data-timeline-virtual-content", "") + timeline.textContent = "only visible rows" + document.body.appendChild(timeline) + const prompt = document.createElement("div") + prompt.setAttribute("data-component", "prompt-input") + prompt.setAttribute("contenteditable", "true") + prompt.textContent = "" + document.body.appendChild(prompt) + + setSessionCopyProvider(() => "FULL SESSION with 100 messages") + cleanups.push(() => setSessionCopyProvider(undefined)) + + keydown(prompt, "a") + const event = keydown(prompt, "c") + + expect(event.defaultPrevented).toBe(true) + // Provider text wins over the DOM "only visible rows" + expect(bridge.posted[0]!.text).toBe("FULL SESSION with 100 messages") + }) + + test("fullSessionCopyPending flag is cleared by intervening keystrokes", () => { + const bridge = framedWindow() + install(bridge.win) + const timeline = document.createElement("div") + timeline.setAttribute("data-timeline-virtual-content", "") + timeline.textContent = "chat" + document.body.appendChild(timeline) + const prompt = document.createElement("div") + prompt.setAttribute("data-component", "prompt-input") + prompt.setAttribute("contenteditable", "true") + prompt.textContent = "" + document.body.appendChild(prompt) + + setSessionCopyProvider(() => "full session") + cleanups.push(() => setSessionCopyProvider(undefined)) + + // Cmd+A arms the flag + keydown(prompt, "a") + // An intervening Cmd+Z clears the flag (it's not C/X) + keydown(prompt, "z") + // Now Cmd+C should NOT use the provider (flag was cleared) + window.getSelection()?.removeAllRanges() + const event = keydown(prompt, "c") + + expect(event.defaultPrevented).toBe(false) + expect(bridge.posted).toHaveLength(0) + }) + + test("without a provider, mod+A then mod+C falls back to DOM selection", () => { + const bridge = framedWindow() + install(bridge.win) + const timeline = document.createElement("div") + timeline.setAttribute("data-timeline-virtual-content", "") + timeline.textContent = "DOM fallback text" + document.body.appendChild(timeline) + const prompt = document.createElement("div") + prompt.setAttribute("data-component", "prompt-input") + prompt.setAttribute("contenteditable", "true") + prompt.textContent = "" + document.body.appendChild(prompt) + + // No provider registered + setSessionCopyProvider(undefined) + + keydown(prompt, "a") + const event = keydown(prompt, "c") + + expect(event.defaultPrevented).toBe(true) + // Falls back to DOM selection text + expect(bridge.posted).toEqual([ + { source: "amicode", kind: "clipboard-write", text: "DOM fallback text" }, + ]) + }) + // --- Undo (Cmd+Z) --- test("mod+Z calls execCommand undo on a contenteditable", () => { diff --git a/packages/app/src/utils/global-clipboard.ts b/packages/app/src/utils/global-clipboard.ts index 5ed3ed742..833660958 100644 --- a/packages/app/src/utils/global-clipboard.ts +++ b/packages/app/src/utils/global-clipboard.ts @@ -24,6 +24,20 @@ export function setClipboardImageHandler(handler?: (file: File) => void): void { clipboardImageHandler = handler } +// Session copy provider: registered by the session page to serialize the full +// session from the data model (messages + parts → text). The clipboard handler +// calls this on Cmd+C after a "select all" instead of reading from the DOM +// (which is incomplete due to virtualization). +let sessionCopyProvider: (() => string) | undefined + +export function setSessionCopyProvider(provider?: () => string): void { + sessionCopyProvider = provider +} + +// Flag: set by Cmd+A when targeting the prompt (signals "user wants the full +// session"), consumed by the next Cmd+C, cleared on any other keystroke. +let fullSessionCopyPending = false + // Elements that carry their own bridged paste (the profile fields' // pasteFallback) mark themselves so the fallback doesn't double-insert. The // marker owns PASTE only: nothing element-local handles copy/cut, so ⌘C/⌘X @@ -183,11 +197,105 @@ export function installGlobalClipboardFallback(win: Window = window): () => void if (key !== "v" && key !== "c" && key !== "x" && key !== "a" && key !== "z" && key !== "y") return const target = event.target - if (!isEditableTarget(target)) return // non-editables keep native behavior + + // Clear the full-session flag on any keystroke that isn't the copy that + // consumes it. Cmd+A sets it; only the immediately following Cmd+C uses it. + if (!(key === "c" || key === "x")) { + fullSessionCopyPending = false + } + + // --- Non-editable targets (rendered messages, code blocks) --- + // Inside the VS Code webview iframe, Electron intercepts Cmd+C at the host + // level before a `copy` event fires in the iframe DOM. Route C/X/A through + // the same bridge the context menu uses successfully. + if (!isEditableTarget(target)) { + if (key === "c" || key === "x") { + // Cmd+X on non-editable = copy-only (cannot delete from rendered DOM) + // If fullSessionCopyPending, prefer the provider (full data-model copy) + if (fullSessionCopyPending && sessionCopyProvider) { + const fullText = sessionCopyProvider() + if (fullText) { + event.preventDefault() + writeClipboardViaBridge(fullText, win) + fullSessionCopyPending = false + return + } + } + fullSessionCopyPending = false + const selection = win.getSelection() + const text = selection?.toString() ?? "" + if (!text) return // no selection: no-op (clipboard unchanged) + event.preventDefault() + writeClipboardViaBridge(text, win) + return + } + if (key === "a") { + event.preventDefault() + // Scope select-all to the preview panel if the target is inside one + const panel = target instanceof Element && target.closest('#review-panel:not([aria-hidden="true"])') + if (panel) { + const content = panel.querySelector('[data-slot="session-review-v2-preview"]') ?? panel + const selection = win.getSelection() + if (selection) { + selection.removeAllRanges() + const range = win.document.createRange() + range.selectNodeContents(content) + selection.addRange(range) + } + return + } + // Otherwise select the full chat area + arm the session copy flag + fullSessionCopyPending = !!sessionCopyProvider + const selection = win.getSelection() + if (selection) { + selection.removeAllRanges() + const range = win.document.createRange() + const timeline = win.document.querySelector("[data-timeline-virtual-content]") + range.selectNodeContents(timeline ?? win.document.body) + selection.addRange(range) + } + return + } + // V/Z/Y on non-editables: no-op (nothing to paste into or undo) + return + } // --- Select all --- if (key === "a") { event.preventDefault() + // If the target is inside the review/file panel, select that panel's content + const panel = target.closest('#review-panel:not([aria-hidden="true"])') + if (panel) { + const content = panel.querySelector('[data-slot="session-review-v2-preview"]') ?? panel + const selection = win.getSelection() + if (selection) { + selection.removeAllRanges() + const range = win.document.createRange() + range.selectNodeContents(content) + selection.addRange(range) + } + return + } + // If the prompt composer is EMPTY, "select all" means the full chat session. + // If the prompt has content, standard select-all (select the draft text). + const promptEl = target.closest('[data-component="prompt-input"]') + if (promptEl) { + const hasContent = (promptEl.textContent ?? "").trim().length > 0 + if (!hasContent) { + fullSessionCopyPending = !!sessionCopyProvider + // Visual feedback: select the timeline DOM (best-effort, may be partial + // due to virtualization — the actual copy comes from the provider) + const timeline = win.document.querySelector("[data-timeline-virtual-content]") + const selection = win.getSelection() + if (selection && timeline) { + selection.removeAllRanges() + const range = win.document.createRange() + range.selectNodeContents(timeline) + selection.addRange(range) + } + return + } + } selectAll(target) return } @@ -225,7 +333,37 @@ export function installGlobalClipboardFallback(win: Window = window): () => void } const text = extractSelection(target, { cut: key === "x" }) - if (!text) return // nothing selected: the native no-op stands + if (!text) { + // Full-session copy: if Cmd+A armed the flag and a provider exists, use + // the data model (complete, not limited by DOM virtualization). + if (fullSessionCopyPending && sessionCopyProvider) { + const fullText = sessionCopyProvider() + if (fullText) { + event.preventDefault() + writeClipboardViaBridge(fullText, win) + } + fullSessionCopyPending = false + return + } + fullSessionCopyPending = false + // Fallback: a prior Cmd+A may have placed the selection on the chat session + // content (outside this editable). Copy whatever is selected in the DOM. + const selection = win.getSelection() + if (selection && selection.rangeCount > 0 && !selection.isCollapsed) { + const range = selection.getRangeAt(0) + // Only bridge-copy when the selection lives OUTSIDE this editable — if it + // were inside, extractSelection above would have found it already. + if (!target.contains(range.commonAncestorContainer)) { + const docText = selection.toString() + if (docText) { + event.preventDefault() + writeClipboardViaBridge(docText, win) + } + } + } + return + } + fullSessionCopyPending = false event.preventDefault() writeClipboardViaBridge(text, win) } diff --git a/packages/app/src/utils/serialize-session.test.ts b/packages/app/src/utils/serialize-session.test.ts new file mode 100644 index 000000000..3a6707a0c --- /dev/null +++ b/packages/app/src/utils/serialize-session.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, test } from "bun:test" +import { serializeSession } from "./serialize-session" +import type { Message, Part } from "@opencode-ai/sdk/v2" + +function userMsg(id: string): Message { + return { + id, + sessionID: "s1", + role: "user", + time: { created: 1 }, + agent: "default", + model: { providerID: "test", modelID: "test" }, + } +} + +function assistantMsg(id: string): Message { + return { + id, + sessionID: "s1", + role: "assistant", + time: { created: 2 }, + parentID: "m1", + modelID: "test", + providerID: "test", + mode: "default", + agent: "default", + path: { cwd: "/", root: "/" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + } +} + +function textPart(messageID: string, text: string, opts?: { ignored?: boolean; synthetic?: boolean }): Part { + return { + id: `p-${messageID}-${Math.random().toString(36).slice(2, 6)}`, + sessionID: "s1", + messageID, + type: "text", + text, + ignored: opts?.ignored, + synthetic: opts?.synthetic, + } +} + +function toolPart(messageID: string): Part { + return { + id: `t-${messageID}`, + sessionID: "s1", + messageID, + type: "tool", + callID: "call1", + tool: "bash", + state: { + status: "completed", + input: {}, + output: "done", + title: "bash", + metadata: {}, + time: { start: 1, end: 2 }, + }, + } +} + +describe("serializeSession", () => { + test("formats a simple user + assistant exchange", () => { + const messages: Message[] = [userMsg("m1"), assistantMsg("m2")] + const parts: Record = { + m1: [textPart("m1", "How do I list files?")], + m2: [textPart("m2", "Use `ls` to list files in the current directory.")], + } + + const result = serializeSession(messages, (id) => parts[id] ?? []) + + expect(result).toBe( + "User:\nHow do I list files?\n\nAssistant:\nUse `ls` to list files in the current directory.", + ) + }) + + test("concatenates multiple text parts within a single message", () => { + const messages: Message[] = [assistantMsg("m1")] + const parts: Record = { + m1: [textPart("m1", "First chunk. "), textPart("m1", "Second chunk.")], + } + + const result = serializeSession(messages, (id) => parts[id] ?? []) + + expect(result).toBe("Assistant:\nFirst chunk. Second chunk.") + }) + + test("skips messages with no text parts", () => { + const messages: Message[] = [userMsg("m1"), assistantMsg("m2"), assistantMsg("m3")] + const parts: Record = { + m1: [textPart("m1", "hello")], + m2: [toolPart("m2")], // only a tool call, no text + m3: [textPart("m3", "done")], + } + + const result = serializeSession(messages, (id) => parts[id] ?? []) + + expect(result).toBe("User:\nhello\n\nAssistant:\ndone") + }) + + test("skips ignored and synthetic text parts", () => { + const messages: Message[] = [assistantMsg("m1")] + const parts: Record = { + m1: [ + textPart("m1", "visible"), + textPart("m1", " ignored", { ignored: true }), + textPart("m1", " synthetic", { synthetic: true }), + ], + } + + const result = serializeSession(messages, (id) => parts[id] ?? []) + + expect(result).toBe("Assistant:\nvisible") + }) + + test("skips messages where all text is whitespace", () => { + const messages: Message[] = [userMsg("m1"), assistantMsg("m2")] + const parts: Record = { + m1: [textPart("m1", " \n ")], + m2: [textPart("m2", "actual content")], + } + + const result = serializeSession(messages, (id) => parts[id] ?? []) + + expect(result).toBe("Assistant:\nactual content") + }) + + test("returns empty string for an empty session", () => { + const result = serializeSession([], () => []) + expect(result).toBe("") + }) + + test("trims trailing whitespace from message text", () => { + const messages: Message[] = [userMsg("m1")] + const parts: Record = { + m1: [textPart("m1", "hello world \n\n")], + } + + const result = serializeSession(messages, (id) => parts[id] ?? []) + + expect(result).toBe("User:\nhello world") + }) + + test("handles a multi-turn conversation", () => { + const messages: Message[] = [userMsg("m1"), assistantMsg("m2"), userMsg("m3"), assistantMsg("m4")] + const parts: Record = { + m1: [textPart("m1", "What is 2+2?")], + m2: [textPart("m2", "4")], + m3: [textPart("m3", "And 3+3?")], + m4: [textPart("m4", "6")], + } + + const result = serializeSession(messages, (id) => parts[id] ?? []) + + expect(result).toBe("User:\nWhat is 2+2?\n\nAssistant:\n4\n\nUser:\nAnd 3+3?\n\nAssistant:\n6") + }) +}) diff --git a/packages/app/src/utils/serialize-session.ts b/packages/app/src/utils/serialize-session.ts new file mode 100644 index 000000000..09911f3e7 --- /dev/null +++ b/packages/app/src/utils/serialize-session.ts @@ -0,0 +1,37 @@ +// Serialize a session's messages and parts into human-readable markdown text. +// Used by the clipboard "copy full session" flow — the data-model path that +// doesn't depend on DOM selection or virtualized rendering. + +import type { Message, Part } from "@opencode-ai/sdk/v2" + +export type PartsAccessor = (messageID: string) => readonly Part[] + +/** + * Serialize a session into markdown-formatted text with role labels. + * + * Format: + * User: + * + * + * Assistant: + * + * + * Only text parts are included — tool calls, reasoning, step markers, and + * other structural parts are omitted for readability. + */ +export function serializeSession(messages: readonly Message[], getParts: PartsAccessor): string { + const blocks: string[] = [] + + for (const msg of messages) { + const textParts = getParts(msg.id).filter((p) => p.type === "text" && !p.ignored && !p.synthetic) + if (textParts.length === 0) continue + + const role = msg.role === "user" ? "User" : "Assistant" + const text = textParts.map((p) => (p as { text: string }).text).join("") + if (!text.trim()) continue + + blocks.push(`${role}:\n${text.trimEnd()}`) + } + + return blocks.join("\n\n") +}