story-kit: merge 342_story_web_ui_button_to_delete_a_story_from_the_pipeline
This commit is contained in:
@@ -373,6 +373,10 @@ export const api = {
|
|||||||
launchQaApp(storyId: string) {
|
launchQaApp(storyId: string) {
|
||||||
return callMcpTool("launch_qa_app", { story_id: storyId });
|
return callMcpTool("launch_qa_app", { story_id: storyId });
|
||||||
},
|
},
|
||||||
|
/** Delete a story from the pipeline, stopping any running agent and removing the worktree. */
|
||||||
|
deleteStory(storyId: string) {
|
||||||
|
return callMcpTool("delete_story", { story_id: storyId });
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
async function callMcpTool(
|
async function callMcpTool(
|
||||||
|
|||||||
@@ -213,6 +213,15 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
const [selectedWorkItemId, setSelectedWorkItemId] = useState<string | null>(
|
const [selectedWorkItemId, setSelectedWorkItemId] = useState<string | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleDeleteItem = React.useCallback(
|
||||||
|
(item: import("../api/client").PipelineStageItem) => {
|
||||||
|
api.deleteStory(item.story_id).catch((err: unknown) => {
|
||||||
|
console.error("Failed to delete story:", err);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
const [queuedMessages, setQueuedMessages] = useState<
|
const [queuedMessages, setQueuedMessages] = useState<
|
||||||
{ id: string; text: string }[]
|
{ id: string; text: string }[]
|
||||||
>([]);
|
>([]);
|
||||||
@@ -1089,6 +1098,7 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
costs={storyTokenCosts}
|
costs={storyTokenCosts}
|
||||||
onItemClick={(item) => setSelectedWorkItemId(item.story_id)}
|
onItemClick={(item) => setSelectedWorkItemId(item.story_id)}
|
||||||
onStopAgent={handleStopAgent}
|
onStopAgent={handleStopAgent}
|
||||||
|
onDeleteItem={handleDeleteItem}
|
||||||
/>
|
/>
|
||||||
<StagePanel
|
<StagePanel
|
||||||
title="To Merge"
|
title="To Merge"
|
||||||
@@ -1096,6 +1106,7 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
costs={storyTokenCosts}
|
costs={storyTokenCosts}
|
||||||
onItemClick={(item) => setSelectedWorkItemId(item.story_id)}
|
onItemClick={(item) => setSelectedWorkItemId(item.story_id)}
|
||||||
onStopAgent={handleStopAgent}
|
onStopAgent={handleStopAgent}
|
||||||
|
onDeleteItem={handleDeleteItem}
|
||||||
/>
|
/>
|
||||||
<StagePanel
|
<StagePanel
|
||||||
title="QA"
|
title="QA"
|
||||||
@@ -1103,6 +1114,7 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
costs={storyTokenCosts}
|
costs={storyTokenCosts}
|
||||||
onItemClick={(item) => setSelectedWorkItemId(item.story_id)}
|
onItemClick={(item) => setSelectedWorkItemId(item.story_id)}
|
||||||
onStopAgent={handleStopAgent}
|
onStopAgent={handleStopAgent}
|
||||||
|
onDeleteItem={handleDeleteItem}
|
||||||
/>
|
/>
|
||||||
<StagePanel
|
<StagePanel
|
||||||
title="Current"
|
title="Current"
|
||||||
@@ -1113,6 +1125,7 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
busyAgentNames={busyAgentNames}
|
busyAgentNames={busyAgentNames}
|
||||||
onStartAgent={handleStartAgent}
|
onStartAgent={handleStartAgent}
|
||||||
onStopAgent={handleStopAgent}
|
onStopAgent={handleStopAgent}
|
||||||
|
onDeleteItem={handleDeleteItem}
|
||||||
/>
|
/>
|
||||||
<StagePanel
|
<StagePanel
|
||||||
title="Backlog"
|
title="Backlog"
|
||||||
@@ -1123,6 +1136,7 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
busyAgentNames={busyAgentNames}
|
busyAgentNames={busyAgentNames}
|
||||||
onStartAgent={handleStartAgent}
|
onStartAgent={handleStartAgent}
|
||||||
onStopAgent={handleStopAgent}
|
onStopAgent={handleStopAgent}
|
||||||
|
onDeleteItem={handleDeleteItem}
|
||||||
/>
|
/>
|
||||||
<ServerLogsPanel logs={serverLogs} />
|
<ServerLogsPanel logs={serverLogs} />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ interface StagePanelProps {
|
|||||||
emptyMessage?: string;
|
emptyMessage?: string;
|
||||||
onItemClick?: (item: PipelineStageItem) => void;
|
onItemClick?: (item: PipelineStageItem) => void;
|
||||||
onStopAgent?: (storyId: string, agentName: string) => void;
|
onStopAgent?: (storyId: string, agentName: string) => void;
|
||||||
|
onDeleteItem?: (item: PipelineStageItem) => void;
|
||||||
/** Map of story_id → total_cost_usd for displaying cost badges. */
|
/** Map of story_id → total_cost_usd for displaying cost badges. */
|
||||||
costs?: Map<string, number>;
|
costs?: Map<string, number>;
|
||||||
/** Agent roster to populate the start agent dropdown. */
|
/** Agent roster to populate the start agent dropdown. */
|
||||||
@@ -253,6 +254,7 @@ export function StagePanel({
|
|||||||
emptyMessage = "Empty.",
|
emptyMessage = "Empty.",
|
||||||
onItemClick,
|
onItemClick,
|
||||||
onStopAgent,
|
onStopAgent,
|
||||||
|
onDeleteItem,
|
||||||
costs,
|
costs,
|
||||||
agentRoster,
|
agentRoster,
|
||||||
busyAgentNames,
|
busyAgentNames,
|
||||||
@@ -444,9 +446,8 @@ export function StagePanel({
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
return onItemClick ? (
|
const card = onItemClick ? (
|
||||||
<button
|
<button
|
||||||
key={`${title}-${item.story_id}`}
|
|
||||||
type="button"
|
type="button"
|
||||||
data-testid={`card-${item.story_id}`}
|
data-testid={`card-${item.story_id}`}
|
||||||
onClick={() => onItemClick(item)}
|
onClick={() => onItemClick(item)}
|
||||||
@@ -455,12 +456,57 @@ export function StagePanel({
|
|||||||
{cardInner}
|
{cardInner}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
|
<div data-testid={`card-${item.story_id}`} style={cardStyle}>
|
||||||
|
{cardInner}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${title}-${item.story_id}`}
|
key={`${title}-${item.story_id}`}
|
||||||
data-testid={`card-${item.story_id}`}
|
style={{ position: "relative" }}
|
||||||
style={cardStyle}
|
|
||||||
>
|
>
|
||||||
{cardInner}
|
{card}
|
||||||
|
{onDeleteItem && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid={`delete-btn-${item.story_id}`}
|
||||||
|
title={`Delete ${item.name ?? item.story_id}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const label = item.name ?? item.story_id;
|
||||||
|
if (
|
||||||
|
window.confirm(
|
||||||
|
`Delete "${label}"? This cannot be undone.`,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
onDeleteItem(item);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "4px",
|
||||||
|
right: "4px",
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
color: "#555",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "0.85em",
|
||||||
|
lineHeight: 1,
|
||||||
|
padding: "2px 4px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.color =
|
||||||
|
"#f85149";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.color =
|
||||||
|
"#555";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -967,6 +967,20 @@ fn handle_tools_list(id: Option<Value>) -> JsonRpcResponse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "delete_story",
|
||||||
|
"description": "Delete a work item from the pipeline entirely. Stops any running agent, removes the worktree, and deletes the story file. Use only for removing obsolete or duplicate items.",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"story_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Work item identifier (filename stem, e.g. '28_story_my_feature')"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["story_id"]
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "move_story",
|
"name": "move_story",
|
||||||
"description": "Move a work item (story, bug, spike, or refactor) to an arbitrary pipeline stage. Prefer dedicated tools when available: use accept_story to mark items done, move_story_to_merge to queue for merging, or request_qa to trigger QA review. Use move_story only for arbitrary moves that lack a dedicated tool — for example, moving a story back to backlog or recovering a ghost story by moving it back to current.",
|
"description": "Move a work item (story, bug, spike, or refactor) to an arbitrary pipeline stage. Prefer dedicated tools when available: use accept_story to mark items done, move_story_to_merge to queue for merging, or request_qa to trigger QA review. Use move_story only for arbitrary moves that lack a dedicated tool — for example, moving a story back to backlog or recovering a ghost story by moving it back to current.",
|
||||||
@@ -1061,6 +1075,8 @@ async fn handle_tools_call(
|
|||||||
"prompt_permission" => diagnostics::tool_prompt_permission(&args, ctx).await,
|
"prompt_permission" => diagnostics::tool_prompt_permission(&args, ctx).await,
|
||||||
// Token usage
|
// Token usage
|
||||||
"get_token_usage" => diagnostics::tool_get_token_usage(&args, ctx),
|
"get_token_usage" => diagnostics::tool_get_token_usage(&args, ctx),
|
||||||
|
// Delete story
|
||||||
|
"delete_story" => story_tools::tool_delete_story(&args, ctx).await,
|
||||||
// Arbitrary pipeline movement
|
// Arbitrary pipeline movement
|
||||||
"move_story" => diagnostics::tool_move_story(&args, ctx),
|
"move_story" => diagnostics::tool_move_story(&args, ctx),
|
||||||
_ => Err(format!("Unknown tool: {tool_name}")),
|
_ => Err(format!("Unknown tool: {tool_name}")),
|
||||||
@@ -1171,7 +1187,8 @@ mod tests {
|
|||||||
assert!(names.contains(&"rebuild_and_restart"));
|
assert!(names.contains(&"rebuild_and_restart"));
|
||||||
assert!(names.contains(&"get_token_usage"));
|
assert!(names.contains(&"get_token_usage"));
|
||||||
assert!(names.contains(&"move_story"));
|
assert!(names.contains(&"move_story"));
|
||||||
assert_eq!(tools.len(), 41);
|
assert!(names.contains(&"delete_story"));
|
||||||
|
assert_eq!(tools.len(), 42);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -390,6 +390,53 @@ pub(super) fn tool_close_bug(args: &Value, ctx: &AppContext) -> Result<String, S
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(super) async fn tool_delete_story(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
||||||
|
let story_id = args
|
||||||
|
.get("story_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or("Missing required argument: story_id")?;
|
||||||
|
|
||||||
|
let project_root = ctx.agents.get_project_root(&ctx.state)?;
|
||||||
|
|
||||||
|
// 1. Stop any running agents for this story (best-effort)
|
||||||
|
if let Ok(agents) = ctx.agents.list_agents() {
|
||||||
|
for agent in agents.iter().filter(|a| a.story_id == story_id) {
|
||||||
|
let _ = ctx.agents.stop_agent(&project_root, story_id, &agent.agent_name).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Remove agent pool entries
|
||||||
|
ctx.agents.remove_agents_for_story(story_id);
|
||||||
|
|
||||||
|
// 3. Remove worktree (best-effort)
|
||||||
|
if let Ok(config) = crate::config::ProjectConfig::load(&project_root) {
|
||||||
|
let _ = crate::worktree::remove_worktree_by_story_id(&project_root, story_id, &config).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Find and delete the story file from any pipeline stage
|
||||||
|
let sk = project_root.join(".story_kit").join("work");
|
||||||
|
let stage_dirs = ["1_backlog", "2_current", "3_qa", "4_merge", "5_done", "6_archived"];
|
||||||
|
let mut deleted = false;
|
||||||
|
for stage in &stage_dirs {
|
||||||
|
let path = sk.join(stage).join(format!("{story_id}.md"));
|
||||||
|
if path.exists() {
|
||||||
|
fs::remove_file(&path)
|
||||||
|
.map_err(|e| format!("Failed to delete story file: {e}"))?;
|
||||||
|
slog_warn!("[delete_story] Deleted '{story_id}' from work/{stage}/");
|
||||||
|
deleted = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !deleted {
|
||||||
|
return Err(format!(
|
||||||
|
"Story '{story_id}' not found in any pipeline stage."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(format!("Story '{story_id}' deleted from pipeline."))
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn tool_create_refactor(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
pub(super) fn tool_create_refactor(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
||||||
let name = args
|
let name = args
|
||||||
.get("name")
|
.get("name")
|
||||||
@@ -1129,6 +1176,52 @@ mod tests {
|
|||||||
assert!(result.unwrap_err().contains("blocked"));
|
assert!(result.unwrap_err().contains("blocked"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn tool_delete_story_missing_story_id() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let ctx = test_ctx(tmp.path());
|
||||||
|
let result = tool_delete_story(&json!({}), &ctx).await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().contains("story_id"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn tool_delete_story_not_found_returns_error() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let ctx = test_ctx(tmp.path());
|
||||||
|
let result = tool_delete_story(&json!({"story_id": "99_nonexistent"}), &ctx).await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().contains("not found in any pipeline stage"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn tool_delete_story_deletes_file_from_backlog() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let backlog = tmp.path().join(".story_kit/work/1_backlog");
|
||||||
|
fs::create_dir_all(&backlog).unwrap();
|
||||||
|
let story_file = backlog.join("10_story_cleanup.md");
|
||||||
|
fs::write(&story_file, "---\nname: Cleanup\n---\n").unwrap();
|
||||||
|
|
||||||
|
let ctx = test_ctx(tmp.path());
|
||||||
|
let result = tool_delete_story(&json!({"story_id": "10_story_cleanup"}), &ctx).await;
|
||||||
|
assert!(result.is_ok(), "expected ok: {result:?}");
|
||||||
|
assert!(!story_file.exists(), "story file should be deleted");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn tool_delete_story_deletes_file_from_current() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let current = tmp.path().join(".story_kit/work/2_current");
|
||||||
|
fs::create_dir_all(¤t).unwrap();
|
||||||
|
let story_file = current.join("11_story_active.md");
|
||||||
|
fs::write(&story_file, "---\nname: Active\n---\n").unwrap();
|
||||||
|
|
||||||
|
let ctx = test_ctx(tmp.path());
|
||||||
|
let result = tool_delete_story(&json!({"story_id": "11_story_active"}), &ctx).await;
|
||||||
|
assert!(result.is_ok(), "expected ok: {result:?}");
|
||||||
|
assert!(!story_file.exists(), "story file should be deleted");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tool_accept_story_missing_story_id() {
|
fn tool_accept_story_missing_story_id() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
Reference in New Issue
Block a user