From 3c10b460704f5a24fbc7ce6ef1c28b1416254ebf Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 1 Jun 2026 19:43:17 -0400 Subject: [PATCH] ADE-92 copy launch prompts to clipboard --- .../src/renderer/components/app/App.tsx | 1 + .../components/app/LinearQuickViewButton.tsx | 14 +++- .../renderer/components/app/SettingsPage.tsx | 24 ++++-- .../chat/AgentChatComposer.test.tsx | 39 +++++++++ .../components/chat/AgentChatComposer.tsx | 34 +++++++- .../components/chat/AgentChatPane.test.tsx | 83 ++++++++++++++++++- .../components/chat/AgentChatPane.tsx | 18 ++++ .../components/settings/AppearanceSection.tsx | 27 ++++++ .../src/renderer/lib/launchPromptClipboard.ts | 21 +++++ .../src/renderer/state/appStore.test.ts | 13 +++ apps/desktop/src/renderer/state/appStore.ts | 15 ++++ 11 files changed, 278 insertions(+), 11 deletions(-) create mode 100644 apps/desktop/src/renderer/lib/launchPromptClipboard.ts diff --git a/apps/desktop/src/renderer/components/app/App.tsx b/apps/desktop/src/renderer/components/app/App.tsx index da287facf..45482ca2c 100644 --- a/apps/desktop/src/renderer/components/app/App.tsx +++ b/apps/desktop/src/renderer/components/app/App.tsx @@ -588,6 +588,7 @@ function ProjectTabHost() { smartTooltipsEnabled: s.smartTooltipsEnabled, onboardingEnabled: s.onboardingEnabled, didYouKnowEnabled: s.didYouKnowEnabled, + launchPromptClipboardEnabled: s.launchPromptClipboardEnabled, }))); const storesRef = React.useRef(new Map()); const lruRef = React.useRef([]); diff --git a/apps/desktop/src/renderer/components/app/LinearQuickViewButton.tsx b/apps/desktop/src/renderer/components/app/LinearQuickViewButton.tsx index d8c3129ca..1624b3346 100644 --- a/apps/desktop/src/renderer/components/app/LinearQuickViewButton.tsx +++ b/apps/desktop/src/renderer/components/app/LinearQuickViewButton.tsx @@ -25,6 +25,8 @@ import { import { BatchLaunchModal, type BatchLaunchSubmit } from "./BatchLaunchModal"; import { BatchLaunchStatusToast } from "./BatchLaunchStatusToast"; import { + defaultKickoffIntro, + defaultKickoffPrompt, findIssueConflicts, runBatchLaunch, type BatchLaunchIssueConfig, @@ -35,6 +37,7 @@ import { rememberCreatingIssues, rememberLaunchedLanes, } from "../../lib/launchedLanesHighlight"; +import { copyLaunchPromptToClipboard } from "../../lib/launchPromptClipboard"; const INITIAL_VISIBILITY_CHECK_DELAY_MS = 2_000; const VISIBILITY_RETRY_INTERVAL_MS = 3_000; @@ -105,6 +108,7 @@ export function LinearQuickViewButton({ const lanes = useAppStore((s) => s.lanes); const refreshLanes = useAppStore((s) => s.refreshLanes); const selectLane = useAppStore((s) => s.selectLane); + const launchPromptClipboardEnabled = useAppStore((s) => s.launchPromptClipboardEnabled); const [visible, setVisible] = useState(false); const [open, setOpen] = useState(false); const [quickView, setQuickView] = useState(null); @@ -305,6 +309,14 @@ export function LinearQuickViewButton({ const launchBatch = useCallback(async (entries: BatchLaunchSubmit[]) => { if (!entries.length) return; + if (launchPromptClipboardEnabled) { + const lastLaunchEntry = [...entries].reverse().find(({ config }) => !config.laneOnly); + const lastPrompt = lastLaunchEntry + ? lastLaunchEntry.config.kickoffPrompt.trim() + || (lastLaunchEntry.config.sessionType === "cli" ? defaultKickoffIntro() : defaultKickoffPrompt()) + : ""; + void copyLaunchPromptToClipboard(lastPrompt); + } // Record optimistic "creating lane" placeholders for issues that mint a NEW // lane (existing-lane targets already have a lane), keyed by issue id. The // Lanes tab renders these as spinner tabs immediately on reroute and clears @@ -386,7 +398,7 @@ export function LinearQuickViewButton({ sessionIds: result.createdSessionIds, }); } - }, [refreshLanes]); + }, [launchPromptClipboardEnabled, refreshLanes]); const handleBatchLaunch = useCallback((entries: BatchLaunchSubmit[]) => { // Close + reroute synchronously; the orchestrator runs detached so the diff --git a/apps/desktop/src/renderer/components/app/SettingsPage.tsx b/apps/desktop/src/renderer/components/app/SettingsPage.tsx index 39ce4d268..74d7148e7 100644 --- a/apps/desktop/src/renderer/components/app/SettingsPage.tsx +++ b/apps/desktop/src/renderer/components/app/SettingsPage.tsx @@ -1,5 +1,5 @@ import React, { useState, useCallback, useEffect } from "react"; -import { useSearchParams, useLocation } from "react-router-dom"; +import { useSearchParams, useLocation, useNavigate } from "react-router-dom"; import { Brain, ChartLineUp, GearSix, Stack, Plugs, Palette, DeviceMobile, Robot } from "@phosphor-icons/react"; import { GeneralSection } from "../settings/GeneralSection"; import { AppearanceSection } from "../settings/AppearanceSection"; @@ -46,6 +46,11 @@ const TAB_ALIASES: Record = { "ade-usage": "ade-usage", }; +const HASH_TARGET_SECTIONS: Partial> = { + "ai-providers": "ai", + "chat-launch-clipboard": "appearance", +}; + function padIndex(i: number): string { return String(i + 1).padStart(2, "0"); } @@ -54,6 +59,7 @@ function padIndex(i: number): string { export function SettingsPage({ active = true }: { active?: boolean } = {}) { const location = useLocation(); + const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); const tabParam = searchParams.get("tab"); const canonicalTab = tabParam && SECTIONS.some((s) => s.id === tabParam) @@ -85,14 +91,22 @@ export function SettingsPage({ active = true }: { active?: boolean } = {}) { setSection(next); const nextParams = new URLSearchParams(searchParams); nextParams.set("tab", next); - setSearchParams(nextParams, { replace: true }); - }, [searchParams, setSearchParams]); + navigate({ pathname: location.pathname, search: `?${nextParams.toString()}`, hash: "" }, { replace: true }); + }, [location.pathname, navigate, searchParams]); useEffect(() => { if (!active) return; - if (section !== "ai" || location.hash !== "#ai-providers") return; + if (!location.hash) return; + let targetId = location.hash.slice(1); + try { + targetId = decodeURIComponent(targetId); + } catch { + // A malformed hash should not break the settings page. + } + if (!targetId) return; + if (HASH_TARGET_SECTIONS[targetId] !== section) return; const id = window.requestAnimationFrame(() => { - document.getElementById("ai-providers")?.scrollIntoView({ block: "start", behavior: "smooth" }); + document.getElementById(targetId)?.scrollIntoView({ block: "start", behavior: "smooth" }); }); return () => window.cancelAnimationFrame(id); }, [active, section, location.hash]); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx index 865db5a56..7c317bce0 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx @@ -1367,6 +1367,45 @@ describe("AgentChatComposer", () => { })).toBeTruthy(); }); + it("shows the launch clipboard helper only while typing in the prompt", () => { + const onOpenLaunchPromptClipboardSettings = vi.fn(); + renderComposer({ + draft: "Recoverable launch prompt", + turnActive: false, + launchPromptClipboardEnabled: true, + onOpenLaunchPromptClipboardSettings, + }); + + expect(screen.queryByText(/Prompt will be copied to clipboard after Send\./)).toBeNull(); + + const textarea = screen.getByRole("textbox"); + fireEvent.focus(textarea); + + expect(screen.getByText(/Prompt will be copied to clipboard after Send\./)).toBeTruthy(); + const settingButton = screen.getByRole("button", { name: "Setting" }); + fireEvent.blur(textarea, { relatedTarget: settingButton }); + fireEvent.focus(settingButton); + expect(screen.getByText(/Prompt will be copied to clipboard after Send\./)).toBeTruthy(); + + fireEvent.click(settingButton); + expect(onOpenLaunchPromptClipboardSettings).toHaveBeenCalledTimes(1); + + fireEvent.blur(settingButton); + expect(screen.queryByText(/Prompt will be copied to clipboard after Send\./)).toBeNull(); + }); + + it("hides the launch clipboard helper when the setting is disabled", () => { + renderComposer({ + draft: "No helper", + turnActive: false, + launchPromptClipboardEnabled: false, + }); + + fireEvent.focus(screen.getByRole("textbox")); + + expect(screen.queryByText(/Prompt will be copied to clipboard after Send\./)).toBeNull(); + }); + it("focuses the grid composer when the tile becomes active", () => { const props = buildComposerProps({ layoutVariant: "grid-tile", diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index cc711e824..ed05ad907 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -813,6 +813,8 @@ export function AgentChatComposer({ onDispatchSteerInterrupt, onOpenAiSettings, onOpenLinearSettings, + launchPromptClipboardEnabled = false, + onOpenLaunchPromptClipboardSettings, onStartOrchestratorChat, onStopOrchestratorChat, orchestratorModeActive = false, @@ -962,6 +964,8 @@ export function AgentChatComposer({ onDispatchSteerInterrupt?: (steerId: string) => void; onOpenAiSettings?: () => void; onOpenLinearSettings?: () => void; + launchPromptClipboardEnabled?: boolean; + onOpenLaunchPromptClipboardSettings?: () => void; /** * Open the "New orchestrator chat" flow from the visible composer mode * button (see `goal.md` ยง10.1). Hosts that don't want the entry simply @@ -1015,6 +1019,8 @@ export function AgentChatComposer({ }) { const [attachmentPickerOpen, setAttachmentPickerOpen] = useState(false); const [attachmentQuery, setAttachmentQuery] = useState(""); + const [composerFocused, setComposerFocused] = useState(false); + const composerFocusRegionRef = useRef(null); const [attachmentBusy, setAttachmentBusy] = useState(false); const [attachmentResults, setAttachmentResults] = useState([]); const [attachmentCursor, setAttachmentCursor] = useState(0); @@ -1091,6 +1097,11 @@ export function AgentChatComposer({ const canAttachIssueContext = !composerInputLocked && typeof onAddContextAttachment === "function"; const showOrchestratorModeButton = Boolean(onStartOrchestratorChat && !sessionId && !parallelChatMode); const orchestratorModeButtonDisabled = composerInputLocked || busy || turnActive; + const showLaunchClipboardHelper = + launchPromptClipboardEnabled + && composerFocused + && !composerInputLocked + && draft.trim().length > 0; const resizeTextarea = useCallback(() => { if (useRichComposer) return; @@ -3901,7 +3912,16 @@ export function AgentChatComposer({ ) : null} -
+
setComposerFocused(true)} + onBlurCapture={(event) => { + const nextTarget = event.relatedTarget as Node | null; + if (nextTarget && composerFocusRegionRef.current?.contains(nextTarget)) return; + setComposerFocused(false); + }} + > {/* Ghost suggestion overlay */} {promptSuggestion && !draft.length && !turnActive ? (
)} + {showLaunchClipboardHelper ? ( +
+ Prompt will be copied to clipboard after Send.{" "} + +
+ ) : null}
diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx index 8917c304c..b1f77757a 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx @@ -464,10 +464,14 @@ function installAdeMocks(options?: { const archive = vi.fn().mockResolvedValue(undefined); const unarchive = vi.fn().mockResolvedValue(undefined); const deleteLane = vi.fn().mockResolvedValue(undefined); + const writeClipboardText = vi.fn().mockResolvedValue(undefined); const chatEventListeners = new Set<(event: AgentChatEventEnvelope) => void>(); const sessionChangeListeners = new Set<(event: TerminalSessionChangedEvent) => void>(); globalThis.window.ade = { + app: { + writeClipboardText, + }, projectConfig: { get: vi.fn().mockResolvedValue({ effective: { @@ -617,6 +621,7 @@ function installAdeMocks(options?: { parallelLaunchStateGet, parallelLaunchStateSet, handoff, + writeClipboardText, emitChatEvent: (event: AgentChatEventEnvelope) => { for (const listener of chatEventListeners) { listener(event); @@ -639,6 +644,7 @@ function resetChatTestStore() { focusedSessionId: null, projectTransition: null, laneInspectorTabs: {}, + launchPromptClipboardEnabled: true, workViewByProject: {}, laneWorkViewByScope: {}, }); @@ -2951,7 +2957,7 @@ describe("AgentChatPane submit recovery", () => { it("does not wait for onSessionCreated before sending the first message in a new chat", async () => { const onSessionCreated = vi.fn().mockImplementation(() => new Promise(() => {})); - const { send, create } = installAdeMocks({ sessions: [] }); + const { send, create, writeClipboardText } = installAdeMocks({ sessions: [] }); render( @@ -2983,7 +2989,72 @@ describe("AgentChatPane submit recovery", () => { text: "Ship the instant route fix.", displayText: "Ship the instant route fix.", })); + expect(writeClipboardText).toHaveBeenCalledWith("Ship the instant route fix."); + }); + }); + + it("copies a new chat prompt before session creation failures can lose it", async () => { + const { writeClipboardText } = installAdeMocks({ + sessions: [], + createError: new Error("create exploded"), + }); + + render( + + + , + ); + + const trigger = await screen.findByRole("button", { name: /^Select model/ }); + const codexLabel = getModelById("openai/gpt-5.4")?.displayName ?? "GPT-5.4"; + fireEvent.pointerDown(trigger, { button: 0 }); + fireEvent.click(trigger); + fireEvent.click(await screen.findByRole("tab", { name: /^OpenAI$/i })); + await clickEnabledModelOption(new RegExp(escapeRegExp(codexLabel), "i")); + + const textbox = await screen.findByRole("textbox"); + fireEvent.change(textbox, { target: { value: "Recover this prompt if launch fails." } }); + fireEvent.click(await screen.findByRole("button", { name: "Send" })); + + await waitFor(() => { + expect(writeClipboardText).toHaveBeenCalledWith("Recover this prompt if launch fails."); + expect(screen.getByText("create exploded")).toBeTruthy(); + }); + }); + + it("does not copy submitted prompts when the launch clipboard setting is disabled", async () => { + useAppStore.setState({ launchPromptClipboardEnabled: false }); + const { send, writeClipboardText } = installAdeMocks({ sessions: [] }); + + render( + + + , + ); + + const trigger = await screen.findByRole("button", { name: /^Select model/ }); + const codexLabel = getModelById("openai/gpt-5.4")?.displayName ?? "GPT-5.4"; + fireEvent.pointerDown(trigger, { button: 0 }); + fireEvent.click(trigger); + fireEvent.click(await screen.findByRole("tab", { name: /^OpenAI$/i })); + await clickEnabledModelOption(new RegExp(escapeRegExp(codexLabel), "i")); + + const textbox = await screen.findByRole("textbox"); + fireEvent.change(textbox, { target: { value: "Do not copy this prompt." } }); + fireEvent.click(await screen.findByRole("button", { name: "Send" })); + + await waitFor(() => { + expect(send).toHaveBeenCalledWith(expect.objectContaining({ + text: "Do not copy this prompt.", + })); }); + expect(writeClipboardText).not.toHaveBeenCalled(); }); it("logs synchronous session-created callback failures without blocking the first send", async () => { @@ -3033,7 +3104,7 @@ describe("AgentChatPane submit recovery", () => { it("foreground auto-create opens the new chat in Work instead of routing to Lanes", async () => { const onSessionCreated = vi.fn(); - const { send, create, createLane, suggestLaneName } = installAdeMocks({ sessions: [] }); + const { send, create, createLane, suggestLaneName, writeClipboardText } = installAdeMocks({ sessions: [] }); suggestLaneName.mockResolvedValue("fix-auto-create-flow"); createLane.mockResolvedValue({ id: "lane-created", @@ -3076,6 +3147,7 @@ describe("AgentChatPane submit recovery", () => { sessionId: "created-session", text: "Fix auto create lane routing.", })); + expect(writeClipboardText).toHaveBeenCalledWith("Fix auto create lane routing."); expect(onSessionCreated).toHaveBeenCalledWith( expect.objectContaining({ id: "created-session", laneId: "lane-created" }), { activate: false, source: "draft-launch" }, @@ -4047,7 +4119,7 @@ describe("AgentChatPane submit recovery", () => { }); it("auto-creates a lane for a foreground CLI session draft", async () => { - const { send, create, createLane, suggestLaneName } = installAdeMocks({ sessions: [] }); + const { send, create, createLane, suggestLaneName, writeClipboardText } = installAdeMocks({ sessions: [] }); const onLaunchCliSession = vi.fn().mockResolvedValue({ sessionId: "terminal-created", ptyId: "pty-created" }); suggestLaneName.mockResolvedValue("cli-auto-lane"); createLane.mockResolvedValue({ @@ -4093,6 +4165,7 @@ describe("AgentChatPane submit recovery", () => { tracked: true, disposition: "foreground", })); + expect(writeClipboardText).toHaveBeenCalledWith("Launch a CLI agent on a new lane."); }); const launchArgs = onLaunchCliSession.mock.calls[0]?.[0]; expect(launchArgs.startupCommand).not.toContain("Launch a CLI agent on a new lane."); @@ -4639,7 +4712,7 @@ describe("AgentChatPane submit recovery", () => { it("launches one child lane per parallel model and opens work-focus tiling", async () => { const createdLanes: Array> = []; - const { send, suggestLaneName, parallelLaunchStateSet } = installAdeMocks({ sessions: [], includeClaudeModel: true }); + const { send, suggestLaneName, parallelLaunchStateSet, writeClipboardText } = installAdeMocks({ sessions: [], includeClaudeModel: true }); const createChild = vi.fn().mockImplementation(async ({ name, parentLaneId }: { name: string; parentLaneId: string }) => { const lane = { id: `lane-child-${createdLanes.length + 1}`, @@ -4721,6 +4794,8 @@ describe("AgentChatPane submit recovery", () => { expect(create).toHaveBeenCalledTimes(2); expect(send).toHaveBeenCalledTimes(2); }); + expect(writeClipboardText).toHaveBeenCalledTimes(1); + expect(writeClipboardText).toHaveBeenCalledWith("Fix the login bug"); expect(create).toHaveBeenNthCalledWith(1, expect.objectContaining({ laneId: "lane-child-1", provider: "codex", diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 3d082e2f4..13415de3d 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -123,6 +123,7 @@ import { ConfirmDialog, useConfirmDialog } from "../shared/InlineDialogs"; import { ChatActionsDrawerPanel, type ChatActionsTab } from "./ChatActionsDrawerPanel"; import { useAppStore } from "../../state/appStore"; import { buildChatAppearanceRootStyle } from "./chatAppearance"; +import { copyLaunchPromptToClipboard } from "../../lib/launchPromptClipboard"; import { LaneAccentDot } from "../lanes/LaneAccentDot"; import { LaneCombobox, AUTO_CREATE_LANE_OPTION_ID } from "../terminals/LaneCombobox"; import { @@ -2311,6 +2312,7 @@ export function AgentChatPane({ const chatTranscriptDensity = useAppStore((s) => s.chatTranscriptDensity); const chatChromeTint = useAppStore((s) => s.chatChromeTint); const chatShellGeometry = useAppStore((s) => s.chatShellGeometry); + const launchPromptClipboardEnabled = useAppStore((s) => s.launchPromptClipboardEnabled); const chatAppearanceRootStyle = useMemo( () => buildChatAppearanceRootStyle({ chatFontSizePx, transcriptDensity: chatTranscriptDensity }), [chatFontSizePx, chatTranscriptDensity], @@ -2323,6 +2325,13 @@ export function AgentChatPane({ const openLinearSettings = useCallback(() => { navigate("/settings?tab=integrations&integration=linear"); }, [navigate]); + const openLaunchPromptClipboardSettings = useCallback(() => { + navigate("/settings?tab=appearance#chat-launch-clipboard"); + }, [navigate]); + const copyPromptForLaunch = useCallback(async (promptText: string) => { + if (!launchPromptClipboardEnabled) return; + await copyLaunchPromptToClipboard(promptText); + }, [launchPromptClipboardEnabled]); const setWorkViewState = useAppStore((s) => s.setWorkViewState); const setLaneWorkViewState = useAppStore((s) => s.setLaneWorkViewState); const refreshLanesStore = useAppStore((s) => s.refreshLanes); @@ -5968,6 +5977,7 @@ export function AgentChatPane({ return; } draftLaunchInFlightKeysRef.current.add(requestKey); + void copyPromptForLaunch(snapshot.text); const jobId = createDraftLaunchJobId(); if (mode === "foreground") { @@ -6073,6 +6083,7 @@ export function AgentChatPane({ parallelLaunchBusy, prepareDraftLaunchForSend, projectTransitionBlocksChat, + copyPromptForLaunch, refreshLanesStore, refreshSessions, resolveDraftLaunchLane, @@ -6282,6 +6293,7 @@ export function AgentChatPane({ return; } setPromptSuggestion(null); + void copyPromptForLaunch(draftText); const resolved = await handleApproval(planReadyGate.itemId, "decline", draftText); if (resolved) setDraft(""); return; @@ -6334,6 +6346,7 @@ export function AgentChatPane({ setError(`Parallel launch allows at most ${PARALLEL_CHAT_MAX_ATTACHMENTS} attachments. Remove some files or send in batches.`); return; } + void copyPromptForLaunch(text); const draftSnapshot = draft; const attachmentsSnapshot = [...attachments]; @@ -6585,6 +6598,7 @@ export function AgentChatPane({ return; } } + void copyPromptForLaunch(text); if ( text === "/context" && selectedSessionId @@ -6873,6 +6887,7 @@ export function AgentChatPane({ busy, codexFastMode, constrainedModelSelectionError, + copyPromptForLaunch, createSession, currentNativeControls, contextAttachments, @@ -8060,6 +8075,8 @@ export function AgentChatPane({ onRemoveMacosVmContext={removeMacosVmContext} onOpenAiSettings={openAiProvidersSettings} onOpenLinearSettings={openLinearSettings} + launchPromptClipboardEnabled={launchPromptClipboardEnabled} + onOpenLaunchPromptClipboardSettings={openLaunchPromptClipboardSettings} onStartOrchestratorChat={() => { // Switch the lane to a fresh orchestrator-lead draft. The // submit path will then call `agentChat.create` + @@ -8296,6 +8313,7 @@ export function AgentChatPane({ setCursorCloudPaneOpen(true); }} onSubmitToCloud={async (promptText) => { + void copyPromptForLaunch(promptText); if (cursorCloudLaunchModeOpen) { const result = await cursorCloudInlineLaunchRef.current?.launchWithPrompt(promptText); return Boolean(result); diff --git a/apps/desktop/src/renderer/components/settings/AppearanceSection.tsx b/apps/desktop/src/renderer/components/settings/AppearanceSection.tsx index 926e9cbb8..78e2b57d5 100644 --- a/apps/desktop/src/renderer/components/settings/AppearanceSection.tsx +++ b/apps/desktop/src/renderer/components/settings/AppearanceSection.tsx @@ -193,6 +193,7 @@ export function AppearanceSection() { const agentSoundSelectId = useId(); const volumeSliderId = useId(); const quietToggleId = useId(); + const launchPromptClipboardToggleId = useId(); const terminalFieldId = useId(); const theme = useAppStore((s) => s.theme); @@ -209,6 +210,8 @@ export function AppearanceSection() { const setChatShellGeometry = useAppStore((s) => s.setChatShellGeometry); const chatUserMinimapEnabled = useAppStore((s) => s.chatUserMinimapEnabled); const setChatUserMinimapEnabled = useAppStore((s) => s.setChatUserMinimapEnabled); + const launchPromptClipboardEnabled = useAppStore((s) => s.launchPromptClipboardEnabled); + const setLaunchPromptClipboardEnabled = useAppStore((s) => s.setLaunchPromptClipboardEnabled); const codeBlockCopyButtonPosition = useAppStore((s) => s.codeBlockCopyButtonPosition); const setCodeBlockCopyButtonPosition = useAppStore((s) => s.setCodeBlockCopyButtonPosition); @@ -431,6 +434,30 @@ export function AppearanceSection() {
+
+ +
+
User message minimap
diff --git a/apps/desktop/src/renderer/lib/launchPromptClipboard.ts b/apps/desktop/src/renderer/lib/launchPromptClipboard.ts new file mode 100644 index 000000000..f6772d0aa --- /dev/null +++ b/apps/desktop/src/renderer/lib/launchPromptClipboard.ts @@ -0,0 +1,21 @@ +export async function copyLaunchPromptToClipboard(promptText: string): Promise { + const text = promptText.trim(); + if (!text) return; + + try { + if (typeof window !== "undefined" && window.ade?.app?.writeClipboardText) { + await window.ade.app.writeClipboardText(text); + return; + } + } catch { + // Fall back to the browser clipboard API below. + } + + try { + if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + } + } catch { + // Clipboard recovery is best-effort; never block the launch. + } +} diff --git a/apps/desktop/src/renderer/state/appStore.test.ts b/apps/desktop/src/renderer/state/appStore.test.ts index f3a0f6569..4c0426517 100644 --- a/apps/desktop/src/renderer/state/appStore.test.ts +++ b/apps/desktop/src/renderer/state/appStore.test.ts @@ -79,6 +79,7 @@ function resetStore() { smartTooltipsEnabled: true, onboardingEnabled: true, didYouKnowEnabled: true, + launchPromptClipboardEnabled: true, laneInspectorTabs: {}, workViewByProject: {}, laneWorkViewByScope: {}, @@ -237,6 +238,18 @@ describe("appStore", () => { expect(useAppStore.getState().chatUserMinimapEnabled).toBe(true); }); + it("persists the launch prompt clipboard toggle", () => { + expect(useAppStore.getState().launchPromptClipboardEnabled).toBe(true); + useAppStore.getState().setLaunchPromptClipboardEnabled(false); + expect(useAppStore.getState().launchPromptClipboardEnabled).toBe(false); + const calls = mockLocalStorage.setItem.mock.calls.filter( + ([key]) => key === "ade.userPreferences.v1", + ); + const latest = calls[calls.length - 1]; + expect(latest).toBeTruthy(); + expect(JSON.parse(latest![1])).toMatchObject({ launchPromptClipboardEnabled: false }); + }); + it("persists transcript density and shell geometry prefs", () => { useAppStore.getState().setChatTranscriptDensity("compact"); useAppStore.getState().setChatShellGeometry("sharp"); diff --git a/apps/desktop/src/renderer/state/appStore.ts b/apps/desktop/src/renderer/state/appStore.ts index 6e1556024..59e5c8ad5 100644 --- a/apps/desktop/src/renderer/state/appStore.ts +++ b/apps/desktop/src/renderer/state/appStore.ts @@ -430,6 +430,7 @@ type PersistedUserPreferences = { smartTooltipsEnabled: boolean; onboardingEnabled: boolean; didYouKnowEnabled: boolean; + launchPromptClipboardEnabled: boolean; codeBlockCopyButtonPosition: CodeBlockCopyButtonPosition; agentTurnCompletionSound: AgentTurnCompletionSound; agentTurnCompletionSoundVolume: number; @@ -459,6 +460,7 @@ function readUnifiedUserPreferences(): PersistedUserPreferences | null { smartTooltipsEnabled: parsed.smartTooltipsEnabled !== false, onboardingEnabled: parsed.onboardingEnabled !== false, didYouKnowEnabled: parsed.didYouKnowEnabled !== false, + launchPromptClipboardEnabled: parsed.launchPromptClipboardEnabled !== false, codeBlockCopyButtonPosition: normalizeCodeBlockCopyButtonPosition(parsed.codeBlockCopyButtonPosition), agentTurnCompletionSound: normalizeAgentTurnCompletionSound(parsed.agentTurnCompletionSound), agentTurnCompletionSoundVolume: normalizeAgentTurnCompletionSoundVolume(parsed.agentTurnCompletionSoundVolume), @@ -500,6 +502,7 @@ function readLegacyUserPreferences(): PersistedUserPreferences { smartTooltipsEnabled, onboardingEnabled: true, didYouKnowEnabled: true, + launchPromptClipboardEnabled: true, codeBlockCopyButtonPosition: "top", agentTurnCompletionSound: "off", agentTurnCompletionSoundVolume: DEFAULT_AGENT_TURN_COMPLETION_SOUND_VOLUME, @@ -527,6 +530,7 @@ function persistUserPreferencesFrom(state: { smartTooltipsEnabled: boolean; onboardingEnabled: boolean; didYouKnowEnabled: boolean; + launchPromptClipboardEnabled: boolean; codeBlockCopyButtonPosition: CodeBlockCopyButtonPosition; agentTurnCompletionSound: AgentTurnCompletionSound; agentTurnCompletionSoundVolume: number; @@ -543,6 +547,7 @@ function persistUserPreferencesFrom(state: { smartTooltipsEnabled: state.smartTooltipsEnabled, onboardingEnabled: state.onboardingEnabled, didYouKnowEnabled: state.didYouKnowEnabled, + launchPromptClipboardEnabled: state.launchPromptClipboardEnabled, codeBlockCopyButtonPosition: state.codeBlockCopyButtonPosition, agentTurnCompletionSound: state.agentTurnCompletionSound, agentTurnCompletionSoundVolume: state.agentTurnCompletionSoundVolume, @@ -649,6 +654,7 @@ export type AppState = { smartTooltipsEnabled: boolean; onboardingEnabled: boolean; didYouKnowEnabled: boolean; + launchPromptClipboardEnabled: boolean; workViewByProject: Record; laneWorkViewByScope: Record; /** @@ -721,6 +727,7 @@ export type AppState = { setSmartTooltipsEnabled: (enabled: boolean) => void; setOnboardingEnabled: (enabled: boolean) => void; setDidYouKnowEnabled: (enabled: boolean) => void; + setLaunchPromptClipboardEnabled: (enabled: boolean) => void; getWorkViewState: (projectRoot: string | null | undefined) => WorkProjectViewState; setWorkViewState: ( projectRoot: string | null | undefined, @@ -890,6 +897,7 @@ const createAppState: StateCreator = (set, get) => { smartTooltipsEnabled: initialUserPreferences.smartTooltipsEnabled, onboardingEnabled: initialUserPreferences.onboardingEnabled, didYouKnowEnabled: initialUserPreferences.didYouKnowEnabled, + launchPromptClipboardEnabled: initialUserPreferences.launchPromptClipboardEnabled, workViewByProject: initialPersistedWorkViews.workViewByProject, laneWorkViewByScope: initialPersistedWorkViews.laneWorkViewByScope, laneSelectionByProject: {}, @@ -1114,6 +1122,11 @@ const createAppState: StateCreator = (set, get) => { persistUserPreferencesFrom({ ...prev, didYouKnowEnabled: enabled }); return { didYouKnowEnabled: enabled }; }), + setLaunchPromptClipboardEnabled: (enabled) => + set((prev) => { + persistUserPreferencesFrom({ ...prev, launchPromptClipboardEnabled: enabled }); + return { launchPromptClipboardEnabled: enabled }; + }), openNewTab: () => set({ isNewTabOpen: true, showWelcome: true }), cancelNewTab: () => { const hasProject = get().project != null; @@ -1743,6 +1756,7 @@ export function createProjectAppStore(project: ProjectInfo): AppStoreApi { smartTooltipsEnabled: rootState.smartTooltipsEnabled, onboardingEnabled: rootState.onboardingEnabled, didYouKnowEnabled: rootState.didYouKnowEnabled, + launchPromptClipboardEnabled: rootState.launchPromptClipboardEnabled, setTheme: rootState.setTheme, setTerminalPreferences: rootState.setTerminalPreferences, setCodeBlockCopyButtonPosition: rootState.setCodeBlockCopyButtonPosition, @@ -1758,6 +1772,7 @@ export function createProjectAppStore(project: ProjectInfo): AppStoreApi { setSmartTooltipsEnabled: rootState.setSmartTooltipsEnabled, setOnboardingEnabled: rootState.setOnboardingEnabled, setDidYouKnowEnabled: rootState.setDidYouKnowEnabled, + setLaunchPromptClipboardEnabled: rootState.setLaunchPromptClipboardEnabled, }); return store; }