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
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
type ProjectRepairReport,
type RepairStepResult,
} from "../../../shared/types/recovery";
import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
import { useAppStore } from "../../state/appStore";

/**
Expand Down Expand Up @@ -149,7 +150,7 @@ export function ProjectRecoveryScreen() {
const [report, setReport] = useState<ProjectRepairReport | null>(null);
const [revealed, setRevealed] = useState(0);
const [repairError, setRepairError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const { copy, copied } = useCopyToClipboard();
const reopenStartedRef = useRef(false);

// Diagnose on mount / when the failed root changes. On failure fall back to
Expand Down Expand Up @@ -249,16 +250,6 @@ export function ProjectRecoveryScreen() {
.filter((line): line is string => Boolean(line && line.trim()))
.join("\n");

const copyTechnical = () => {
void navigator.clipboard?.writeText(technicalText).then(
() => {
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
},
() => {},
);
};

const visibleSteps = report ? report.steps.slice(0, phase === "repairing" ? revealed : undefined) : [];

const isSuccess = phase === "success";
Expand Down Expand Up @@ -372,7 +363,7 @@ export function ProjectRecoveryScreen() {
type="button"
onClick={(e) => {
e.preventDefault();
copyTechnical();
void copy(technicalText);
}}
className="inline-flex items-center gap-1 text-[11px] text-fg/45 transition-colors hover:text-fg/75"
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
X,
} from "@phosphor-icons/react";
import { useClickOutside } from "../../hooks/useClickOutside";
import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
import { cn } from "../ui/cn";
import { textareaCls } from "./designTokens";
import { INPUT_CLS, INPUT_STYLE } from "./shared";
Expand Down Expand Up @@ -537,16 +538,9 @@ function ParamField({
}

function PlaceholderRow() {
const [copied, setCopied] = useState<string | null>(null);
const copy = async (placeholder: string) => {
try {
await navigator.clipboard.writeText(placeholder);
setCopied(placeholder);
window.setTimeout(() => setCopied((current) => (current === placeholder ? null : current)), 1200);
} catch {
// Clipboard may be blocked (e.g., insecure context) — silently no-op.
}
};
// Keyed: only the chip that was clicked shows the confirmation. Clipboard may
// be blocked (e.g., insecure context) — the hook then leaves the chip idle.
const { copy, isCopied } = useCopyToClipboard({ timeout: 1200 });
return (
<details className="rounded-md border border-white/[0.08] bg-white/[0.03] px-2.5 py-1.5 text-[10px]">
<summary className="cursor-pointer text-[10px] uppercase tracking-[1px] text-muted-fg/70 hover:text-fg/85">
Expand All @@ -559,14 +553,14 @@ function PlaceholderRow() {
type="button"
className={cn(
"rounded border px-1.5 py-0.5 text-[10px] transition-colors",
copied === placeholder.value
isCopied(placeholder.value)
? "border-emerald-400/40 bg-emerald-500/10 text-emerald-200"
: "border-white/[0.08] bg-white/[0.03] text-muted-fg/70 hover:border-accent/40 hover:text-fg",
)}
onClick={() => void copy(placeholder.value)}
onClick={() => void copy(placeholder.value, placeholder.value)}
title={placeholder.value}
>
{copied === placeholder.value ? "✓ copied" : placeholder.label}
{isCopied(placeholder.value) ? "✓ copied" : placeholder.label}
</button>
))}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Button } from "../../ui/Button";
import { cn } from "../../ui/cn";
import { formatDate } from "../../../lib/format";
import { linearIngressApi } from "../linearIngressApi";
import { useAsyncAction } from "../../../hooks/useAsyncAction";

function Dot({ tone }: { tone: "ok" | "warn" | "off" }) {
return (
Expand All @@ -32,7 +33,6 @@ function githubSummary(status: AutomationIngressStatus | null): { tone: "ok" | "
export function IngressStatusStrip({ ingressStatus }: { ingressStatus: AutomationIngressStatus | null }) {
const [dismissed, setDismissed] = useState(false);
const [linear, setLinear] = useState<AutomationLinearIngressStatus | null>(null);
const [busy, setBusy] = useState(false);
const api = linearIngressApi();

const refreshLinear = useCallback(async () => {
Expand All @@ -48,24 +48,23 @@ export function IngressStatusStrip({ ingressStatus }: { ingressStatus: Automatio
void refreshLinear();
}, [refreshLinear]);

const { run: setupLinear, pending: busy } = useAsyncAction({
action: async () => {
if (!api?.setup) return;
try {
await api.setup();
} finally {
// The service records lastError; the refresh surfaces it either way.
await refreshLinear().catch(() => {});
}
},
});

if (dismissed) return null;

const gh = githubSummary(ingressStatus);
const linearAvailable = Boolean(api?.getStatus) && linear != null && linear.state !== "disabled";

const setupLinear = async () => {
if (!api?.setup) return;
setBusy(true);
try {
await api.setup();
} catch {
// The service records lastError; the refresh below surfaces it.
} finally {
await refreshLinear().catch(() => {});
setBusy(false);
}
};

return (
<div className="flex items-center gap-3 border-b border-white/[0.06] bg-white/[0.02] px-4 py-2 text-[11px]">
<div className="flex items-center gap-1.5" title={`GitHub events: ${gh.label}`}>
Expand Down Expand Up @@ -99,7 +98,7 @@ export function IngressStatusStrip({ ingressStatus }: { ingressStatus: Automatio
// a linear.* rule is enabled — no manual connect step.
<span className="text-muted-fg/70">Via ADE app</span>
) : (
<Button size="sm" variant="outline" disabled={busy} onClick={() => void setupLinear()}>
<Button size="sm" variant="outline" disabled={busy} onClick={setupLinear}>
Connect
</Button>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1215,8 +1215,37 @@ describe("AgentChatMessageList transcript rendering", () => {

fireEvent.click(screen.getByRole("button", { name: "Copy message" }));

// The invariant is that the hidden prompt never reaches the clipboard. The
// shared copy hook no-ops on empty text rather than writing "", so the
// clipboard is left untouched instead of being wiped. Asserting "not called
// at all" is the exact new contract and is not vacuous: the sibling test
// below proves the same button does reach `writeText` for a visible message.
// The early return happens before any await, so no settling wait is needed.
expect(writeText).not.toHaveBeenCalled();
});

it("copies the visible message text when it is not hidden", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText },
});

renderMessageList([
{
sessionId: "session-1",
timestamp: "2026-03-17T10:00:00.000Z",
event: {
type: "user_message",
text: "A perfectly ordinary message.",
},
},
]);

fireEvent.click(screen.getByRole("button", { name: "Copy message" }));

await waitFor(() => {
expect(writeText).toHaveBeenCalledWith("");
expect(writeText).toHaveBeenCalledWith("A perfectly ordinary message.");
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import type {
} from "../../../shared/types";
import { getModelById, resolveModelDescriptor, type ModelDescriptor } from "../../../shared/modelRegistry";
import { cn } from "../ui/cn";
import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
import { formatTime } from "../../lib/format";
import { navigateToAppTarget, openUrlInAdeBrowser } from "../../lib/openExternal";
import { isPathEqualOrDescendant, isWindowsAbsolutePath, normalizePath } from "../../lib/pathUtils";
Expand Down Expand Up @@ -972,44 +973,7 @@ function MessageCopyButton({
label?: string;
title?: string;
}) {
const [copied, setCopied] = useState(false);
const mountedRef = useRef(true);
const resetTimerRef = useRef<number | null>(null);

useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
if (resetTimerRef.current !== null) {
clearTimeout(resetTimerRef.current);
}
};
}, []);

const handleCopy = useCallback(() => {
if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) return;
void navigator.clipboard.writeText(value)
.then(() => {
if (!mountedRef.current) return;
if (resetTimerRef.current !== null) {
clearTimeout(resetTimerRef.current);
}
setCopied(true);
resetTimerRef.current = window.setTimeout(() => {
resetTimerRef.current = null;
if (!mountedRef.current) return;
setCopied(false);
}, 1_500);
})
.catch(() => {
if (!mountedRef.current) return;
if (resetTimerRef.current !== null) {
clearTimeout(resetTimerRef.current);
resetTimerRef.current = null;
}
setCopied(false);
});
}, [value]);
const { copy, copied } = useCopyToClipboard();

return (
<button
Expand All @@ -1018,7 +982,7 @@ function MessageCopyButton({
"inline-flex items-center gap-1 rounded-md border border-white/[0.06] bg-white/[0.03] px-1.5 py-0.5 font-sans text-[length:calc(var(--chat-font-size)*9/14)] text-fg/40 transition-all hover:border-violet-400/20 hover:bg-violet-500/[0.06] hover:text-fg/70",
className,
)}
onClick={handleCopy}
onClick={() => void copy(value)}
title={copied ? "Copied" : title}
aria-label={copied ? "Copied" : title}
>
Expand Down
15 changes: 3 additions & 12 deletions apps/desktop/src/renderer/components/chat/AgentCliAuthCard.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { ArrowClockwise, CheckCircle, CopySimple, Play, Terminal, Warning } from "@phosphor-icons/react";
import { cn } from "../ui/cn";
import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";

export type AgentCliAuthCardInfo = {
agent: string;
Expand Down Expand Up @@ -59,22 +60,12 @@ const CLAUDE_ACCENT: AccentTokens = {
};

function CommandCopyButton({ command, label }: { command: string; label: string }) {
const [copied, setCopied] = useState(false);

const handleCopy = useCallback(() => {
if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) return;
void navigator.clipboard.writeText(command)
.then(() => {
setCopied(true);
window.setTimeout(() => setCopied(false), 1_500);
})
.catch(() => setCopied(false));
}, [command]);
const { copy, copied } = useCopyToClipboard();

return (
<button
type="button"
onClick={handleCopy}
onClick={() => void copy(command)}
className="inline-flex items-center gap-1.5 rounded-md border border-white/[0.08] bg-white/[0.04] px-2 py-1 font-mono text-[length:calc(var(--chat-font-size)*9/14)] font-bold uppercase tracking-[0.14em] text-fg/58 transition-colors hover:border-amber-300/25 hover:bg-amber-300/[0.07] hover:text-amber-100"
title={copied ? "Copied" : `Copy ${label}`}
>
Expand Down
18 changes: 4 additions & 14 deletions apps/desktop/src/renderer/components/chat/ChatPrPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
XCircle,
} from "@phosphor-icons/react";
import { cn } from "../ui/cn";
import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
import type { OpenProjectBinding, PrCheck, PrReview, PrState, PrStatus, PrSummary } from "../../../shared/types";
import { formatPrBadgeLabel } from "../prs/shared/prFormatters";
import { PrUserAvatar } from "../prs/shared/PrUserAvatar";
Expand Down Expand Up @@ -449,7 +450,7 @@ export const ChatPrPane = React.memo(function ChatPrPane({
}, [machinesById, runtimePinKey]);
const [pr, setPr] = useState<PrSummary | null>(null);
const [loading, setLoading] = useState(true);
const [copied, setCopied] = useState(false);
const { copy, copied } = useCopyToClipboard();
const [checks, setChecks] = useState<PrCheck[] | null>(null);
const [reviews, setReviews] = useState<PrReview[] | null>(null);
const [status, setStatus] = useState<PrStatus | null>(null);
Expand Down Expand Up @@ -649,12 +650,6 @@ export const ChatPrPane = React.memo(function ChatPrPane({
return () => window.clearTimeout(id);
}, [delta?.nonce, delta]);

useEffect(() => {
if (!copied) return;
const id = window.setTimeout(() => setCopied(false), 1500);
return () => window.clearTimeout(id);
}, [copied]);

// Same rule the sidebar badge follows: a PR id only resolves on the machine
// that owns it, so a pinned pane's "Open in ADE" would land on an empty PRs
// tab. `openLanePr` sends a foreign PR to GitHub instead.
Expand All @@ -674,13 +669,8 @@ export const ChatPrPane = React.memo(function ChatPrPane({

const copyLink = useCallback(async () => {
if (!pr) return;
try {
await navigator.clipboard.writeText(pr.githubUrl);
setCopied(true);
} catch {
/* clipboard denied */
}
}, [pr]);
await copy(pr.githubUrl);
}, [copy, pr]);

