From ef0d95ffb5df65a0c92f7a10e96103a3b086964b Mon Sep 17 00:00:00 2001 From: yu-sugimoto Date: Sat, 7 Mar 2026 15:13:26 +0900 Subject: [PATCH 1/6] =?UTF-8?q?fix:=20=E3=83=AD=E3=82=B0=E5=87=BA=E5=8A=9B?= =?UTF-8?q?=E3=81=AE=E3=83=95=E3=82=A1=E3=82=A4=E3=83=AB=E3=83=91=E3=82=B9?= =?UTF-8?q?=E3=82=92=E7=B0=A1=E7=95=A5=E5=8C=96=E3=81=97=E3=80=81=E3=83=97?= =?UTF-8?q?=E3=83=AD=E3=83=B3=E3=83=97=E3=83=88=E3=83=A1=E3=83=83=E3=82=BB?= =?UTF-8?q?=E3=83=BC=E3=82=B8=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker/worker-analyze.py | 10 ++++++---- docker/worker-createpr.py | 2 +- docker/worker-implement.py | 14 ++++++++------ 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/docker/worker-analyze.py b/docker/worker-analyze.py index e4ae5ad..f97dc4d 100644 --- a/docker/worker-analyze.py +++ b/docker/worker-analyze.py @@ -62,11 +62,13 @@ def _emit_tool_detail(phase: str, tool_name: str, raw_input: str) -> None: return if tool_name == "Read": - emit_log(phase, f"Reading: {params.get('file_path', '?')}") + path = params.get("file_path", "?").replace("/workspace/repo/", "") + emit_log(phase, f"Reading: {path}") elif tool_name in ("Write", "Edit"): - emit_log(phase, f"Editing: {params.get('file_path', '?')}") + path = params.get("file_path", "?").replace("/workspace/repo/", "") + emit_log(phase, f"Editing: {path}") elif tool_name == "Bash": - cmd = params.get("command", "?") + cmd = params.get("command", "?").replace("/workspace/repo/", "").replace("/workspace/repo", ".") emit_log(phase, f"Running: {cmd[:100]}") @@ -177,7 +179,7 @@ async def launch_and_screenshot(repo_dir: str, screenshot_output: str, device_ty async with ClaudeSDKClient(options=options) as client: # Phase 1: install deps + start dev server emit_log("launching", "Launching project") - await client.query(f"""You need to launch the web application at {repo_dir}. + await client.query("""You need to launch the web application. Follow these steps: 1. Investigate how to start the dev server (check package.json scripts, README, etc.) diff --git a/docker/worker-createpr.py b/docker/worker-createpr.py index 954b6c0..a506ce5 100644 --- a/docker/worker-createpr.py +++ b/docker/worker-createpr.py @@ -59,7 +59,7 @@ async def push_and_create_pr( """Use Claude Agent SDK to push the branch and create a PR.""" prompt = f"""You are creating a GitHub Pull Request for UI design changes. -The repository is at {repo_dir}. You are on branch "{branch_name}". +You are on branch "{branch_name}". The base branch is "{base_branch}". Here is a summary of the changes that were applied (from the diff): diff --git a/docker/worker-implement.py b/docker/worker-implement.py index d5633e0..f5b3d4f 100644 --- a/docker/worker-implement.py +++ b/docker/worker-implement.py @@ -62,11 +62,13 @@ def _emit_tool_detail(phase: str, tool_name: str, raw_input: str) -> None: return if tool_name == "Read": - emit_log(phase, f"Reading: {params.get('file_path', '?')}") + path = params.get("file_path", "?").replace("/workspace/repo/", "") + emit_log(phase, f"Reading: {path}") elif tool_name in ("Write", "Edit"): - emit_log(phase, f"Editing: {params.get('file_path', '?')}") + path = params.get("file_path", "?").replace("/workspace/repo/", "") + emit_log(phase, f"Editing: {path}") elif tool_name == "Bash": - cmd = params.get("command", "?") + cmd = params.get("command", "?").replace("/workspace/repo/", "").replace("/workspace/repo", ".") emit_log(phase, f"Running: {cmd[:100]}") @@ -125,7 +127,7 @@ def s3_upload_text(s3, bucket, key, text, content_type="text/plain"): async def implement_changes(repo_dir: str, proposal_plan: str) -> None: """Use Claude Agent SDK to implement the design proposal.""" - prompt = f"""You are implementing UI changes to a web application located at {repo_dir}. + prompt = f"""You are implementing UI changes to a web application. Here is the specific design proposal to implement: @@ -240,7 +242,7 @@ async def fix_with_claude(repo_dir: str, error_message: str, device_type: str = device = "playwright_mobile" if device_type == "mobile" else "playwright" async with ClaudeSDKClient(options=options) as client: - await client.query(f"""The dev server failed to start in the project at {repo_dir}. + await client.query(f"""The dev server failed to start. Here is the error output: @@ -283,7 +285,7 @@ async def launch_and_screenshot(repo_dir: str, screenshot_output: str, device_ty async with ClaudeSDKClient(options=options) as client: # Phase 1: install deps + start dev server emit_log("launching", "Launching project") - await client.query(f"""You need to launch the web application at {repo_dir}. + await client.query("""You need to launch the web application. Follow these steps: 1. Investigate how to start the dev server (check package.json scripts, README, etc.) From 66edc09d322973f8ba7d3057fd18059272d07901 Mon Sep 17 00:00:00 2001 From: yu-sugimoto Date: Sat, 7 Mar 2026 15:38:23 +0900 Subject: [PATCH 2/6] =?UTF-8?q?chore:=20=E4=B8=8D=E8=A6=81=E3=81=AA?= =?UTF-8?q?=E3=83=AD=E3=82=B0=E5=87=BA=E5=8A=9B=E3=82=92=E5=89=8A=E9=99=A4?= =?UTF-8?q?=E3=81=97=E3=80=81=E3=82=B9=E3=82=AF=E3=83=AA=E3=83=97=E3=83=88?= =?UTF-8?q?=E3=81=AE=E5=8F=AF=E8=AA=AD=E6=80=A7=E3=82=92=E5=90=91=E4=B8=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker/worker-entrypoint.sh | 5 ----- 1 file changed, 5 deletions(-) diff --git a/docker/worker-entrypoint.sh b/docker/worker-entrypoint.sh index b8eac72..5cc6311 100644 --- a/docker/worker-entrypoint.sh +++ b/docker/worker-entrypoint.sh @@ -1,11 +1,6 @@ #!/bin/bash set -e -echo "=== Worker Starting ===" -echo "Mode: ${WORKER_MODE}" -echo "Session: ${SESSION_ID} Iter: ${ITERATION_INDEX}" -echo "Repository: ${REPO_URL}" - if [ -z "$WORKER_MODE" ]; then echo "Error: WORKER_MODE is required (analyze, implement, or createpr)" exit 1 From 33951f71140a0237b6ae5810ff26fadf9c4584d2 Mon Sep 17 00:00:00 2001 From: yu-sugimoto Date: Sat, 7 Mar 2026 15:47:03 +0900 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20=E3=83=AD=E3=82=B0=E3=83=A1=E3=83=83?= =?UTF-8?q?=E3=82=BB=E3=83=BC=E3=82=B8=E3=81=AE=E3=83=95=E3=82=A9=E3=83=BC?= =?UTF-8?q?=E3=83=9E=E3=83=83=E3=83=88=E3=82=92=E6=94=B9=E5=96=84=E3=81=97?= =?UTF-8?q?=E3=80=81=E5=8F=AF=E8=AA=AD=E6=80=A7=E3=82=92=E5=90=91=E4=B8=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/infra/k8s_client.py | 2 +- backend/app/infra/log_stream_client.py | 11 ++--------- docker/worker-analyze.py | 16 +++++++--------- docker/worker-implement.py | 15 ++++++--------- 4 files changed, 16 insertions(+), 28 deletions(-) diff --git a/backend/app/infra/k8s_client.py b/backend/app/infra/k8s_client.py index 1dde7e5..29fe2d8 100644 --- a/backend/app/infra/k8s_client.py +++ b/backend/app/infra/k8s_client.py @@ -494,7 +494,7 @@ async def stream_pod_logs( break # Pod exists but not yet running if phase == "Pending" and not sandbox_emitted: - yield '@@LOG@@{"phase":"sandbox","message":"Creating sandbox"}' + yield '@@LOG@@{"phase":"sandbox","message":"Creating: sandbox"}' sandbox_emitted = True except ApiException as e: logger.warning("Error listing pods for job %s: %s", job_name, e) diff --git a/backend/app/infra/log_stream_client.py b/backend/app/infra/log_stream_client.py index fe5d661..8da49f1 100644 --- a/backend/app/infra/log_stream_client.py +++ b/backend/app/infra/log_stream_client.py @@ -103,15 +103,8 @@ async def _stream_single_job(job_info: JobInfo) -> None: } await queue.put(event) else: - # Non-structured log line - emit as raw - await queue.put( - { - "job_type": job_info.job_type, - "proposal_index": job_info.proposal_index, - "phase": "running", - "message": line, - } - ) + # Non-structured log lines (subprocess stdout, etc.) are internal — skip + pass except asyncio.CancelledError: return except Exception as e: diff --git a/docker/worker-analyze.py b/docker/worker-analyze.py index f97dc4d..4b4ac2f 100644 --- a/docker/worker-analyze.py +++ b/docker/worker-analyze.py @@ -156,7 +156,7 @@ async def _process_messages(client: ClaudeSDKClient, phase: str) -> None: text = block.text.strip() if text.startswith("Browser") or text.startswith("Searching"): continue - emit_log(phase, text[:200], detail=block.text) + emit_log(phase, f"Thinking: {text[:200]}", detail=block.text) elif isinstance(block, ToolUseBlock): _emit_tool_detail(phase, block.name, json.dumps(block.input)) @@ -178,7 +178,7 @@ async def launch_and_screenshot(repo_dir: str, screenshot_output: str, device_ty async with ClaudeSDKClient(options=options) as client: # Phase 1: install deps + start dev server - emit_log("launching", "Launching project") + emit_log("launching", "Launching: project") await client.query("""You need to launch the web application. Follow these steps: @@ -195,7 +195,7 @@ async def launch_and_screenshot(repo_dir: str, screenshot_output: str, device_ty await _process_messages(client, "launching") # Phase 2: take screenshot (same session, so dev server URL is remembered) - emit_log("screenshot", "Taking before screenshot") + emit_log("screenshot", "Taking: before screenshot") device = "playwright_mobile" if device_type == "mobile" else "playwright" await client.query(f"""Now take a screenshot of the running application. @@ -375,7 +375,7 @@ async def generate_proposals( text = block.text.strip() if text.startswith("Browser") or text.startswith("Searching"): continue - emit_log("analyzing", text[:200], detail=block.text) + emit_log("analyzing", f"Thinking: {text[:200]}", detail=block.text) collected_text.append(block.text) elif isinstance(block, ToolUseBlock): _emit_tool_detail("analyzing", block.name, json.dumps(block.input)) @@ -402,7 +402,7 @@ async def main() -> None: s3_prefix = f"sessions/{session_id}/iterations/{iteration_index}" # Step 1: Clone repository - emit_log("cloning", "Cloning repository") + emit_log("cloning", "Cloning: repository") subprocess.run( ["git", "clone", "--depth", "1", "--branch", branch, repo_url, repo_dir], check=True, @@ -450,7 +450,7 @@ async def main() -> None: ) # Step 2: Code analysis + device detection + proposal generation (no browser needed) - emit_log("analyzing", "Generating design proposals") + emit_log("analyzing", "Thinking: Generating design proposals") try: result = await generate_proposals(repo_dir, instruction, num_proposals) except Exception as e: @@ -463,15 +463,13 @@ async def main() -> None: device_type = device_type.lower() if device_type not in ("desktop", "mobile"): device_type = "desktop" - emit_log("analyzing", f"Generated {len(proposals)} proposals (device: {device_type})") + emit_log("analyzing", f"Thinking: Generated {len(proposals)} proposals (device: {device_type})") # Step 3: Launch dev server + take before screenshot (with determined device_type) before_path = f"{tmp_dir}/before.png" await launch_and_screenshot(repo_dir, before_path, device_type=device_type) # Step 4: Upload results to S3 - emit_log("uploading", "Uploading results to S3") - # Upload before screenshot if Path(before_path).exists(): s3_upload_file( diff --git a/docker/worker-implement.py b/docker/worker-implement.py index f5b3d4f..b1c7483 100644 --- a/docker/worker-implement.py +++ b/docker/worker-implement.py @@ -177,7 +177,7 @@ async def implement_changes(repo_dir: str, proposal_plan: str) -> None: text = block.text.strip() if text.startswith("Browser") or text.startswith("Searching"): continue - emit_log("implementing", text[:200], detail=block.text) + emit_log("implementing", f"Thinking: {text[:200]}", detail=block.text) elif isinstance(block, ToolUseBlock): _emit_tool_detail("implementing", block.name, json.dumps(block.input)) @@ -213,7 +213,7 @@ async def _process_messages(client: ClaudeSDKClient, phase: str) -> None: text = block.text.strip() if text.startswith("Browser") or text.startswith("Searching"): continue - emit_log(phase, text[:200], detail=block.text) + emit_log(phase, f"Thinking: {text[:200]}", detail=block.text) elif isinstance(block, ToolUseBlock): _emit_tool_detail(phase, block.name, json.dumps(block.input)) @@ -284,7 +284,7 @@ async def launch_and_screenshot(repo_dir: str, screenshot_output: str, device_ty async with ClaudeSDKClient(options=options) as client: # Phase 1: install deps + start dev server - emit_log("launching", "Launching project") + emit_log("launching", "Launching: project") await client.query("""You need to launch the web application. Follow these steps: @@ -301,7 +301,7 @@ async def launch_and_screenshot(repo_dir: str, screenshot_output: str, device_ty await _process_messages(client, "launching") # Phase 2: take screenshot (same session, so dev server URL is remembered) - emit_log("screenshot", "Taking after screenshot") + emit_log("screenshot", "Taking: after screenshot") device = "playwright_mobile" if device_type == "mobile" else "playwright" await client.query(f"""Now take a screenshot of the running application. @@ -383,7 +383,7 @@ async def main() -> None: sys.exit(1) # Step 2: Clone repository - emit_log("cloning", "Cloning repository") + emit_log("cloning", "Cloning: repository") subprocess.run( ["git", "clone", "--depth", "1", "--branch", branch, repo_url, repo_dir], check=True, @@ -436,7 +436,7 @@ async def main() -> None: emit_log("implementing", f"Created local branch: {branch_name}") screenshot_path = f"{tmp_dir}/after.png" - emit_log("implementing", "Implementing design proposal") + emit_log("implementing", "Implementing: design proposal") try: await implement_changes(repo_dir, proposal_plan) except Exception as e: @@ -471,7 +471,6 @@ async def main() -> None: emit_log("implementing", "WARNING: Could not launch dev server, screenshot may be missing") # Step 4: Generate cumulative patch (squash everything since base_branch) - emit_log("uploading", "Generating cumulative patch") # Parse proposal plan to get title for commit message try: plan_data = json.loads(proposal_plan) @@ -503,8 +502,6 @@ async def main() -> None: f.write(patch_result.stdout) # Step 5: Upload results to S3 - emit_log("uploading", "Uploading results to S3") - # Upload after screenshot if Path(screenshot_path).exists(): s3_upload_file( From 346bca910a09ec4e1e5e0ebea761debb1838baef Mon Sep 17 00:00:00 2001 From: yu-sugimoto Date: Sat, 7 Mar 2026 16:01:02 +0900 Subject: [PATCH 4/6] =?UTF-8?q?feat:=20=E3=82=BB=E3=83=83=E3=82=B7?= =?UTF-8?q?=E3=83=A7=E3=83=B3=E3=83=9D=E3=83=BC=E3=83=AA=E3=83=B3=E3=82=B0?= =?UTF-8?q?=E3=81=AE=E5=86=8D=E8=B5=B7=E5=8B=95=E3=83=AD=E3=82=B8=E3=83=83?= =?UTF-8?q?=E3=82=AF=E3=82=92=E8=BF=BD=E5=8A=A0=E3=81=97=E3=80=81=E3=82=BF?= =?UTF-8?q?=E3=83=BC=E3=83=9F=E3=83=8A=E3=83=AB=E7=8A=B6=E6=85=8B=E3=81=A7?= =?UTF-8?q?=E3=81=AE=E3=83=9D=E3=83=BC=E3=83=AA=E3=83=B3=E3=82=B0=E5=81=9C?= =?UTF-8?q?=E6=AD=A2=E3=82=92=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/hooks/useSessionPolling.ts | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/frontend/src/hooks/useSessionPolling.ts b/frontend/src/hooks/useSessionPolling.ts index 33c07c9..bb343d7 100644 --- a/frontend/src/hooks/useSessionPolling.ts +++ b/frontend/src/hooks/useSessionPolling.ts @@ -26,6 +26,23 @@ export function useSessionPolling(sessionId: string | null, intervalMs: number = } }, []) + const restartPolling = useCallback(() => { + if (timerRef.current !== null || !sessionId) return + const poll = async () => { + try { + const data = await getSession(sessionId) + setSession(data) + if (isTerminal(data)) { + stopPolling() + } + } catch (e) { + setError((e as Error).message) + } + } + poll() + timerRef.current = window.setInterval(poll, intervalMs) + }, [sessionId, intervalMs, stopPolling]) + useEffect(() => { if (!sessionId) { return @@ -63,10 +80,13 @@ export function useSessionPolling(sessionId: string | null, intervalMs: number = try { const data = await getSession(sessionId) setSession(data) + if (!isTerminal(data) && timerRef.current === null) { + restartPolling() + } } catch (e) { setError((e as Error).message) } - }, [sessionId]) + }, [sessionId, restartPolling]) return { session, error, isLoading, refetch } } From 52b1b6cd28001e54d3eebfab1f8807ba81b3bc78 Mon Sep 17 00:00:00 2001 From: yu-sugimoto Date: Sat, 7 Mar 2026 16:30:01 +0900 Subject: [PATCH 5/6] =?UTF-8?q?feat:=20=E3=82=A4=E3=83=B3=E3=82=B9?= =?UTF-8?q?=E3=83=88=E3=83=A9=E3=82=AF=E3=82=B7=E3=83=A7=E3=83=B3=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0=E3=81=97=E3=80=81=E3=82=BB=E3=83=83=E3=82=B7?= =?UTF-8?q?=E3=83=A7=E3=83=B3=E5=AE=9F=E8=A3=85=E3=81=A8=E5=88=86=E6=9E=90?= =?UTF-8?q?=E3=81=AE=E3=83=AF=E3=83=BC=E3=82=AF=E3=83=95=E3=83=AD=E3=83=BC?= =?UTF-8?q?=E3=82=92=E6=94=B9=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/usecase/session_usecase.py | 3 ++ .../workflow/session_implementation_graph.py | 1 + backend/app/workflow/state.py | 1 + docker/worker-analyze.py | 28 +++++++++++++---- docker/worker-implement.py | 31 +++++++++++++++---- frontend/.prettierignore | 1 + 6 files changed, 53 insertions(+), 12 deletions(-) diff --git a/backend/app/usecase/session_usecase.py b/backend/app/usecase/session_usecase.py index 0e4862b..3dbc940 100644 --- a/backend/app/usecase/session_usecase.py +++ b/backend/app/usecase/session_usecase.py @@ -360,6 +360,7 @@ async def _run_session_analysis( plan_json=str(proposal.plan), selected_proposal_index=selected_proposal_index, device_type=device_type, + instruction=instruction, ) ) else: @@ -400,6 +401,7 @@ async def _run_session_implementation( plan_json: str, selected_proposal_index: int | None, device_type: str = "desktop", + instruction: str = "", ) -> None: """Background: run one session-based implementation workflow.""" db = SessionLocal() @@ -424,6 +426,7 @@ async def _run_session_implementation( "proposal_index": proposal_index, "proposal_plan": plan_json, "device_type": device_type, + "instruction": instruction, "selected_proposal_index": selected_proposal_index, "k8s_job_name": None, "status": "pending", diff --git a/backend/app/workflow/session_implementation_graph.py b/backend/app/workflow/session_implementation_graph.py index 3597784..cf234af 100644 --- a/backend/app/workflow/session_implementation_graph.py +++ b/backend/app/workflow/session_implementation_graph.py @@ -22,6 +22,7 @@ async def create_k8s_job(state: SessionImplementationState) -> dict: { "plan": state["proposal_plan"], "device_type": state.get("device_type", "desktop"), + "instruction": state.get("instruction", ""), }, ensure_ascii=False, ) diff --git a/backend/app/workflow/state.py b/backend/app/workflow/state.py index a2ad5ae..dedb2e9 100644 --- a/backend/app/workflow/state.py +++ b/backend/app/workflow/state.py @@ -37,6 +37,7 @@ class SessionImplementationState(TypedDict): proposal_index: int proposal_plan: str device_type: str + instruction: str # Patch context (for iter > 0) selected_proposal_index: int | None diff --git a/docker/worker-analyze.py b/docker/worker-analyze.py index 4b4ac2f..8d8b778 100644 --- a/docker/worker-analyze.py +++ b/docker/worker-analyze.py @@ -161,7 +161,7 @@ async def _process_messages(client: ClaudeSDKClient, phase: str) -> None: _emit_tool_detail(phase, block.name, json.dumps(block.input)) -async def launch_and_screenshot(repo_dir: str, screenshot_output: str, device_type: str = "desktop") -> None: +async def launch_and_screenshot(repo_dir: str, screenshot_output: str, device_type: str = "desktop", instruction: str = "") -> None: """Launch dev server and take screenshot in a single session. Uses ClaudeSDKClient to keep the same conversation context across @@ -197,19 +197,35 @@ async def launch_and_screenshot(repo_dir: str, screenshot_output: str, device_ty # Phase 2: take screenshot (same session, so dev server URL is remembered) emit_log("screenshot", "Taking: before screenshot") device = "playwright_mobile" if device_type == "mobile" else "playwright" + + instruction_block = "" + if instruction: + instruction_block = f""" +## User's change request +\"\"\"{instruction}\"\"\" + +Based on this request, you MUST determine which page and area to screenshot: +1. Read the project's routing configuration (e.g. React Router, Next.js pages, Vue Router) to find the relevant page/route +2. Navigate to the page that is most relevant to the user's request (NOT necessarily the root `/`) +3. If the change target is below the fold (e.g. footer, bottom section), use mcp__{device}__browser_evaluate to scroll the element into view before taking the screenshot +""" + await client.query(f"""Now take a screenshot of the running application. You already know the dev server URL from the previous step. Use the **{device}** browser tools (mcp__{device}__*) to take the screenshot. - +{instruction_block} Steps: -1. Use mcp__{device}__browser_navigate to open the dev server URL -2. Use mcp__{device}__browser_wait_for to wait for the page to fully load -3. Use mcp__{device}__browser_take_screenshot to capture the viewport and save to {screenshot_output} +1. Read the project's routing configuration to identify the correct page for the user's request +2. Use mcp__{device}__browser_navigate to open the appropriate page URL +3. Use mcp__{device}__browser_wait_for to wait for the page to fully load +4. If the target area is not visible in the viewport, use mcp__{device}__browser_evaluate to scroll it into view +5. Use mcp__{device}__browser_take_screenshot to capture the viewport and save to {screenshot_output} IMPORTANT: - Do NOT use fullPage. Capture only what the user actually sees in the viewport. +- Navigate to the page most relevant to the user's request, not just the root URL. - If a specific element needs to be visible, use mcp__{device}__browser_evaluate to scrollIntoView first. """) await _process_messages(client, "screenshot") @@ -467,7 +483,7 @@ async def main() -> None: # Step 3: Launch dev server + take before screenshot (with determined device_type) before_path = f"{tmp_dir}/before.png" - await launch_and_screenshot(repo_dir, before_path, device_type=device_type) + await launch_and_screenshot(repo_dir, before_path, device_type=device_type, instruction=instruction) # Step 4: Upload results to S3 # Upload before screenshot diff --git a/docker/worker-implement.py b/docker/worker-implement.py index b1c7483..34bdf62 100644 --- a/docker/worker-implement.py +++ b/docker/worker-implement.py @@ -267,7 +267,7 @@ async def fix_with_claude(repo_dir: str, error_message: str, device_type: str = await _process_messages(client, "implementing") -async def launch_and_screenshot(repo_dir: str, screenshot_output: str, device_type: str = "desktop") -> None: +async def launch_and_screenshot(repo_dir: str, screenshot_output: str, device_type: str = "desktop", instruction: str = "") -> None: """Launch dev server and take screenshot in a single session. Uses ClaudeSDKClient to keep the same conversation context across @@ -303,19 +303,35 @@ async def launch_and_screenshot(repo_dir: str, screenshot_output: str, device_ty # Phase 2: take screenshot (same session, so dev server URL is remembered) emit_log("screenshot", "Taking: after screenshot") device = "playwright_mobile" if device_type == "mobile" else "playwright" + + instruction_block = "" + if instruction: + instruction_block = f""" +## User's change request +\"\"\"{instruction}\"\"\" + +Based on this request, you MUST determine which page and area to screenshot: +1. Read the project's routing configuration (e.g. React Router, Next.js pages, Vue Router) to find the relevant page/route +2. Navigate to the page that is most relevant to the user's request (NOT necessarily the root `/`) +3. If the change target is below the fold (e.g. footer, bottom section), use mcp__{device}__browser_evaluate to scroll the element into view before taking the screenshot +""" + await client.query(f"""Now take a screenshot of the running application. You already know the dev server URL from the previous step. Use the **{device}** browser tools (mcp__{device}__*) to take the screenshot. - +{instruction_block} Steps: -1. Use mcp__{device}__browser_navigate to open the dev server URL -2. Use mcp__{device}__browser_wait_for to wait for the page to fully load -3. Use mcp__{device}__browser_take_screenshot to capture the viewport and save to {screenshot_output} +1. Read the project's routing configuration to identify the correct page for the user's request +2. Use mcp__{device}__browser_navigate to open the appropriate page URL +3. Use mcp__{device}__browser_wait_for to wait for the page to fully load +4. If the target area is not visible in the viewport, use mcp__{device}__browser_evaluate to scroll it into view +5. Use mcp__{device}__browser_take_screenshot to capture the viewport and save to {screenshot_output} IMPORTANT: - Do NOT use fullPage. Capture only what the user actually sees in the viewport. +- Navigate to the page most relevant to the user's request, not just the root URL. - If a specific element needs to be visible, use mcp__{device}__browser_evaluate to scrollIntoView first. """) await _process_messages(client, "screenshot") @@ -359,15 +375,18 @@ async def main() -> None: plan_data = json.loads(raw) proposal_plan = plan_data.get("plan", raw) device_type = plan_data.get("device_type", "desktop") + instruction = plan_data.get("instruction", "") if isinstance(device_type, str): device_type = device_type.lower() if device_type not in ("desktop", "mobile"): device_type = "desktop" except json.JSONDecodeError: proposal_plan = raw # backward compat + instruction = "" else: # Fallback to env var (for backward compat during transition) proposal_plan = os.environ.get("PROPOSAL_PLAN", "") + instruction = "" emit_log("setup", f"Using device type: {device_type}") # Log loaded proposal details @@ -447,7 +466,7 @@ async def main() -> None: last_error = None for attempt in range(max_retries + 1): try: - await launch_and_screenshot(repo_dir, screenshot_path, device_type=device_type) + await launch_and_screenshot(repo_dir, screenshot_path, device_type=device_type, instruction=instruction) last_error = None break # success except Exception as e: diff --git a/frontend/.prettierignore b/frontend/.prettierignore index 91a3983..b975e13 100644 --- a/frontend/.prettierignore +++ b/frontend/.prettierignore @@ -1,3 +1,4 @@ dist node_modules package-lock.json +.pytest_cache From 3dcff210a7415b8f3da41b9fa84418bdf26f4fcf Mon Sep 17 00:00:00 2001 From: yu-sugimoto Date: Sat, 7 Mar 2026 16:55:11 +0900 Subject: [PATCH 6/6] =?UTF-8?q?chore:=20=E3=82=B3=E3=83=BC=E3=83=89?= =?UTF-8?q?=E3=81=AE=E6=95=B4=E5=BD=A2=E3=81=A8=E5=8F=AF=E8=AA=AD=E6=80=A7?= =?UTF-8?q?=E3=81=AE=E5=90=91=E4=B8=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker/worker-analyze.py | 87 +++++++++++++++++++++------ docker/worker-implement.py | 120 ++++++++++++++++++++++++++++--------- 2 files changed, 161 insertions(+), 46 deletions(-) diff --git a/docker/worker-analyze.py b/docker/worker-analyze.py index 8d8b778..e86f0d4 100644 --- a/docker/worker-analyze.py +++ b/docker/worker-analyze.py @@ -14,17 +14,38 @@ import boto3 from botocore.config import Config -from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient, query, AssistantMessage, TextBlock, ToolUseBlock +from claude_agent_sdk import ( + ClaudeAgentOptions, + ClaudeSDKClient, + query, + AssistantMessage, + TextBlock, + ToolUseBlock, +) from claude_agent_sdk.types import StreamEvent PLAYWRIGHT_MCP_SERVERS = { "playwright": { "command": "npx", - "args": ["@playwright/mcp@0.0.68", "--headless", "--browser", "chromium", "--viewport-size", "1280x800"], + "args": [ + "@playwright/mcp@0.0.68", + "--headless", + "--browser", + "chromium", + "--viewport-size", + "1280x800", + ], }, "playwright_mobile": { "command": "npx", - "args": ["@playwright/mcp@0.0.68", "--headless", "--browser", "chromium", "--device", "iPhone 15"], + "args": [ + "@playwright/mcp@0.0.68", + "--headless", + "--browser", + "chromium", + "--device", + "iPhone 15", + ], }, } @@ -68,7 +89,11 @@ def _emit_tool_detail(phase: str, tool_name: str, raw_input: str) -> None: path = params.get("file_path", "?").replace("/workspace/repo/", "") emit_log(phase, f"Editing: {path}") elif tool_name == "Bash": - cmd = params.get("command", "?").replace("/workspace/repo/", "").replace("/workspace/repo", ".") + cmd = ( + params.get("command", "?") + .replace("/workspace/repo/", "") + .replace("/workspace/repo", ".") + ) emit_log(phase, f"Running: {cmd[:100]}") @@ -95,17 +120,24 @@ def s3_download(s3, bucket, key, local_path): return False -def s3_upload_file(s3, bucket, key, local_path, content_type="application/octet-stream"): +def s3_upload_file( + s3, bucket, key, local_path, content_type="application/octet-stream" +): for attempt in range(3): try: s3.upload_file( - local_path, bucket, key, + local_path, + bucket, + key, ExtraArgs={"ContentType": content_type}, ) print(f"Uploaded {key}") return except Exception as e: - print(f"S3 upload attempt {attempt + 1} failed for {key}: {e}", file=sys.stderr) + print( + f"S3 upload attempt {attempt + 1} failed for {key}: {e}", + file=sys.stderr, + ) if attempt == 2: raise @@ -114,13 +146,18 @@ def s3_upload_text(s3, bucket, key, text, content_type="text/plain"): for attempt in range(3): try: s3.put_object( - Bucket=bucket, Key=key, - Body=text.encode("utf-8"), ContentType=content_type, + Bucket=bucket, + Key=key, + Body=text.encode("utf-8"), + ContentType=content_type, ) print(f"Uploaded {key}") return except Exception as e: - print(f"S3 upload attempt {attempt + 1} failed for {key}: {e}", file=sys.stderr) + print( + f"S3 upload attempt {attempt + 1} failed for {key}: {e}", + file=sys.stderr, + ) if attempt == 2: raise @@ -161,7 +198,12 @@ async def _process_messages(client: ClaudeSDKClient, phase: str) -> None: _emit_tool_detail(phase, block.name, json.dumps(block.input)) -async def launch_and_screenshot(repo_dir: str, screenshot_output: str, device_type: str = "desktop", instruction: str = "") -> None: +async def launch_and_screenshot( + repo_dir: str, + screenshot_output: str, + device_type: str = "desktop", + instruction: str = "", +) -> None: """Launch dev server and take screenshot in a single session. Uses ClaudeSDKClient to keep the same conversation context across @@ -287,7 +329,7 @@ def _extract_proposals_json(collected_text: list[str]) -> dict: pass # Strategy 3: Extract from markdown code blocks in full text - for pattern in [r'```json\s*(.*?)```', r'```\s*(.*?)```']: + for pattern in [r"```json\s*(.*?)```", r"```\s*(.*?)```"]: m = re.search(pattern, full_text, re.DOTALL) if m: try: @@ -432,9 +474,9 @@ async def main() -> None: f"/proposals/{selected_proposal_index}/changes.diff" ) local_patch = f"{tmp_dir}/parent.diff" - emit_log("patching", f"Downloading patch from S3: {patch_key}") + emit_log("patching", "Downloading: patch from S3") if s3_download(s3, bucket, patch_key, local_patch): - emit_log("patching", "Applying cumulative patch") + emit_log("patching", "Applying: cumulative patch") result = subprocess.run( ["git", "am", "--3way", local_patch], cwd=repo_dir, @@ -458,7 +500,7 @@ async def main() -> None: cwd=repo_dir, check=True, ) - emit_log("patching", "Cumulative patch applied successfully") + emit_log("patching", "Thinking: cumulative patch applied successfully") else: print( f"WARNING: Patch not found at s3://{bucket}/{patch_key}", @@ -479,17 +521,23 @@ async def main() -> None: device_type = device_type.lower() if device_type not in ("desktop", "mobile"): device_type = "desktop" - emit_log("analyzing", f"Thinking: Generated {len(proposals)} proposals (device: {device_type})") + emit_log( + "analyzing", + f"Thinking: Generated {len(proposals)} proposals (device: {device_type})", + ) # Step 3: Launch dev server + take before screenshot (with determined device_type) before_path = f"{tmp_dir}/before.png" - await launch_and_screenshot(repo_dir, before_path, device_type=device_type, instruction=instruction) + await launch_and_screenshot( + repo_dir, before_path, device_type=device_type, instruction=instruction + ) # Step 4: Upload results to S3 # Upload before screenshot if Path(before_path).exists(): s3_upload_file( - s3, bucket, + s3, + bucket, f"{s3_prefix}/before.png", before_path, content_type="image/png", @@ -498,7 +546,8 @@ async def main() -> None: # Upload proposals.json (includes device_type) proposals_data = {"device_type": device_type, "proposals": proposals} s3_upload_text( - s3, bucket, + s3, + bucket, f"{s3_prefix}/proposals.json", json.dumps(proposals_data, ensure_ascii=False, indent=2), content_type="application/json", diff --git a/docker/worker-implement.py b/docker/worker-implement.py index 34bdf62..ff53957 100644 --- a/docker/worker-implement.py +++ b/docker/worker-implement.py @@ -14,17 +14,38 @@ import boto3 from botocore.config import Config -from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient, query, AssistantMessage, TextBlock, ToolUseBlock +from claude_agent_sdk import ( + ClaudeAgentOptions, + ClaudeSDKClient, + query, + AssistantMessage, + TextBlock, + ToolUseBlock, +) from claude_agent_sdk.types import StreamEvent PLAYWRIGHT_MCP_SERVERS = { "playwright": { "command": "npx", - "args": ["@playwright/mcp@0.0.68", "--headless", "--browser", "chromium", "--viewport-size", "1280x800"], + "args": [ + "@playwright/mcp@0.0.68", + "--headless", + "--browser", + "chromium", + "--viewport-size", + "1280x800", + ], }, "playwright_mobile": { "command": "npx", - "args": ["@playwright/mcp@0.0.68", "--headless", "--browser", "chromium", "--device", "iPhone 15"], + "args": [ + "@playwright/mcp@0.0.68", + "--headless", + "--browser", + "chromium", + "--device", + "iPhone 15", + ], }, } @@ -68,7 +89,11 @@ def _emit_tool_detail(phase: str, tool_name: str, raw_input: str) -> None: path = params.get("file_path", "?").replace("/workspace/repo/", "") emit_log(phase, f"Editing: {path}") elif tool_name == "Bash": - cmd = params.get("command", "?").replace("/workspace/repo/", "").replace("/workspace/repo", ".") + cmd = ( + params.get("command", "?") + .replace("/workspace/repo/", "") + .replace("/workspace/repo", ".") + ) emit_log(phase, f"Running: {cmd[:100]}") @@ -95,17 +120,24 @@ def s3_download(s3, bucket, key, local_path): return False -def s3_upload_file(s3, bucket, key, local_path, content_type="application/octet-stream"): +def s3_upload_file( + s3, bucket, key, local_path, content_type="application/octet-stream" +): for attempt in range(3): try: s3.upload_file( - local_path, bucket, key, + local_path, + bucket, + key, ExtraArgs={"ContentType": content_type}, ) print(f"Uploaded {key}") return except Exception as e: - print(f"S3 upload attempt {attempt + 1} failed for {key}: {e}", file=sys.stderr) + print( + f"S3 upload attempt {attempt + 1} failed for {key}: {e}", + file=sys.stderr, + ) if attempt == 2: raise @@ -114,13 +146,18 @@ def s3_upload_text(s3, bucket, key, text, content_type="text/plain"): for attempt in range(3): try: s3.put_object( - Bucket=bucket, Key=key, - Body=text.encode("utf-8"), ContentType=content_type, + Bucket=bucket, + Key=key, + Body=text.encode("utf-8"), + ContentType=content_type, ) print(f"Uploaded {key}") return except Exception as e: - print(f"S3 upload attempt {attempt + 1} failed for {key}: {e}", file=sys.stderr) + print( + f"S3 upload attempt {attempt + 1} failed for {key}: {e}", + file=sys.stderr, + ) if attempt == 2: raise @@ -177,9 +214,13 @@ async def implement_changes(repo_dir: str, proposal_plan: str) -> None: text = block.text.strip() if text.startswith("Browser") or text.startswith("Searching"): continue - emit_log("implementing", f"Thinking: {text[:200]}", detail=block.text) + emit_log( + "implementing", f"Thinking: {text[:200]}", detail=block.text + ) elif isinstance(block, ToolUseBlock): - _emit_tool_detail("implementing", block.name, json.dumps(block.input)) + _emit_tool_detail( + "implementing", block.name, json.dumps(block.input) + ) async def _process_messages(client: ClaudeSDKClient, phase: str) -> None: @@ -229,10 +270,13 @@ async def kill_dev_servers(repo_dir: str) -> None: await asyncio.sleep(2) -async def fix_with_claude(repo_dir: str, error_message: str, device_type: str = "desktop") -> None: +async def fix_with_claude( + repo_dir: str, error_message: str, device_type: str = "desktop" +) -> None: """Use Claude to diagnose and fix the dev server launch failure.""" options = ClaudeAgentOptions( - allowed_tools=["Read", "Write", "Edit", "Bash", "Glob", "Grep"] + PLAYWRIGHT_MCP_TOOLS, + allowed_tools=["Read", "Write", "Edit", "Bash", "Glob", "Grep"] + + PLAYWRIGHT_MCP_TOOLS, mcp_servers=PLAYWRIGHT_MCP_SERVERS, cwd=repo_dir, max_turns=20, @@ -267,7 +311,12 @@ async def fix_with_claude(repo_dir: str, error_message: str, device_type: str = await _process_messages(client, "implementing") -async def launch_and_screenshot(repo_dir: str, screenshot_output: str, device_type: str = "desktop", instruction: str = "") -> None: +async def launch_and_screenshot( + repo_dir: str, + screenshot_output: str, + device_type: str = "desktop", + instruction: str = "", +) -> None: """Launch dev server and take screenshot in a single session. Uses ClaudeSDKClient to keep the same conversation context across @@ -360,8 +409,7 @@ async def main() -> None: os.makedirs(tmp_dir, exist_ok=True) s3_prefix = ( - f"sessions/{session_id}/iterations/{iteration_index}" - f"/proposals/{proposal_index}" + f"sessions/{session_id}/iterations/{iteration_index}/proposals/{proposal_index}" ) # Step 1: Download proposal plan from S3 @@ -391,7 +439,11 @@ async def main() -> None: # Log loaded proposal details try: - _plan = json.loads(proposal_plan) if isinstance(proposal_plan, str) else proposal_plan + _plan = ( + json.loads(proposal_plan) + if isinstance(proposal_plan, str) + else proposal_plan + ) title = _plan.get("title", f"proposal-{proposal_index}") except (json.JSONDecodeError, AttributeError): title = f"proposal-{proposal_index}" @@ -416,9 +468,9 @@ async def main() -> None: f"/proposals/{selected_proposal_index}/changes.diff" ) local_patch = f"{tmp_dir}/parent.diff" - emit_log("patching", f"Downloading patch from S3: {patch_key}") + emit_log("patching", "Downloading: patch from S3") if s3_download(s3, bucket, patch_key, local_patch): - emit_log("patching", "Applying cumulative patch") + emit_log("patching", "Applying: cumulative patch") result = subprocess.run( ["git", "am", "--3way", local_patch], cwd=repo_dir, @@ -442,7 +494,7 @@ async def main() -> None: cwd=repo_dir, check=True, ) - emit_log("patching", "Cumulative patch applied successfully") + emit_log("patching", "Thinking: cumulative patch applied successfully") else: print( f"WARNING: Patch not found at s3://{bucket}/{patch_key}", @@ -450,7 +502,9 @@ async def main() -> None: ) # Step 3: Create local branch and implement the proposal - branch_name = f"local/session-{session_id[:8]}-iter{iteration_index}-prop{proposal_index}" + branch_name = ( + f"local/session-{session_id[:8]}-iter{iteration_index}-prop{proposal_index}" + ) subprocess.run(["git", "checkout", "-b", branch_name], cwd=repo_dir, check=True) emit_log("implementing", f"Created local branch: {branch_name}") @@ -466,7 +520,12 @@ async def main() -> None: last_error = None for attempt in range(max_retries + 1): try: - await launch_and_screenshot(repo_dir, screenshot_path, device_type=device_type, instruction=instruction) + await launch_and_screenshot( + repo_dir, + screenshot_path, + device_type=device_type, + instruction=instruction, + ) last_error = None break # success except Exception as e: @@ -487,7 +546,10 @@ async def main() -> None: ) if last_error: - emit_log("implementing", "WARNING: Could not launch dev server, screenshot may be missing") + emit_log( + "implementing", + "WARNING: Could not launch dev server, screenshot may be missing", + ) # Step 4: Generate cumulative patch (squash everything since base_branch) # Parse proposal plan to get title for commit message @@ -513,7 +575,9 @@ async def main() -> None: # Generate format-patch (cumulative: base_branch → HEAD) patch_result = subprocess.run( ["git", "format-patch", "-1", "HEAD", "--stdout"], - capture_output=True, text=True, cwd=repo_dir, + capture_output=True, + text=True, + cwd=repo_dir, check=True, ) local_diff = f"{tmp_dir}/changes.diff" @@ -524,7 +588,8 @@ async def main() -> None: # Upload after screenshot if Path(screenshot_path).exists(): s3_upload_file( - s3, bucket, + s3, + bucket, f"{s3_prefix}/after.png", screenshot_path, content_type="image/png", @@ -532,7 +597,8 @@ async def main() -> None: # Upload cumulative patch s3_upload_text( - s3, bucket, + s3, + bucket, f"{s3_prefix}/changes.diff", patch_result.stdout, content_type="text/plain",