From 39b67ff75490a55fa73804a66dc123d5d211efb1 Mon Sep 17 00:00:00 2001 From: Dave Date: Fri, 20 Feb 2026 12:48:50 +0000 Subject: [PATCH] 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 --- .story_kit/project.toml | 4 +- frontend/src/api/agents.ts | 1 + frontend/src/components/AgentPanel.test.tsx | 186 ++++++++++++++++++++ frontend/src/components/AgentPanel.tsx | 73 ++++++++ server/src/agents.rs | 8 +- server/src/config.rs | 11 +- server/src/worktree.rs | 21 +++ 7 files changed, 297 insertions(+), 7 deletions(-) create mode 100644 frontend/src/components/AgentPanel.test.tsx diff --git a/.story_kit/project.toml b/.story_kit/project.toml index b782a81..ffb29f2 100644 --- a/.story_kit/project.toml +++ b/.story_kit/project.toml @@ -54,7 +54,7 @@ role = "Full-stack engineer. Implements features across all components." model = "sonnet" max_turns = 50 max_budget_usd = 5.00 -prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .story_kit/README.md to understand the dev process. Pick up the story from .story_kit/stories/ - move it to current/ if needed. Follow the SDTW process through implementation and verification (Steps 1-3). The worktree and feature branch already exist - do not create them. Check .mcp.json for MCP tools. Do NOT accept the story or merge - commit your work and stop." +prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .story_kit/README.md to understand the dev process. Pick up the story from .story_kit/stories/ - move it to current/ if needed. Follow the SDTW process through implementation and verification (Steps 1-3). The worktree and feature branch already exist - do not create them. Check .mcp.json for MCP tools. Do NOT accept the story or merge - commit your work and stop. If the user asks to review your changes, tell them to run: cd \"{{worktree_path}}\" && git difftool {{base_branch}}...HEAD" system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Follow the Story-Driven Test Workflow strictly. Run cargo clippy and biome checks before considering work complete. Commit all your work before finishing - use a descriptive commit message. Do not accept stories, move them to archived, or merge to master - a human will do that. Do not coordinate with other agents - focus on your assigned story." [[agent]] @@ -63,5 +63,5 @@ role = "Full-stack engineer. Implements features across all components." model = "sonnet" max_turns = 50 max_budget_usd = 5.00 -prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .story_kit/README.md to understand the dev process. Pick up the story from .story_kit/stories/ - move it to current/ if needed. Follow the SDTW process through implementation and verification (Steps 1-3). The worktree and feature branch already exist - do not create them. Check .mcp.json for MCP tools. Do NOT accept the story or merge - commit your work and stop." +prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .story_kit/README.md to understand the dev process. Pick up the story from .story_kit/stories/ - move it to current/ if needed. Follow the SDTW process through implementation and verification (Steps 1-3). The worktree and feature branch already exist - do not create them. Check .mcp.json for MCP tools. Do NOT accept the story or merge - commit your work and stop. If the user asks to review your changes, tell them to run: cd \"{{worktree_path}}\" && git difftool {{base_branch}}...HEAD" system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Follow the Story-Driven Test Workflow strictly. Run cargo clippy and biome checks before considering work complete. Commit all your work before finishing - use a descriptive commit message. Do not accept stories, move them to archived, or merge to master - a human will do that. Do not coordinate with other agents - focus on your assigned story." diff --git a/frontend/src/api/agents.ts b/frontend/src/api/agents.ts index 7106e5b..76559db 100644 --- a/frontend/src/api/agents.ts +++ b/frontend/src/api/agents.ts @@ -6,6 +6,7 @@ export interface AgentInfo { status: AgentStatusValue; session_id: string | null; worktree_path: string | null; + base_branch: string | null; } export interface AgentEvent { diff --git a/frontend/src/components/AgentPanel.test.tsx b/frontend/src/components/AgentPanel.test.tsx new file mode 100644 index 0000000..04cc1fd --- /dev/null +++ b/frontend/src/components/AgentPanel.test.tsx @@ -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( + , + ); + + // 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + const expandButton = await screen.findByText("▶"); + await userEvent.click(expandButton); + + expect( + await screen.findByText( + 'cd "/tmp/wt" && git difftool master...HEAD', + ), + ).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/AgentPanel.tsx b/frontend/src/components/AgentPanel.tsx index e71d10c..e60ca39 100644 --- a/frontend/src/components/AgentPanel.tsx +++ b/frontend/src/components/AgentPanel.tsx @@ -20,6 +20,7 @@ interface AgentState { log: string[]; sessionId: string | null; worktreePath: string | null; + baseBranch: string | null; } const STATUS_COLORS: Record = { @@ -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 ( +
+ + {command} + + +
+ ); +} + export function AgentPanel({ stories }: AgentPanelProps) { const [agents, setAgents] = useState>({}); const [roster, setRoster] = useState([]); @@ -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} )} + {a.worktreePath && ( + + )}
, pub worktree_path: Option, + pub base_branch: Option, } struct StoryAgent { @@ -182,7 +183,7 @@ impl AgentPool { // Spawn the agent process let wt_path_str = wt_info.path.to_string_lossy().to_string(); let (command, args, prompt) = - config.render_agent_args(&wt_path_str, story_id, Some(&resolved_name))?; + config.render_agent_args(&wt_path_str, story_id, Some(&resolved_name), Some(&wt_info.base_branch))?; let sid = story_id.to_string(); let aname = resolved_name.clone(); @@ -247,6 +248,7 @@ impl AgentPool { status: AgentStatus::Running, session_id: None, worktree_path: Some(wt_path_str), + base_branch: Some(wt_info.base_branch.clone()), }) } @@ -321,6 +323,10 @@ impl AgentPool { .worktree_info .as_ref() .map(|wt| wt.path.to_string_lossy().to_string()), + base_branch: agent + .worktree_info + .as_ref() + .map(|wt| wt.base_branch.clone()), } }) .collect()) diff --git a/server/src/config.rs b/server/src/config.rs index c378ff8..c0cb7cf 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -175,6 +175,7 @@ impl ProjectConfig { worktree_path: &str, story_id: &str, agent_name: Option<&str>, + base_branch: Option<&str>, ) -> Result<(String, Vec, String), String> { let agent = match agent_name { Some(name) => self @@ -185,9 +186,11 @@ impl ProjectConfig { .ok_or_else(|| "No agents configured".to_string())?, }; + let bb = base_branch.unwrap_or("master"); let render = |s: &str| { s.replace("{{worktree_path}}", worktree_path) .replace("{{story_id}}", story_id) + .replace("{{base_branch}}", bb) }; let command = render(&agent.command); @@ -378,7 +381,7 @@ max_turns = 0 fn render_agent_args_default() { let config = ProjectConfig::default(); let (cmd, args, prompt) = config - .render_agent_args("/tmp/wt", "42_foo", None) + .render_agent_args("/tmp/wt", "42_foo", None, None) .unwrap(); assert_eq!(cmd, "claude"); assert!(args.is_empty()); @@ -404,7 +407,7 @@ max_turns = 30 let config = ProjectConfig::parse(toml_str).unwrap(); let (cmd, args, prompt) = config - .render_agent_args("/tmp/wt", "42_foo", Some("supervisor")) + .render_agent_args("/tmp/wt", "42_foo", Some("supervisor"), Some("master")) .unwrap(); assert_eq!(cmd, "claude"); assert!(args.contains(&"--model".to_string())); @@ -422,7 +425,7 @@ max_turns = 30 // Render for coder let (_, coder_args, _) = config - .render_agent_args("/tmp/wt", "42_foo", Some("coder")) + .render_agent_args("/tmp/wt", "42_foo", Some("coder"), Some("master")) .unwrap(); assert!(coder_args.contains(&"sonnet".to_string())); assert!(coder_args.contains(&"30".to_string())); @@ -433,7 +436,7 @@ max_turns = 30 #[test] fn render_agent_args_not_found() { let config = ProjectConfig::default(); - let result = config.render_agent_args("/tmp/wt", "42_foo", Some("nonexistent")); + let result = config.render_agent_args("/tmp/wt", "42_foo", Some("nonexistent"), None); assert!(result.is_err()); assert!(result.unwrap_err().contains("No agent named 'nonexistent'")); } diff --git a/server/src/worktree.rs b/server/src/worktree.rs index 77e7f4f..21edd2a 100644 --- a/server/src/worktree.rs +++ b/server/src/worktree.rs @@ -7,6 +7,7 @@ use std::process::Command; pub struct WorktreeInfo { pub path: PathBuf, pub branch: String, + pub base_branch: String, } /// Worktree path as a sibling of the project root: `{project_root}-story-{id}`. @@ -24,6 +25,23 @@ fn branch_name(story_id: &str) -> String { format!("feature/story-{story_id}") } +/// Detect the current branch of the project root (the base branch worktrees fork from). +fn detect_base_branch(project_root: &Path) -> String { + Command::new("git") + .args(["rev-parse", "--abbrev-ref", "HEAD"]) + .current_dir(project_root) + .output() + .ok() + .and_then(|o| { + if o.status.success() { + Some(String::from_utf8_lossy(&o.stdout).trim().to_string()) + } else { + None + } + }) + .unwrap_or_else(|| "master".to_string()) +} + /// Create a git worktree for the given story. /// /// - Creates the worktree at `{project_root}-story-{story_id}` (sibling directory) @@ -37,6 +55,7 @@ pub async fn create_worktree( ) -> Result { let wt_path = worktree_path(project_root, story_id); let branch = branch_name(story_id); + let base_branch = detect_base_branch(project_root); let root = project_root.to_path_buf(); // Already exists — reuse @@ -45,6 +64,7 @@ pub async fn create_worktree( return Ok(WorktreeInfo { path: wt_path, branch, + base_branch, }); } @@ -60,6 +80,7 @@ pub async fn create_worktree( Ok(WorktreeInfo { path: wt_path, branch, + base_branch, }) }