Story 33: Copy-paste diff commands for agent worktrees

- Add base_branch detection to WorktreeInfo (from project root HEAD)
- Expose base_branch in AgentInfo API response
- Add {{base_branch}} template variable to agent config rendering
- Show git difftool command with copy-to-clipboard in AgentPanel UI
- Add diff command instruction to coder agent prompts
- Add AgentPanel tests for diff command rendering and clipboard

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dave
2026-02-20 12:48:50 +00:00
parent 1cd1d318d3
commit 39b67ff754
7 changed files with 297 additions and 7 deletions

View File

@@ -6,6 +6,7 @@ export interface AgentInfo {
status: AgentStatusValue;
session_id: string | null;
worktree_path: string | null;
base_branch: string | null;
}
export interface AgentEvent {

View File

@@ -0,0 +1,186 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { AgentInfo, AgentConfigInfo } from "../api/agents";
import { agentsApi } from "../api/agents";
vi.mock("../api/agents", () => {
const agentsApi = {
listAgents: vi.fn(),
getAgentConfig: vi.fn(),
startAgent: vi.fn(),
stopAgent: vi.fn(),
reloadConfig: vi.fn(),
};
return { agentsApi, subscribeAgentStream: vi.fn(() => () => {}) };
});
// Dynamic import so the mock is in place before the module loads
const { AgentPanel } = await import("./AgentPanel");
const mockedAgents = {
listAgents: vi.mocked(agentsApi.listAgents),
getAgentConfig: vi.mocked(agentsApi.getAgentConfig),
startAgent: vi.mocked(agentsApi.startAgent),
};
const ROSTER: AgentConfigInfo[] = [
{
name: "coder-1",
role: "Full-stack engineer",
model: "sonnet",
allowed_tools: null,
max_turns: 50,
max_budget_usd: 5.0,
},
];
describe("AgentPanel diff command", () => {
beforeAll(() => {
Element.prototype.scrollIntoView = vi.fn();
});
beforeEach(() => {
mockedAgents.getAgentConfig.mockResolvedValue(ROSTER);
mockedAgents.listAgents.mockResolvedValue([]);
});
it("shows diff command when an agent has a worktree path", async () => {
const agentList: AgentInfo[] = [
{
story_id: "33_diff_commands",
agent_name: "coder-1",
status: "running",
session_id: null,
worktree_path: "/tmp/project-story-33",
base_branch: "master",
},
];
mockedAgents.listAgents.mockResolvedValue(agentList);
render(
<AgentPanel
stories={[
{ story_id: "33_diff_commands", name: "Diff Commands", error: null },
]}
/>,
);
// Expand the agent detail by clicking the expand button
const expandButton = await screen.findByText("▶");
await userEvent.click(expandButton);
// The diff command should be rendered
expect(
await screen.findByText(
'cd "/tmp/project-story-33" && git difftool master...HEAD',
),
).toBeInTheDocument();
// A copy button should exist
expect(screen.getByText("Copy")).toBeInTheDocument();
});
it("copies diff command to clipboard on click", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.assign(navigator, {
clipboard: { writeText },
});
const agentList: AgentInfo[] = [
{
story_id: "33_diff_commands",
agent_name: "coder-1",
status: "completed",
session_id: null,
worktree_path: "/home/user/my-project-story-33",
base_branch: "main",
},
];
mockedAgents.listAgents.mockResolvedValue(agentList);
render(
<AgentPanel
stories={[
{ story_id: "33_diff_commands", name: "Diff Commands", error: null },
]}
/>,
);
const expandButton = await screen.findByText("▶");
await userEvent.click(expandButton);
const copyButton = await screen.findByText("Copy");
await userEvent.click(copyButton);
await waitFor(() => {
expect(writeText).toHaveBeenCalledWith(
'cd "/home/user/my-project-story-33" && git difftool main...HEAD',
);
});
expect(await screen.findByText("Copied")).toBeInTheDocument();
});
it("uses base_branch from the server in the diff command", async () => {
const agentList: AgentInfo[] = [
{
story_id: "33_diff_commands",
agent_name: "coder-1",
status: "running",
session_id: null,
worktree_path: "/tmp/wt",
base_branch: "develop",
},
];
mockedAgents.listAgents.mockResolvedValue(agentList);
render(
<AgentPanel
stories={[
{ story_id: "33_diff_commands", name: "Diff Commands", error: null },
]}
/>,
);
const expandButton = await screen.findByText("▶");
await userEvent.click(expandButton);
expect(
await screen.findByText(
'cd "/tmp/wt" && git difftool develop...HEAD',
),
).toBeInTheDocument();
});
it("defaults to master when base_branch is null", async () => {
const agentList: AgentInfo[] = [
{
story_id: "33_diff_commands",
agent_name: "coder-1",
status: "running",
session_id: null,
worktree_path: "/tmp/wt",
base_branch: null,
},
];
mockedAgents.listAgents.mockResolvedValue(agentList);
render(
<AgentPanel
stories={[
{ story_id: "33_diff_commands", name: "Diff Commands", error: null },
]}
/>,
);
const expandButton = await screen.findByText("▶");
await userEvent.click(expandButton);
expect(
await screen.findByText(
'cd "/tmp/wt" && git difftool master...HEAD',
),
).toBeInTheDocument();
});
});

