From 91534b4a5937672c40f1edb33b976f007a946145 Mon Sep 17 00:00:00 2001 From: Dave Date: Fri, 20 Feb 2026 14:11:53 +0000 Subject: [PATCH] Accept story 38: Auto-Open Project on Server Startup Server detects .story_kit/ in cwd or parent directories at startup and automatically opens the project. MCP tools work immediately without manual project-open step. Falls back to cwd when no .story_kit/ found. Co-Authored-By: Claude Opus 4.6 --- .../38_auto_open_project_on_server_startup.md | 2 +- frontend/src/components/AgentPanel.test.tsx | 10 +-- frontend/src/components/AgentPanel.tsx | 32 ++++---- frontend/src/components/Chat.test.tsx | 11 ++- server/src/main.rs | 82 +++++++++++++++++-- 5 files changed, 106 insertions(+), 31 deletions(-) rename .story_kit/stories/{current => archived}/38_auto_open_project_on_server_startup.md (97%) diff --git a/.story_kit/stories/current/38_auto_open_project_on_server_startup.md b/.story_kit/stories/archived/38_auto_open_project_on_server_startup.md similarity index 97% rename from .story_kit/stories/current/38_auto_open_project_on_server_startup.md rename to .story_kit/stories/archived/38_auto_open_project_on_server_startup.md index 8c4d079..9612b06 100644 --- a/.story_kit/stories/current/38_auto_open_project_on_server_startup.md +++ b/.story_kit/stories/archived/38_auto_open_project_on_server_startup.md @@ -1,6 +1,6 @@ --- name: Auto-Open Project on Server Startup -test_plan: pending +test_plan: approved --- # Story 38: Auto-Open Project on Server Startup diff --git a/frontend/src/components/AgentPanel.test.tsx b/frontend/src/components/AgentPanel.test.tsx index 04cc1fd..b2e6864 100644 --- a/frontend/src/components/AgentPanel.test.tsx +++ b/frontend/src/components/AgentPanel.test.tsx @@ -1,7 +1,7 @@ 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 type { AgentConfigInfo, AgentInfo } from "../api/agents"; import { agentsApi } from "../api/agents"; vi.mock("../api/agents", () => { @@ -147,9 +147,7 @@ describe("AgentPanel diff command", () => { await userEvent.click(expandButton); expect( - await screen.findByText( - 'cd "/tmp/wt" && git difftool develop...HEAD', - ), + await screen.findByText('cd "/tmp/wt" && git difftool develop...HEAD'), ).toBeInTheDocument(); }); @@ -178,9 +176,7 @@ describe("AgentPanel diff command", () => { await userEvent.click(expandButton); expect( - await screen.findByText( - 'cd "/tmp/wt" && git difftool master...HEAD', - ), + 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 f99647b..8b951d2 100644 --- a/frontend/src/components/AgentPanel.tsx +++ b/frontend/src/components/AgentPanel.tsx @@ -108,7 +108,10 @@ function agentKey(storyId: string, agentName: string): string { function DiffCommand({ worktreePath, baseBranch, -}: { worktreePath: string; baseBranch: string }) { +}: { + worktreePath: string; + baseBranch: string; +}) { const [copied, setCopied] = useState(false); const command = `cd "${worktreePath}" && git difftool ${baseBranch}...HEAD`; @@ -652,19 +655,20 @@ export function AgentPanel({ stories }: AgentPanelProps) { {/* Empty state when expanded with no agents */} - {expandedKey === story.story_id && storyAgentEntries.length === 0 && ( -
- No agents started. Use the Run button to start an agent. -
- )} + {expandedKey === story.story_id && + storyAgentEntries.length === 0 && ( +
+ No agents started. Use the Run button to start an agent. +
+ )} {/* Expanded detail per agent */} {storyAgentEntries.map(([key, a]) => { diff --git a/frontend/src/components/Chat.test.tsx b/frontend/src/components/Chat.test.tsx index 5144678..6493e59 100644 --- a/frontend/src/components/Chat.test.tsx +++ b/frontend/src/components/Chat.test.tsx @@ -487,10 +487,13 @@ describe("Chat review panel", () => { render(); expect(await screen.findByText("Upcoming Stories")).toBeInTheDocument(); - expect( - await screen.findByText("View Upcoming Stories"), - ).toBeInTheDocument(); - expect(await screen.findByText("32_worktree")).toBeInTheDocument(); + // Both AgentPanel and ReviewPanel display story names, so multiple elements are expected + const storyNameElements = await screen.findAllByText( + "View Upcoming Stories", + ); + expect(storyNameElements.length).toBeGreaterThan(0); + const worktreeElements = await screen.findAllByText("32_worktree"); + expect(worktreeElements.length).toBeGreaterThan(0); }); it("collect coverage button triggers collection and refreshes gate", async () => { diff --git a/server/src/main.rs b/server/src/main.rs index b5e4570..116cb79 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -41,14 +41,49 @@ fn remove_port_file(path: &Path) { let _ = std::fs::remove_file(path); } +/// Walk from `start` up through parent directories, returning the first +/// directory that contains a `.story_kit/` subdirectory, or `None`. +fn find_story_kit_root(start: &Path) -> Option { + let mut current = start.to_path_buf(); + loop { + if current.join(".story_kit").is_dir() { + return Some(current); + } + if !current.pop() { + return None; + } + } +} + #[tokio::main] async fn main() -> Result<(), std::io::Error> { let app_state = Arc::new(SessionState::default()); let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); - *app_state.project_root.lock().unwrap() = Some(cwd.clone()); let store = Arc::new( JsonFileStore::from_path(PathBuf::from("store.json")).map_err(std::io::Error::other)?, ); + + // Auto-detect a .story_kit/ project in cwd or parent directories. + if let Some(project_root) = find_story_kit_root(&cwd) { + io::fs::open_project( + project_root.to_string_lossy().to_string(), + &app_state, + store.as_ref(), + ) + .await + .unwrap_or_else(|e| { + eprintln!("Warning: failed to auto-open project at {project_root:?}: {e}"); + project_root.to_string_lossy().to_string() + }); + + // Validate agent config for the detected project root. + config::ProjectConfig::load(&project_root) + .unwrap_or_else(|e| panic!("Invalid project.toml: {e}")); + } else { + // No .story_kit/ found — fall back to cwd so existing behaviour is preserved. + *app_state.project_root.lock().unwrap() = Some(cwd.clone()); + } + let workflow = Arc::new(std::sync::Mutex::new(WorkflowState::default())); let port = resolve_port(); let agents = Arc::new(AgentPool::new(port)); @@ -70,10 +105,6 @@ async fn main() -> Result<(), std::io::Error> { println!("\x1b[96;1mFrontend:\x1b[0m \x1b[94mhttp://{addr}\x1b[0m"); println!("\x1b[92;1mOpenAPI Docs:\x1b[0m \x1b[94mhttp://{addr}/docs\x1b[0m"); - // Validate agent config at startup — panic on invalid project.toml. - config::ProjectConfig::load(&cwd) - .unwrap_or_else(|e| panic!("Invalid project.toml: {e}")); - let port_file = write_port_file(&cwd, port); let result = Server::new(TcpListener::bind(&addr)).run(app).await; @@ -136,4 +167,45 @@ name = "coder" remove_port_file(&path); assert!(!path.exists()); } + + #[test] + fn find_story_kit_root_returns_cwd_when_story_kit_in_cwd() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(tmp.path().join(".story_kit")).unwrap(); + + let result = find_story_kit_root(tmp.path()); + assert_eq!(result, Some(tmp.path().to_path_buf())); + } + + #[test] + fn find_story_kit_root_returns_parent_when_story_kit_in_parent() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(tmp.path().join(".story_kit")).unwrap(); + let child = tmp.path().join("subdir").join("nested"); + std::fs::create_dir_all(&child).unwrap(); + + let result = find_story_kit_root(&child); + assert_eq!(result, Some(tmp.path().to_path_buf())); + } + + #[test] + fn find_story_kit_root_returns_none_when_no_story_kit() { + let tmp = tempfile::tempdir().unwrap(); + // No .story_kit/ created + + let result = find_story_kit_root(tmp.path()); + assert_eq!(result, None); + } + + #[test] + fn find_story_kit_root_prefers_nearest_ancestor() { + // If both cwd and a parent have .story_kit/, return cwd (nearest). + let tmp = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(tmp.path().join(".story_kit")).unwrap(); + let child = tmp.path().join("inner"); + std::fs::create_dir_all(child.join(".story_kit")).unwrap(); + + let result = find_story_kit_root(&child); + assert_eq!(result, Some(child)); + } }