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
1 change: 1 addition & 0 deletions apps/desktop/src/renderer/components/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -588,6 +588,7 @@ function ProjectTabHost() {
smartTooltipsEnabled: s.smartTooltipsEnabled,
onboardingEnabled: s.onboardingEnabled,
didYouKnowEnabled: s.didYouKnowEnabled,
launchPromptClipboardEnabled: s.launchPromptClipboardEnabled,
})));
const storesRef = React.useRef(new Map<string, AppStoreApi>());
const lruRef = React.useRef<string[]>([]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import {
import { BatchLaunchModal, type BatchLaunchSubmit } from "./BatchLaunchModal";
import { BatchLaunchStatusToast } from "./BatchLaunchStatusToast";
import {
defaultKickoffIntro,
defaultKickoffPrompt,
findIssueConflicts,
runBatchLaunch,
type BatchLaunchIssueConfig,
Expand All @@ -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;
Expand Down Expand Up @@ -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<CtoLinearQuickView | null>(null);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
24 changes: 19 additions & 5 deletions apps/desktop/src/renderer/components/app/SettingsPage.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -46,6 +46,11 @@ const TAB_ALIASES: Record<string, SectionId> = {
"ade-usage": "ade-usage",
};

const HASH_TARGET_SECTIONS: Partial<Record<string, SectionId>> = {
"ai-providers": "ai",
"chat-launch-clipboard": "appearance",
};

function padIndex(i: number): string {
return String(i + 1).padStart(2, "0");
}
Expand All @@ -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)
Expand Down Expand Up @@ -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]);
Comment thread
arul28 marked this conversation as resolved.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -813,6 +813,8 @@ export function AgentChatComposer({
onDispatchSteerInterrupt,
onOpenAiSettings,
onOpenLinearSettings,
launchPromptClipboardEnabled = false,
onOpenLaunchPromptClipboardSettings,
onStartOrchestratorChat,
onStopOrchestratorChat,
orchestratorModeActive = false,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1015,6 +1019,8 @@ export function AgentChatComposer({
}) {
const [attachmentPickerOpen, setAttachmentPickerOpen] = useState(false);
const [attachmentQuery, setAttachmentQuery] = useState("");
const [composerFocused, setComposerFocused] = useState(false);
const composerFocusRegionRef = useRef<HTMLDivElement | null>(null);
const [attachmentBusy, setAttachmentBusy] = useState(false);
const [attachmentResults, setAttachmentResults] = useState<AgentChatFileRef[]>([]);
const [attachmentCursor, setAttachmentCursor] = useState(0);
Expand Down Expand Up @@ -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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const resizeTextarea = useCallback(() => {
if (useRichComposer) return;
Expand Down Expand Up @@ -3901,7 +3912,16 @@ export function AgentChatComposer({
</div>
) : null}

<div className="relative">
<div
ref={composerFocusRegionRef}
className="relative"
onFocusCapture={() => 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 ? (
<div
Expand Down Expand Up @@ -4081,6 +4101,18 @@ export function AgentChatComposer({
onPaste={handlePaste}
/>
)}
{showLaunchClipboardHelper ? (
<div className="px-4 pb-2 font-sans text-[10.5px] leading-snug text-muted-fg/55">
Prompt will be copied to clipboard after Send.{" "}
<button
type="button"
className="text-fg/70 underline decoration-white/20 underline-offset-2 transition-colors hover:text-fg"
onClick={onOpenLaunchPromptClipboardSettings}
>
Setting
</button>
</div>
) : null}
</div>
</div>
</ChatComposerShell>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -617,6 +621,7 @@ function installAdeMocks(options?: {
parallelLaunchStateGet,
parallelLaunchStateSet,
handoff,
writeClipboardText,
emitChatEvent: (event: AgentChatEventEnvelope) => {
for (const listener of chatEventListeners) {
listener(event);
Expand All @@ -639,6 +644,7 @@ function resetChatTestStore() {
focusedSessionId: null,
projectTransition: null,
laneInspectorTabs: {},
launchPromptClipboardEnabled: true,
workViewByProject: {},
laneWorkViewByScope: {},
});
Expand Down Expand Up @@ -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<void>(() => {}));
const { send, create } = installAdeMocks({ sessions: [] });
const { send, create, writeClipboardText } = installAdeMocks({ sessions: [] });

render(
<MemoryRouter>
Expand Down Expand Up @@ -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(
<MemoryRouter>
<AgentChatPane
laneId="lane-1"
forceNewSession
/>
</MemoryRouter>,
);

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(
<MemoryRouter>
<AgentChatPane
laneId="lane-1"
forceNewSession
/>
</MemoryRouter>,
);

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 () => {
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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" },
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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.");
Expand Down Expand Up @@ -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<Record<string, unknown>> = [];
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}`,
Expand Down Expand Up @@ -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",
Expand Down
Loading