View File

@@ -20,6 +20,7 @@ interface AgentState {
log: string[];
sessionId: string | null;
worktreePath: string | null;
baseBranch: string | null;
}
const STATUS_COLORS: Record<AgentStatusValue, string> = {
@@ -104,6 +105,69 @@ function agentKey(storyId: string, agentName: string): string {
return `${storyId}:${agentName}`;
}
function DiffCommand({
worktreePath,
baseBranch,
}: { worktreePath: string; baseBranch: string }) {
const [copied, setCopied] = useState(false);
const command = `cd "${worktreePath}" && git difftool ${baseBranch}...HEAD`;
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(command);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Fallback: select text for manual copy
}
};
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: "6px",
marginBottom: "6px",
}}
>
<code
style={{
flex: 1,
fontSize: "0.7em",
color: "#8b949e",
background: "#0d1117",
padding: "4px 8px",
borderRadius: "4px",
border: "1px solid #21262d",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{command}
</code>
<button
type="button"
onClick={handleCopy}
style={{
padding: "3px 8px",
borderRadius: "4px",
border: "1px solid #30363d",
background: copied ? "#238636" : "#21262d",
color: copied ? "#fff" : "#8b949e",
cursor: "pointer",
fontSize: "0.7em",
fontWeight: 600,
whiteSpace: "nowrap",
}}
>
{copied ? "Copied" : "Copy"}
</button>
</div>
);
}
export function AgentPanel({ stories }: AgentPanelProps) {
const [agents, setAgents] = useState<Record<string, AgentState>>({});
const [roster, setRoster] = useState<AgentConfigInfo[]>([]);
@@ -133,6 +197,7 @@ export function AgentPanel({ stories }: AgentPanelProps) {
log: [],
sessionId: a.session_id,
worktreePath: a.worktree_path,
baseBranch: a.base_branch,
};
if (a.status === "running" || a.status === "pending") {
subscribeToAgent(a.story_id, a.agent_name);
@@ -166,6 +231,7 @@ export function AgentPanel({ stories }: AgentPanelProps) {
log: [],
sessionId: null,
worktreePath: null,
baseBranch: null,
};
switch (event.type) {
@@ -241,6 +307,7 @@ export function AgentPanel({ stories }: AgentPanelProps) {
log: [],
sessionId: info.session_id,
worktreePath: info.worktree_path,
baseBranch: info.base_branch,
},
}));
setExpandedKey(key);
@@ -609,6 +676,12 @@ export function AgentPanel({ stories }: AgentPanelProps) {
Worktree: {a.worktreePath}
</div>
)}
{a.worktreePath && (
<DiffCommand
worktreePath={a.worktreePath}
baseBranch={a.baseBranch ?? "master"}
/>
)}
<div
style={{
maxHeight: "300px",