// Ambient status accent on the pane's inner edge: red while checks fail,
// green while it's merge-ready. Kept as an inset shadow so it never shifts
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { useCallback, useState } from "react";
import React from "react";
import { CopySimple } from "@phosphor-icons/react";
import { cn } from "../ui/cn";
import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
import { ChatMarkdown } from "./chatMarkdown";
import { ProviderLogo } from "../shared/ProviderLogos";
import { pendingInputHeaderLabel } from "../../../shared/pendingInputLabels";
Expand All @@ -26,20 +27,12 @@ const ChatProposedPlanCard = React.memo(function ChatProposedPlanCard({
onApprove,
onReject,
}: ChatProposedPlanCardProps) {
const [copied, setCopied] = useState(false);
const { copy, copied } = useCopyToClipboard();
const bodyText = description?.trim() || question?.trim() || "The agent has prepared a plan.";
// Provider-identified header — "{Provider} · Plan ready" — matching the
// question card. The card chrome inherits the per-provider `--chat-accent`.
const headerLabel = pendingInputHeaderLabel(source, "plan_approval");

const handleCopy = useCallback(() => {
if (!navigator.clipboard) return;
void navigator.clipboard.writeText(bodyText).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}).catch(() => {});
}, [bodyText]);

return (
<div className="relative overflow-hidden rounded-xl border border-[color:color-mix(in_srgb,var(--chat-accent)_22%,transparent)] bg-[#12101A] p-4">
<div className="absolute inset-x-0 top-0 h-px bg-[color:color-mix(in_srgb,var(--chat-accent)_30%,transparent)]" />
Expand Down Expand Up @@ -83,7 +76,7 @@ const ChatProposedPlanCard = React.memo(function ChatProposedPlanCard({
<button
type="button"
className="ml-auto flex items-center gap-1 rounded-[var(--chat-radius-pill)] border border-white/[0.06] px-2 py-1 font-mono text-[9px] uppercase tracking-[0.12em] text-fg/35 transition-colors hover:bg-white/[0.04] hover:text-fg/55"
onClick={handleCopy}
onClick={() => void copy(bodyText)}
>
<CopySimple size={10} weight="bold" />
{copied ? "Copied" : "Copy plan"}
Expand Down
Loading
Loading