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
15 changes: 4 additions & 11 deletions src/components/Messages/AssistantMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { TypewriterMarkdown } from "./TypewriterMarkdown";
import type { ButtonConfig } from "./MessageWindow";
import { MessageWindow } from "./MessageWindow";
import { useStartHere } from "@/hooks/useStartHere";
import { useCopyToClipboard } from "@/hooks/useCopyToClipboard";
import { COMPACTED_EMOJI } from "@/constants/ui";
import { ModelDisplay } from "./ModelDisplay";
import { CompactingMessageContent } from "./CompactingMessageContent";
Expand All @@ -27,7 +28,6 @@ export const AssistantMessage: React.FC<AssistantMessageProps> = ({
clipboardWriteText = (data: string) => navigator.clipboard.writeText(data),
}) => {
const [showRaw, setShowRaw] = useState(false);
const [copied, setCopied] = useState(false);

const content = message.content;
const isStreaming = message.isStreaming;
Expand All @@ -42,15 +42,8 @@ export const AssistantMessage: React.FC<AssistantMessageProps> = ({
modal,
} = useStartHere(workspaceId, content, isCompacted);

const handleCopy = async () => {
try {
await clipboardWriteText(content);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error("Failed to copy:", err);
}
};
// Copy to clipboard with feedback
const { copied, copyToClipboard } = useCopyToClipboard(clipboardWriteText);

// Keep only Copy button visible (most common action)
// Kebab menu saves horizontal space by collapsing less-used actions into a single ⋮ button
Expand All @@ -59,7 +52,7 @@ export const AssistantMessage: React.FC<AssistantMessageProps> = ({
: [
{
label: copied ? "✓ Copied" : "Copy",
onClick: () => void handleCopy(),
onClick: () => void copyToClipboard(content),
},
];

Expand Down
23 changes: 5 additions & 18 deletions src/components/Messages/UserMessage.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import React, { useState } from "react";
import React from "react";
import type { DisplayedMessage } from "@/types/message";
import type { ButtonConfig } from "./MessageWindow";
import { MessageWindow } from "./MessageWindow";
import { TerminalOutput } from "./TerminalOutput";
import { formatKeybind, KEYBINDS } from "@/utils/ui/keybinds";
import { useCopyToClipboard } from "@/hooks/useCopyToClipboard";
import type { KebabMenuItem } from "@/components/KebabMenu";

interface UserMessageProps {
Expand All @@ -30,8 +31,6 @@ export const UserMessage: React.FC<UserMessageProps> = ({
isCompacting,
clipboardWriteText = defaultClipboardWriteText,
}) => {
const [copied, setCopied] = useState(false);

const content = message.content;

console.assert(
Expand All @@ -48,20 +47,8 @@ export const UserMessage: React.FC<UserMessageProps> = ({
? content.slice("<local-command-stdout>".length, -"</local-command-stdout>".length).trim()
: "";

const handleCopy = async () => {
console.assert(
typeof content === "string",
"UserMessage copy handler expects message content to be a string."
);

try {
await clipboardWriteText(content);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error("Failed to copy:", err);
}
};
// Copy to clipboard with feedback
const { copied, copyToClipboard } = useCopyToClipboard(clipboardWriteText);

const handleEdit = () => {
if (onEdit && !isLocalCommandOutput) {
Expand All @@ -86,7 +73,7 @@ export const UserMessage: React.FC<UserMessageProps> = ({
: []),
{
label: copied ? "✓ Copied" : "Copy",
onClick: () => void handleCopy(),
onClick: () => void copyToClipboard(content),
},
];

Expand Down
17 changes: 4 additions & 13 deletions src/components/tools/FileEditToolCall.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
LoadingDots,
} from "./shared/ToolPrimitives";
import { useToolExpansion, getStatusDisplay, type ToolStatus } from "./shared/toolUtils";
import { useCopyToClipboard } from "@/hooks/useCopyToClipboard";
import { TooltipWrapper, Tooltip } from "../Tooltip";
import { DiffContainer, DiffRenderer, SelectableDiffRenderer } from "../shared/DiffRenderer";
import { KebabMenu, type KebabMenuItem } from "../KebabMenu";
Expand Down Expand Up @@ -104,29 +105,19 @@ export const FileEditToolCall: React.FC<FileEditToolCallProps> = ({

const { expanded, toggleExpanded } = useToolExpansion(initialExpanded);
const [showRaw, setShowRaw] = React.useState(false);
const [copied, setCopied] = React.useState(false);

const filePath = "file_path" in args ? args.file_path : undefined;

const handleCopyPatch = async () => {
if (result && result.success && result.diff) {
try {
await navigator.clipboard.writeText(result.diff);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error("Failed to copy:", err);
}
}
};
// Copy to clipboard with feedback
const { copied, copyToClipboard } = useCopyToClipboard();

// Build kebab menu items for successful edits with diffs
const kebabMenuItems: KebabMenuItem[] =
result && result.success && result.diff
? [
{
label: copied ? "✓ Copied" : "Copy Patch",
onClick: () => void handleCopyPatch(),
onClick: () => void copyToClipboard(result.diff),
},
{
label: showRaw ? "Show Parsed" : "Show Patch",
Expand Down
17 changes: 5 additions & 12 deletions src/components/tools/ProposePlanToolCall.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { useToolExpansion, getStatusDisplay, type ToolStatus } from "./shared/to
import { MarkdownRenderer } from "../Messages/MarkdownRenderer";
import { formatKeybind, KEYBINDS } from "@/utils/ui/keybinds";
import { useStartHere } from "@/hooks/useStartHere";
import { useCopyToClipboard } from "@/hooks/useCopyToClipboard";
import { TooltipWrapper, Tooltip } from "../Tooltip";
import { cn } from "@/lib/utils";

Expand All @@ -30,7 +31,6 @@ export const ProposePlanToolCall: React.FC<ProposePlanToolCallProps> = ({
}) => {
const { expanded, toggleExpanded } = useToolExpansion(true); // Expand by default
const [showRaw, setShowRaw] = useState(false);
const [copied, setCopied] = useState(false);

// Format: Title as H1 + plan content for "Start Here" functionality
const startHereContent = `# ${args.title}\n\n${args.plan}`;
Expand All @@ -46,20 +46,13 @@ export const ProposePlanToolCall: React.FC<ProposePlanToolCallProps> = ({
false // Plans are never already compacted
);

// Copy to clipboard with feedback
const { copied, copyToClipboard } = useCopyToClipboard();

const [isHovered, setIsHovered] = useState(false);

const statusDisplay = getStatusDisplay(status);

const handleCopy = async () => {
try {
await navigator.clipboard.writeText(args.plan);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error("Failed to copy:", err);
}
};

return (
<ToolContainer expanded={expanded}>
<ToolHeader onClick={toggleExpanded}>
Expand Down Expand Up @@ -134,7 +127,7 @@ export const ProposePlanToolCall: React.FC<ProposePlanToolCallProps> = ({
</TooltipWrapper>
)}
<button
onClick={() => void handleCopy()}
onClick={() => void copyToClipboard(args.plan)}
className="text-muted hover:text-plan-mode cursor-pointer rounded-sm bg-transparent px-2 py-1 font-mono text-[10px] transition-all duration-150 active:translate-y-px"
style={{
border: "1px solid rgba(136, 136, 136, 0.3)",
Expand Down
5 changes: 5 additions & 0 deletions src/constants/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,8 @@
* - Start Here button (plans and assistant messages)
*/
export const COMPACTED_EMOJI = "📦";

/**
* Duration (ms) to show "copied" feedback after copying to clipboard
*/
export const COPY_FEEDBACK_DURATION_MS = 2000;
32 changes: 32 additions & 0 deletions src/hooks/useCopyToClipboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { useState, useCallback } from "react";
import { COPY_FEEDBACK_DURATION_MS } from "@/constants/ui";

/**
* Hook for copy-to-clipboard functionality with temporary "copied" feedback state.
*
* @param clipboardWriteText - Optional custom clipboard write function (defaults to navigator.clipboard.writeText)
* @returns Object with:
* - copied: boolean indicating if content was just copied (resets after COPY_FEEDBACK_DURATION_MS)
* - copyToClipboard: async function to copy text and trigger feedback
*/
export function useCopyToClipboard(
clipboardWriteText: (text: string) => Promise<void> = (text: string) =>
navigator.clipboard.writeText(text)
) {
const [copied, setCopied] = useState(false);

const copyToClipboard = useCallback(
async (text: string) => {
try {
await clipboardWriteText(text);
setCopied(true);
setTimeout(() => setCopied(false), COPY_FEEDBACK_DURATION_MS);
} catch (err) {
console.error("Failed to copy:", err);
}
},
[clipboardWriteText]
);

return { copied, copyToClipboard };
}
110 changes: 110 additions & 0 deletions tests/ipcMain/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ import type { WorkspaceMetadataWithPaths } from "../../src/types/workspace";
import * as path from "path";
import * as os from "os";
import { detectDefaultTrunkBranch } from "../../src/git";
import type { TestEnvironment } from "./setup";
import type { RuntimeConfig } from "../../src/types/runtime";
import type { ToolPolicy } from "../../src/utils/tools/toolPolicy";

// Test constants - centralized for consistency across all tests
export const INIT_HOOK_WAIT_MS = 1500; // Wait for async init hook completion (local runtime)
export const SSH_INIT_WAIT_MS = 7000; // SSH init includes sync + checkout + hook, takes longer
export const HAIKU_MODEL = "anthropic:claude-haiku-4-5"; // Fast model for tests
export const TEST_TIMEOUT_LOCAL_MS = 25000; // Recommended timeout for local runtime tests
export const TEST_TIMEOUT_SSH_MS = 60000; // Recommended timeout for SSH runtime tests
export const STREAM_TIMEOUT_LOCAL_MS = 15000; // Stream timeout for local runtime
export const STREAM_TIMEOUT_SSH_MS = 25000; // Stream timeout for SSH runtime

/**
* Generate a unique branch name
Expand Down Expand Up @@ -98,6 +110,104 @@ export async function clearHistory(
)) as Result<void, string>;
}

/**
* Extract text content from stream events
* Filters for stream-delta events and concatenates the delta text
*/
export function extractTextFromEvents(events: WorkspaceChatMessage[]): string {
return events
.filter((e: any) => e.type === "stream-delta" && "delta" in e)
.map((e: any) => e.delta || "")
.join("");
}

/**
* Create workspace with optional init hook wait
* Enhanced version that can wait for init hook completion (needed for runtime tests)
*/
export async function createWorkspaceWithInit(
env: TestEnvironment,
projectPath: string,
branchName: string,
runtimeConfig?: RuntimeConfig,
waitForInit: boolean = false,
isSSH: boolean = false
): Promise<{ workspaceId: string; workspacePath: string; cleanup: () => Promise<void> }> {
const trunkBranch = await detectDefaultTrunkBranch(projectPath);

const result: any = await env.mockIpcRenderer.invoke(
IPC_CHANNELS.WORKSPACE_CREATE,
projectPath,
branchName,
trunkBranch,
runtimeConfig
);

if (!result.success) {
throw new Error(`Failed to create workspace: ${result.error}`);
}

const workspaceId = result.metadata.id;
const workspacePath = result.metadata.namedWorkspacePath;

// Wait for init hook to complete if requested
if (waitForInit) {
const initTimeout = isSSH ? SSH_INIT_WAIT_MS : INIT_HOOK_WAIT_MS;
const collector = createEventCollector(env.sentEvents, workspaceId);
try {
await collector.waitForEvent("init-end", initTimeout);
} catch (err) {
// Init hook might not exist or might have already completed before we started waiting
// This is not necessarily an error - just log it
console.log(
`Note: init-end event not detected within ${initTimeout}ms (may have completed early)`
);
}
}

const cleanup = async () => {
await env.mockIpcRenderer.invoke(IPC_CHANNELS.WORKSPACE_REMOVE, workspaceId);
};

return { workspaceId, workspacePath, cleanup };
}

/**
* Send message and wait for stream completion
* Convenience helper that combines message sending with event collection
*/
export async function sendMessageAndWait(
env: TestEnvironment,
workspaceId: string,
message: string,
model: string,
toolPolicy?: ToolPolicy,
timeoutMs: number = STREAM_TIMEOUT_LOCAL_MS
): Promise<WorkspaceChatMessage[]> {
// Clear previous events
env.sentEvents.length = 0;

// Send message
const result = await env.mockIpcRenderer.invoke(
IPC_CHANNELS.WORKSPACE_SEND_MESSAGE,
workspaceId,
message,
{
model,
toolPolicy,
}
);

if (!result.success) {
throw new Error(`Failed to send message: ${result.error}`);
}

// Wait for stream completion
const collector = createEventCollector(env.sentEvents, workspaceId);
await collector.waitForEvent("stream-end", timeoutMs);
return collector.getEvents();
}

/**
* Event collector for capturing stream events
*/
Expand Down
Loading