47 Commits

Author SHA1 Message Date
Leon van Zyl
f73a83d171 Merge pull request #231 from AutoForgeAI/ui-redesign
UI redesign
2026-04-01 14:10:00 +02:00
Auto
c324d42c69 version patch 2026-04-01 14:09:03 +02:00
Auto
b7a3b2069a 0.1.21 2026-04-01 14:08:44 +02:00
Auto
1ce26ba32a ui redesign 2026-04-01 14:08:32 +02:00
Leon van Zyl
eedb682187 Merge pull request #230 from AutoForgeAI/embed-browser-windows
Embed browser windows
2026-04-01 11:30:07 +02:00
Auto
71aad12d0b version patch 2026-04-01 11:29:33 +02:00
Auto
342b5b41f3 0.1.20 2026-04-01 11:29:04 +02:00
Auto
9f87f7a314 embed browser windows 2026-04-01 11:28:51 +02:00
Leon van Zyl
cfcba65de2 Merge pull request #229 from AutoForgeAI/chore/auth-disclaimer-and-version-bump
Add auth policy disclaimer and bump to 0.1.19
2026-03-26 11:48:18 +02:00
Auto
1341a16f07 version patch 2026-03-26 11:46:33 +02:00
Auto
824484034f 0.1.19 2026-03-26 11:46:01 +02:00
Auto
e0cd0b721e feat: add auth policy disclaimer and repo maintenance notice
Add prominent warnings about Anthropic's Agent SDK policy regarding
subscription-based authentication for third-party agents. Users are
now advised to use API keys instead of `claude login` to avoid
potential account suspension.

Changes:
- README: Add WARNING and NOTE admonition boxes at top (auth policy
  + repo no longer actively maintained)
- README: Flip auth recommendation to API key first, subscription second
- SettingsModal: Add amber warning Alert when Claude provider is selected
- auth.py: Update CLI/server help messages to recommend API key as Option 1
- Start scripts (start.sh, start.bat, start_ui.sh): Mention ANTHROPIC_API_KEY
  alongside claude login in all auth hints
- start.py, autonomous_agent_demo.py: Update help text references

No functionality removed — subscription auth still works, warnings are
informational only.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:45:45 +02:00
Leon van Zyl
bcb771734e Merge pull request #228 from AutoForgeAI/chore/version-bump-0.1.18
chore: version bump to 0.1.18
2026-03-25 13:04:43 +02:00
Auto
ec156e0bfe version patch 2026-03-25 13:02:17 +02:00
Auto
5e8cf4bdc2 0.1.18 2026-03-25 12:53:37 +02:00
Leon van Zyl
b738859417 Merge pull request #227 from AutoForgeAI/feat/document-file-uploads
feat: add document file upload support for spec creation and project expansion
2026-03-25 12:52:44 +02:00
Auto
7210c6f066 feat: add document file upload support for spec creation and project expansion
Add support for uploading Markdown, Text, Word (.docx), CSV, Excel (.xlsx),
PDF, and PowerPoint (.pptx) files in addition to existing JPEG/PNG image
uploads in the spec creation and project expansion chat interfaces.

Backend changes:
- New server/utils/document_extraction.py: in-memory text extraction for all
  document formats using python-docx, openpyxl, PyPDF2, python-pptx (no disk
  persistence)
- Rename ImageAttachment to FileAttachment across schemas, routers, and
  chat session services
- Add build_attachment_content_blocks() helper in chat_constants.py to route
  images as image content blocks and documents as extracted text blocks
- Separate size limits: 5MB for images, 20MB for documents
- Handle extraction errors (corrupt files, encrypted PDFs) gracefully

Frontend changes:
- Widen accepted MIME types and file extensions in both chat components
- Add resolveMimeType() fallback for browsers that don't set MIME on .md files
- Document attachments display with FileText icon instead of image thumbnail
- ChatMessage renders documents as compact pills with filename and size
- Update help text from "attach images" to "attach files"

Dependencies added: python-docx, openpyxl, PyPDF2, python-pptx

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 12:51:17 +02:00
Leon van Zyl
fca1f6a5e2 Merge pull request #226 from AutoForgeAI/feat/batch-size-limits-and-testing-batch-setting
feat: increase batch size limits to 15 and add testing_batch_size setting
2026-03-20 13:41:54 +02:00
Auto
b15f45c094 version patch 2026-03-20 13:39:56 +02:00
Auto
f999e1937d 0.1.17 2026-03-20 13:39:23 +02:00
Auto
8b2251331d feat: increase batch size limits to 15 and add testing_batch_size setting
Batch size configuration:
- Increase coding agent batch size limit from 1-3 to 1-15
- Increase testing agent batch size limit from 1-5 to 1-15
- Add separate `testing_batch_size` setting (previously only CLI-configurable)
- Pass testing_batch_size through full stack: schema → settings router →
  agent router → process manager → CLI flag

UI changes:
- Replace 3-button batch size selector with range slider (1-15)
- Add new Slider component (ui/src/components/ui/slider.tsx)
- Add "Features per Testing Agent" slider in settings panel
- Add custom slider CSS styling for webkit and mozilla

Updated across: CLAUDE.md, autonomous_agent_demo.py, parallel_orchestrator.py,
server/{schemas,routers,services}, and UI types/hooks/components.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 13:39:19 +02:00
Leon van Zyl
7f875c3bbd Merge pull request #214 from AutoForgeAI/fix/npm-audit-vulnerabilities
Fix 4 npm audit vulnerabilities in UI dependencies
2026-02-26 14:10:02 +02:00
Auto
e26ca3761b fix: resolve 4 npm audit vulnerabilities in UI dependencies
Update rollup, minimatch, ajv, and lodash to patched versions
via npm audit fix (2 high, 2 moderate → 0 vulnerabilities).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 14:09:08 +02:00
Auto
5d3c04a3c7 0.1.16 2026-02-26 14:04:56 +02:00
Leon van Zyl
df23a978cb Merge pull request #213 from AutoForgeAI/feat/scaffold-template-selection
feat: add scaffold router and project template selection
2026-02-26 14:03:16 +02:00
Auto
41c1a14ae3 feat: add scaffold router and project template selection step
Add a new scaffold system that lets users choose a project template
(blank or agentic starter) during project creation. This inserts a
template selection step between folder selection and spec method choice.

Backend:
- New server/routers/scaffold.py with SSE streaming endpoint for
  running hardcoded scaffold commands (npx create-agentic-app)
- Path validation, security checks, and cross-platform npx resolution
- Registered scaffold_router in server/main.py and routers/__init__.py

Frontend (NewProjectModal.tsx):
- New "template" step with Blank Project and Agentic Starter cards
- Real-time scaffold output streaming with auto-scroll log viewer
- Success, error, and retry states with proper back-navigation
- Updated step flow: name → folder → template → method → chat/complete

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 13:18:55 +02:00
Leon van Zyl
472064c3da Merge pull request #212 from AutoForgeAI/fix/rate-limit-and-version-bump
fix: resolve false-positive rate limit and version bump to 0.1.15
2026-02-23 13:18:02 +02:00
Auto
afc2f4ac3c version patch 2026-02-23 13:01:20 +02:00
Auto
dceb535ade 0.1.15 2026-02-23 13:00:47 +02:00
Auto
4f102e7bc2 fix: resolve false-positive rate limit and one-message-behind in chat sessions
The Claude Code CLI v2.1.45+ emits a `rate_limit_event` message type that
the Python SDK v0.1.19 cannot parse, raising MessageParseError. Two bugs
resulted:

1. **False-positive rate limit**: check_rate_limit_error() matched
   "rate_limit" in the exception string "Unknown message type:
   rate_limit_event" via both an explicit type check and a regex fallback,
   triggering 15-19s backoff + query re-send on every session.

2. **One-message-behind**: The MessageParseError killed the
   receive_response() async generator, but the CLI subprocess was still
   alive with buffered response data. Catching and returning meant the
   response was never consumed. The next send_message() would read the
   previous response first, creating a one-behind offset.

Changes:

- chat_constants.py: check_rate_limit_error() now returns (False, None)
  for any MessageParseError, blocking both false-positive paths. Added
  safe_receive_response() helper that retries receive_response() on
  MessageParseError — the SDK's decoupled producer/consumer architecture
  (anyio memory channel) allows the new generator to continue reading
  remaining messages without data loss. Removed calculate_rate_limit_backoff
  re-export and MAX_CHAT_RATE_LIMIT_RETRIES constant.

- spec_chat_session.py, assistant_chat_session.py, expand_chat_session.py:
  Replaced retry-with-backoff loops with safe_receive_response() wrapper.
  Removed asyncio.sleep backoff, query re-send, and rate_limited yield.
  Cleaned up unused imports (asyncio, calculate_rate_limit_backoff,
  MAX_CHAT_RATE_LIMIT_RETRIES).

- agent.py: Added inner retry loop around receive_response() with same
  MessageParseError skip-and-restart pattern. Removed early-return that
  truncated responses.

- types.ts: Removed SpecChatRateLimitedMessage,
  AssistantChatRateLimitedMessage, and their union entries.

- useSpecChat.ts, useAssistantChat.ts, useExpandChat.ts: Removed dead
  'rate_limited' case handlers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 13:00:16 +02:00
Leon van Zyl
9af0f309b7 Merge pull request #211 from AutoForgeAI/fix/rate-limit-event-crash
fix: handle rate_limit_event crash in chat sessions
2026-02-23 12:28:28 +02:00
Auto
49442f0d43 version patch 2026-02-23 12:23:02 +02:00
Auto
f786879908 0.1.14 2026-02-23 12:22:06 +02:00
Auto
dcdd06e02e fix: handle rate_limit_event crash in chat sessions
The Claude CLI sends `rate_limit_event` messages that the SDK's
`parse_message()` doesn't recognize, raising `MessageParseError` and
crashing all three chat session types (spec, assistant, expand).

Changes:
- Bump claude-agent-sdk minimum from 0.1.0 to 0.1.39
- Add `check_rate_limit_error()` helper in chat_constants.py that
  detects rate limits from both MessageParseError data payloads and
  error message text patterns
- Wrap `receive_response()` loops in all three `_query_claude()` methods
  with retry-on-rate-limit logic (up to 3 retries with backoff)
- Gracefully log and skip non-rate-limit MessageParseError instead of
  crashing the session
- Add `rate_limited` message type to frontend TypeScript types and
  handle it in useSpecChat, useAssistantChat, useExpandChat hooks to
  show "Rate limited. Retrying in Xs..." system messages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 12:21:49 +02:00
Auto
b7aef15c3b feat: add VISION.md and enforce Claude Agent SDK exclusivity in PR reviews
- Create VISION.md establishing AutoForge as a Claude Agent SDK wrapper
  exclusively, rejecting integrations with other AI SDKs/CLIs/platforms
- Update review-pr.md step 6 to make vision deviation a merge blocker
  (previously informational only) and auto-reject PRs modifying VISION.md
- Add .claude/launch.json with backend (uvicorn) and frontend (Vite)
  dev server configurations

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 10:23:42 +02:00
Leon van Zyl
d65fa0ca56 Merge pull request #196 from CaitlynByrne/fix/pr-184-feedback
Clean, well-scoped validation improvement. Thanks for the contribution, @CaitlynByrne! 🎉
2026-02-15 10:37:26 +02:00
Caitlyn Byrne
d712e58ff5 fix: stricter field validation for human input requests (#184 feedback)
Validate select option structure (value/label keys, non-empty strings)
and reject options on non-select field types.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 07:44:30 -05:00
Auto
69d9313c07 version patch 2026-02-12 09:48:51 +02:00
Auto
a434767b41 0.1.13 2026-02-12 09:48:21 +02:00
Auto
090dcf977b chore: enhance PR review workflow and add GLM 5 model
- Add merge conflict detection as step 2 in PR review command, surfacing
  conflicts early before the rest of the review proceeds
- Refine merge recommendations: always fix issues on the PR branch before
  merging rather than merging first and fixing on main afterward
- Update verdict definitions (MERGE / MERGE after fixes / DON'T MERGE)
  with clearer action guidance for each outcome
- Add GLM 5 model to the GLM API provider in registry
- Clean up ui/package-lock.json (remove unnecessary peer flags)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 09:48:13 +02:00
Auto
ca5fc48443 Merge pull request #184 from CaitlynByrne/feature/blocked-for-human-input
feat: add blocked for human input feature
2026-02-12 07:37:11 +02:00
Auto
d846a021b8 fix: address PR #184 review findings for blocked-for-human-input feature
A) Graph view: add needs_human_input bucket to handleGraphNodeClick so
   clicking blocked nodes opens the feature modal
B) MCP validation: validate field type enum, require options for select,
   enforce unique non-empty field IDs and labels
C) Progress fallback: include needs_human_input in non-WebSocket total
D) WebSocket: track needs_human_input count in progress state
E) Cleanup guard: remove unnecessary needs_human_input check in
   _cleanup_stale_features (resolved via merge conflict)
F) Defensive SQL: require in_progress=1 in feature_request_human_input

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 07:36:48 +02:00
Auto
819ebcd112 Merge remote-tracking branch 'origin/master' into feature/blocked-for-human-input
# Conflicts:
#	server/services/process_manager.py
2026-02-12 07:36:11 +02:00
Auto
f4636fdfd5 fix: handle pausing/draining states in UI guards and process cleanup
Follow-up fixes after merging PR #183 (graceful pause/drain mode):

- process_manager: _stream_output finally block now transitions from
  pausing/paused_graceful to crashed/stopped (not just running), and
  cleans up the drain signal file on process exit
- App.tsx: block Reset button and R shortcut during pausing/paused_graceful
- AgentThought/ProgressDashboard: keep thought bubble visible while pausing
- OrchestratorAvatar: add draining/paused cases to animation, glow, and
  description switch statements
- AgentMissionControl: show Draining/Paused badge text for new states
- registry.py: remove redundant type annotation to fix mypy no-redef
- process_manager.py: add type:ignore for SQLAlchemy Column assignment
- websocket.py: reclassify test-pass lines as 'testing' not 'success'
- review-pr.md: add post-review recommended action guidance

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 07:28:37 +02:00
Leon van Zyl
c114248b09 Merge pull request #183 from CaitlynByrne/feat/pause-drain
feat: add graceful pause (drain mode) for running agents
2026-02-12 07:22:01 +02:00
Caitlyn Byrne
656df0fd9a feat: add "blocked for human input" feature across full stack
Agents can now request structured human input when they encounter
genuine blockers (API keys, design choices, external configs). The
request is displayed in the UI with a dynamic form, and the human's
response is stored and made available when the agent resumes.

Changes span 21 files + 1 new component:
- Database: 3 new columns (needs_human_input, human_input_request,
  human_input_response) with migration
- MCP: new feature_request_human_input tool + guards on existing tools
- API: new resolve-human-input endpoint, 4th feature bucket
- Orchestrator: skip needs_human_input features in scheduling
- Progress: 4-tuple return from count_passing_tests
- WebSocket: needs_human_input count in progress messages
- UI: conditional 4th Kanban column, HumanInputForm component,
  amber status indicators, dependency graph support

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 14:11:35 -05:00
Caitlyn Byrne
9721368188 feat: add graceful pause (drain mode) for running agents
File-based signal (.pause_drain) lets the orchestrator finish current
work before pausing instead of hard-freezing the process tree.  New
status states pausing/paused_graceful flow through WebSocket to the UI
where a Pause button, draining indicator, and Resume button are shown.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 13:37:22 -05:00
86 changed files with 5885 additions and 1265 deletions

View File

@@ -12,7 +12,13 @@ Pull request(s): $ARGUMENTS
1. **Retrieve PR Details** 1. **Retrieve PR Details**
- Use the GH CLI tool to retrieve the details (descriptions, diffs, comments, feedback, reviews, etc) - Use the GH CLI tool to retrieve the details (descriptions, diffs, comments, feedback, reviews, etc)
2. **Assess PR Complexity** 2. **Check for Merge Conflicts**
- After retrieving PR details, check whether the PR has merge conflicts against the target branch
- Use `gh pr view <number> --json mergeable,mergeStateStatus` or attempt a local merge check with `git merge-tree`
- If conflicts exist, note the conflicting files — these must be resolved on the PR branch before merging
- Surface conflicts early so they inform the rest of the review (don't discover them as a surprise at merge time)
3. **Assess PR Complexity**
After retrieving PR details, assess complexity based on: After retrieving PR details, assess complexity based on:
- Number of files changed - Number of files changed
@@ -34,13 +40,13 @@ Pull request(s): $ARGUMENTS
- >15 files, OR >500 lines, OR >2 contributors, OR touches core architecture - >15 files, OR >500 lines, OR >2 contributors, OR touches core architecture
- Spawn up to 3 agents to analyze different aspects (e.g., security, performance, architecture) - Spawn up to 3 agents to analyze different aspects (e.g., security, performance, architecture)
3. **Analyze Codebase Impact** 4. **Analyze Codebase Impact**
- Based on the complexity tier determined above, spawn the appropriate number of deep dive subagents - Based on the complexity tier determined above, spawn the appropriate number of deep dive subagents
- For Simple PRs: analyze directly without spawning agents - For Simple PRs: analyze directly without spawning agents
- For Medium PRs: spawn 1-2 agents focusing on the most impacted areas - For Medium PRs: spawn 1-2 agents focusing on the most impacted areas
- For Complex PRs: spawn up to 3 agents to cover security, performance, and architectural concerns - For Complex PRs: spawn up to 3 agents to cover security, performance, and architectural concerns
4. **PR Scope & Title Alignment Check** 5. **PR Scope & Title Alignment Check**
- Compare the PR title and description against the actual diff content - Compare the PR title and description against the actual diff content
- Check whether the PR is focused on a single coherent change or contains multiple unrelated changes - Check whether the PR is focused on a single coherent change or contains multiple unrelated changes
- If the title/description describe one thing but the PR contains significantly more (e.g., title says "fix typo in README" but the diff touches 20 files across multiple domains), flag this as a **scope mismatch** - If the title/description describe one thing but the PR contains significantly more (e.g., title says "fix typo in README" but the diff touches 20 files across multiple domains), flag this as a **scope mismatch**
@@ -48,28 +54,53 @@ Pull request(s): $ARGUMENTS
- Suggest specific ways to split the PR (e.g., "separate the refactor from the feature addition") - Suggest specific ways to split the PR (e.g., "separate the refactor from the feature addition")
- Reviewing large, unfocused PRs is impractical and error-prone; the review cannot provide adequate assurance for such changes - Reviewing large, unfocused PRs is impractical and error-prone; the review cannot provide adequate assurance for such changes
5. **Vision Alignment Check** 6. **Vision Alignment Check**
- Read the project's README.md and CLAUDE.md to understand the application's core purpose - **VISION.md protection**: First, check whether the PR diff modifies `VISION.md` in any way (edits, deletions, renames). If it does, **stop the review immediately** — verdict is **DON'T MERGE**. VISION.md is immutable and no PR is permitted to alter it. Explain this to the user and skip all remaining steps.
- Assess whether this PR aligns with the application's intended functionality - Read the project's `VISION.md`, `README.md`, and `CLAUDE.md` to understand the application's core purpose and mandatory architectural constraints
- If the changes deviate significantly from the core vision or add functionality that doesn't serve the application's purpose, note this in the review - Assess whether this PR aligns with the vision defined in `VISION.md`
- This is not a blocker, but should be flagged for the reviewer's consideration - **Vision deviation is a merge blocker.** If the PR introduces functionality, integrations, or architectural changes that conflict with `VISION.md`, the verdict must be **DON'T MERGE**. This is not negotiable — the vision document takes precedence over any PR rationale.
6. **Safety Assessment** 7. **Safety Assessment**
- Provide a review on whether the PR is safe to merge as-is - Provide a review on whether the PR is safe to merge as-is
- Provide any feedback in terms of risk level - Provide any feedback in terms of risk level
7. **Improvements** 8. **Improvements**
- Propose any improvements in terms of importance and complexity - Propose any improvements in terms of importance and complexity
8. **Merge Recommendation** 9. **Merge Recommendation**
- Based on all findings, provide a clear merge/don't-merge recommendation - Based on all findings (including merge conflict status from step 2), provide a clear recommendation
- If all concerns are minor (cosmetic issues, naming suggestions, small style nits, missing comments, etc.), recommend **merging the PR** and note that the reviewer can address these minor concerns themselves with a quick follow-up commit pushed directly to master - **If no concerns and no conflicts**: recommend merging as-is
- If there are significant concerns (bugs, security issues, architectural problems, scope mismatch), recommend **not merging** and explain what needs to be resolved first - **If concerns are minor/fixable and/or merge conflicts exist**: recommend fixing on the PR branch first, then merging. Never merge a PR with known issues to main — always fix on the PR branch first
- **If there are significant concerns** (bugs, security issues, architectural problems, scope mismatch) that require author input or are too risky to fix: recommend **not merging** and explain what needs to be resolved
9. **TLDR** 10. **TLDR**
- End the review with a `## TLDR` section - End the review with a `## TLDR` section
- In 3-5 bullet points maximum, summarize: - In 3-5 bullet points maximum, summarize:
- What this PR is actually about (one sentence) - What this PR is actually about (one sentence)
- Merge conflict status (clean or conflicting files)
- The key concerns, if any (or "no significant concerns") - The key concerns, if any (or "no significant concerns")
- **Verdict: MERGE** / **MERGE (with minor follow-up)** / **DON'T MERGE** with a one-line reason - **Verdict: MERGE** / **MERGE (after fixes)** / **DON'T MERGE** with a one-line reason
- This section should be scannable in under 10 seconds - This section should be scannable in under 10 seconds
Verdict definitions:
- **MERGE** — no issues, clean to merge as-is
- **MERGE (after fixes)** — minor issues and/or conflicts exist, but can be resolved on the PR branch first, then merged
- **DON'T MERGE** — needs author attention, too complex or risky to fix without their input
11. **Post-Review Action**
- Immediately after the TLDR, provide a `## Recommended Action` section
- Based on the verdict, recommend one of the following actions:
**If verdict is MERGE (no concerns):**
- Merge as-is. No further action needed.
**If verdict is MERGE (after fixes):**
- List the specific changes that need to be made (fixes, conflict resolutions, etc.)
- Offer to: check out the PR branch, resolve any merge conflicts, apply the minor fixes identified during review, push the updated branch, then merge the now-clean PR
- Ask the user: *"Should I check out the PR branch, apply these fixes, and then merge?"*
- **Never merge first and fix on main later** — always fix on the PR branch before merging
**If verdict is DON'T MERGE:**
- If the issues are contained and you are confident you can fix them: offer the same workflow as "MERGE (after fixes)" — check out the PR branch, apply fixes, push, then merge
- If the issues are too complex, risky, or require author input (e.g., design decisions, major refactors, unclear intent): recommend sending the PR back to the author with specific feedback on what needs to change
- Be honest about your confidence level — if you're unsure whether you can address the concerns correctly, say so and defer to the author

18
.claude/launch.json Normal file
View File

@@ -0,0 +1,18 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "backend",
"runtimeExecutable": "python",
"runtimeArgs": ["-m", "uvicorn", "server.main:app", "--host", "127.0.0.1", "--port", "8888", "--reload"],
"port": 8888
},
{
"name": "frontend",
"runtimeExecutable": "cmd",
"runtimeArgs": ["/c", "cd ui && npx vite"],
"port": 5173
}
],
"autoVerify": true
}

View File

@@ -65,7 +65,7 @@ python autonomous_agent_demo.py --project-dir my-app --yolo
# Parallel mode: run multiple agents concurrently (1-5 agents) # Parallel mode: run multiple agents concurrently (1-5 agents)
python autonomous_agent_demo.py --project-dir my-app --parallel --max-concurrency 3 python autonomous_agent_demo.py --project-dir my-app --parallel --max-concurrency 3
# Batch mode: implement multiple features per agent session (1-3) # Batch mode: implement multiple features per agent session (1-15)
python autonomous_agent_demo.py --project-dir my-app --batch-size 3 python autonomous_agent_demo.py --project-dir my-app --batch-size 3
# Batch specific features by ID # Batch specific features by ID
@@ -496,9 +496,9 @@ The orchestrator enforces strict bounds on concurrent processes:
### Multi-Feature Batching ### Multi-Feature Batching
Agents can implement multiple features per session using `--batch-size` (1-3, default: 3): Agents can implement multiple features per session using `--batch-size` (1-15, default: 3):
- `--batch-size N` - Max features per coding agent batch - `--batch-size N` - Max features per coding agent batch
- `--testing-batch-size N` - Features per testing batch (1-5, default: 3) - `--testing-batch-size N` - Features per testing batch (1-15, default: 3)
- `--batch-features 1,2,3` - Specific feature IDs for batch implementation - `--batch-features 1,2,3` - Specific feature IDs for batch implementation
- `--testing-batch-features 1,2,3` - Specific feature IDs for batch regression testing - `--testing-batch-features 1,2,3` - Specific feature IDs for batch regression testing
- `prompts.py` provides `get_batch_feature_prompt()` for multi-feature prompt generation - `prompts.py` provides `get_batch_feature_prompt()` for multi-feature prompt generation

View File

@@ -4,6 +4,12 @@
A long-running autonomous coding agent powered by the Claude Agent SDK. This tool can build complete applications over multiple sessions using a two-agent pattern (initializer + coding agent). Includes a React-based UI for monitoring progress in real-time. A long-running autonomous coding agent powered by the Claude Agent SDK. This tool can build complete applications over multiple sessions using a two-agent pattern (initializer + coding agent). Includes a React-based UI for monitoring progress in real-time.
> [!WARNING]
> **Authentication:** Anthropic's policy states that third-party developers may not offer `claude.ai` login or subscription-based rate limits for their products (including agents built on the Claude Agent SDK) unless previously approved. Using your Claude subscription with AutoForge may risk account suspension. We recommend using an API key from [console.anthropic.com](https://console.anthropic.com/) instead.
> [!NOTE]
> **This repository is no longer actively maintained.** Most agent coding tools now ship their own long-running harnesses, making this project less necessary. Feel free to fork and continue development on your own!
## Video Tutorial ## Video Tutorial
[![Watch the tutorial](https://img.youtube.com/vi/nKiPOxDpcJY/hqdefault.jpg)](https://youtu.be/nKiPOxDpcJY) [![Watch the tutorial](https://img.youtube.com/vi/nKiPOxDpcJY/hqdefault.jpg)](https://youtu.be/nKiPOxDpcJY)
@@ -34,8 +40,8 @@ irm https://claude.ai/install.ps1 | iex
You need one of the following: You need one of the following:
- **Claude Pro/Max Subscription** - Use `claude login` to authenticate (recommended) - **Anthropic API Key** (recommended) - Pay-per-use from https://console.anthropic.com/
- **Anthropic API Key** - Pay-per-use from https://console.anthropic.com/ - **Claude Pro/Max Subscription** - Use `claude login` to authenticate (see warning above)
--- ---
@@ -101,7 +107,7 @@ This launches the React-based web UI at `http://localhost:5173` with:
The start script will: The start script will:
1. Check if Claude CLI is installed 1. Check if Claude CLI is installed
2. Check if you're authenticated (prompt to run `claude login` if not) 2. Check if you're authenticated (prompt to configure authentication if not)
3. Create a Python virtual environment 3. Create a Python virtual environment
4. Install dependencies 4. Install dependencies
5. Launch the main menu 5. Launch the main menu
@@ -371,7 +377,7 @@ Edit `security.py` to add or remove commands from `ALLOWED_COMMANDS`.
Install the Claude Code CLI using the instructions in the Prerequisites section. Install the Claude Code CLI using the instructions in the Prerequisites section.
**"Not authenticated with Claude"** **"Not authenticated with Claude"**
Run `claude login` to authenticate. The start script will prompt you to do this automatically. Set your API key via `ANTHROPIC_API_KEY` environment variable or the Settings UI. Alternatively, run `claude login` to use subscription credentials, but note that Anthropic's policy may not permit subscription-based auth for third-party agents.
**"Appears to hang on first run"** **"Appears to hang on first run"**
This is normal. The initializer agent is generating detailed test cases, which takes significant time. Watch for `[Tool: ...]` output to confirm the agent is working. This is normal. The initializer agent is generating detailed test cases, which takes significant time. Watch for `[Tool: ...]` output to confirm the agent is working.

22
VISION.md Normal file
View File

@@ -0,0 +1,22 @@
# VISION
This document defines the mandatory project vision for AutoForge. All contributions must align with these principles. PRs that deviate from this vision will be rejected. This file itself is immutable via PR — any PR that modifies VISION.md will be rejected outright.
## Claude Agent SDK Exclusivity
AutoForge is a wrapper around the **Claude Agent SDK**. This is a foundational architectural decision, not a preference.
**What this means:**
- AutoForge only supports providers, models, and integrations that work through the Claude Agent SDK.
- We will not integrate with, accommodate, or add support for other AI SDKs, CLIs, or coding agent platforms (e.g., Codex, OpenCode, Aider, Continue, Cursor agents, or similar tools).
**Why:**
Each platform has its own approach to MCP tools, skills, context management, and feature integration. Attempting to support multiple agent frameworks creates an unsustainable maintenance burden and dilutes the quality of the core experience. By committing to the Claude Agent SDK exclusively, we can build deep, reliable integration rather than shallow compatibility across many targets.
**In practice:**
- PRs adding support for non-Claude agent frameworks will be rejected.
- PRs introducing abstractions designed to make AutoForge "agent-agnostic" will be rejected.
- Alternative API providers (e.g., Vertex AI, AWS Bedrock) are acceptable only when accessed through the Claude Agent SDK's own configuration.

View File

@@ -74,7 +74,15 @@ async def run_agent_session(
await client.query(message) await client.query(message)
# Collect response text and show tool use # Collect response text and show tool use
# Retry receive_response() on MessageParseError — the SDK raises this for
# unknown CLI message types (e.g. "rate_limit_event") which kills the async
# generator. The subprocess is still alive so we restart to read remaining
# messages from the buffered channel.
response_text = "" response_text = ""
max_parse_retries = 50
parse_retries = 0
while True:
try:
async for msg in client.receive_response(): async for msg in client.receive_response():
msg_type = type(msg).__name__ msg_type = type(msg).__name__
@@ -115,6 +123,17 @@ async def run_agent_session(
# Tool succeeded - just show brief confirmation # Tool succeeded - just show brief confirmation
print(" [Done]", flush=True) print(" [Done]", flush=True)
break # Normal completion
except Exception as inner_exc:
if type(inner_exc).__name__ == "MessageParseError":
parse_retries += 1
if parse_retries > max_parse_retries:
print(f"Too many unrecognized CLI messages ({parse_retries}), stopping")
break
print(f"Ignoring unrecognized message from Claude CLI: {inner_exc}")
continue
raise # Re-raise to outer except
print("\n" + "-" * 70 + "\n") print("\n" + "-" * 70 + "\n")
return "continue", response_text return "continue", response_text
@@ -222,7 +241,7 @@ async def run_autonomous_agent(
# Check if all features are already complete (before starting a new session) # Check if all features are already complete (before starting a new session)
# Skip this check if running as initializer (needs to create features first) # Skip this check if running as initializer (needs to create features first)
if not is_initializer and iteration == 1: if not is_initializer and iteration == 1:
passing, in_progress, total = count_passing_tests(project_dir) passing, in_progress, total, _nhi = count_passing_tests(project_dir)
if total > 0 and passing == total: if total > 0 and passing == total:
print("\n" + "=" * 70) print("\n" + "=" * 70)
print(" ALL FEATURES ALREADY COMPLETE!") print(" ALL FEATURES ALREADY COMPLETE!")
@@ -348,7 +367,7 @@ async def run_autonomous_agent(
print_progress_summary(project_dir) print_progress_summary(project_dir)
# Check if all features are complete - exit gracefully if done # Check if all features are complete - exit gracefully if done
passing, in_progress, total = count_passing_tests(project_dir) passing, in_progress, total, _nhi = count_passing_tests(project_dir)
if total > 0 and passing == total: if total > 0 and passing == total:
print("\n" + "=" * 70) print("\n" + "=" * 70)
print(" ALL FEATURES COMPLETE!") print(" ALL FEATURES COMPLETE!")

View File

@@ -43,10 +43,10 @@ class Feature(Base):
__tablename__ = "features" __tablename__ = "features"
# Composite index for common status query pattern (passes, in_progress) # Composite index for common status query pattern (passes, in_progress, needs_human_input)
# Used by feature_get_stats, get_ready_features, and other status queries # Used by feature_get_stats, get_ready_features, and other status queries
__table_args__ = ( __table_args__ = (
Index('ix_feature_status', 'passes', 'in_progress'), Index('ix_feature_status', 'passes', 'in_progress', 'needs_human_input'),
) )
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
@@ -61,6 +61,11 @@ class Feature(Base):
# NULL/empty = no dependencies (backwards compatible) # NULL/empty = no dependencies (backwards compatible)
dependencies = Column(JSON, nullable=True, default=None) dependencies = Column(JSON, nullable=True, default=None)
# Human input: agent can request structured input from a human
needs_human_input = Column(Boolean, nullable=False, default=False, index=True)
human_input_request = Column(JSON, nullable=True, default=None) # Agent's structured request
human_input_response = Column(JSON, nullable=True, default=None) # Human's response
def to_dict(self) -> dict: def to_dict(self) -> dict:
"""Convert feature to dictionary for JSON serialization.""" """Convert feature to dictionary for JSON serialization."""
return { return {
@@ -75,6 +80,10 @@ class Feature(Base):
"in_progress": self.in_progress if self.in_progress is not None else False, "in_progress": self.in_progress if self.in_progress is not None else False,
# Dependencies: NULL/empty treated as empty list for backwards compat # Dependencies: NULL/empty treated as empty list for backwards compat
"dependencies": self.dependencies if self.dependencies else [], "dependencies": self.dependencies if self.dependencies else [],
# Human input fields
"needs_human_input": self.needs_human_input if self.needs_human_input is not None else False,
"human_input_request": self.human_input_request,
"human_input_response": self.human_input_response,
} }
def get_dependencies_safe(self) -> list[int]: def get_dependencies_safe(self) -> list[int]:
@@ -302,6 +311,21 @@ def _is_network_path(path: Path) -> bool:
return False return False
def _migrate_add_human_input_columns(engine) -> None:
"""Add human input columns to existing databases that don't have them."""
with engine.connect() as conn:
result = conn.execute(text("PRAGMA table_info(features)"))
columns = [row[1] for row in result.fetchall()]
if "needs_human_input" not in columns:
conn.execute(text("ALTER TABLE features ADD COLUMN needs_human_input BOOLEAN DEFAULT 0"))
if "human_input_request" not in columns:
conn.execute(text("ALTER TABLE features ADD COLUMN human_input_request TEXT DEFAULT NULL"))
if "human_input_response" not in columns:
conn.execute(text("ALTER TABLE features ADD COLUMN human_input_response TEXT DEFAULT NULL"))
conn.commit()
def _migrate_add_schedules_tables(engine) -> None: def _migrate_add_schedules_tables(engine) -> None:
"""Create schedules and schedule_overrides tables if they don't exist.""" """Create schedules and schedule_overrides tables if they don't exist."""
from sqlalchemy import inspect from sqlalchemy import inspect
@@ -425,6 +449,7 @@ def create_database(project_dir: Path) -> tuple:
_migrate_fix_null_boolean_fields(engine) _migrate_fix_null_boolean_fields(engine)
_migrate_add_dependencies_column(engine) _migrate_add_dependencies_column(engine)
_migrate_add_testing_columns(engine) _migrate_add_testing_columns(engine)
_migrate_add_human_input_columns(engine)
# Migrate to add schedules tables # Migrate to add schedules tables
_migrate_add_schedules_tables(engine) _migrate_add_schedules_tables(engine)

22
auth.py
View File

@@ -53,11 +53,16 @@ AUTH_ERROR_HELP_CLI = """
Claude CLI requires authentication to work. Claude CLI requires authentication to work.
To fix this, run: Option 1 (Recommended): Set an API key
export ANTHROPIC_API_KEY=your-key-here
Get a key at: https://console.anthropic.com/
Option 2: Use subscription login
claude login claude login
This will open a browser window to sign in. Note: Anthropic's policy may not permit using
After logging in, try running this command again. subscription auth with third-party agents.
API key authentication is recommended.
================================================== ==================================================
""" """
@@ -69,11 +74,16 @@ AUTH_ERROR_HELP_SERVER = """
Claude CLI requires authentication to work. Claude CLI requires authentication to work.
To fix this, run: Option 1 (Recommended): Set an API key
export ANTHROPIC_API_KEY=your-key-here
Get a key at: https://console.anthropic.com/
Option 2: Use subscription login
claude login claude login
This will open a browser window to sign in. Note: Anthropic's policy may not permit using
After logging in, try starting the agent again. subscription auth with third-party agents.
API key authentication is recommended.
================================================================================ ================================================================================
""" """

View File

@@ -39,6 +39,7 @@ assistant.db-wal
assistant.db-shm assistant.db-shm
.agent.lock .agent.lock
.devserver.lock .devserver.lock
.pause_drain
.claude_settings.json .claude_settings.json
.claude_assistant_settings.json .claude_assistant_settings.json
.claude_settings.expand.*.json .claude_settings.expand.*.json
@@ -146,6 +147,15 @@ def get_claude_assistant_settings_path(project_dir: Path) -> Path:
return _resolve_path(project_dir, ".claude_assistant_settings.json") return _resolve_path(project_dir, ".claude_assistant_settings.json")
def get_pause_drain_path(project_dir: Path) -> Path:
"""Return the path to the ``.pause_drain`` signal file.
This file is created to request a graceful pause (drain mode).
Always uses the new location since it's a transient signal file.
"""
return project_dir / ".autoforge" / ".pause_drain"
def get_progress_cache_path(project_dir: Path) -> Path: def get_progress_cache_path(project_dir: Path) -> Path:
"""Resolve the path to ``.progress_cache``.""" """Resolve the path to ``.progress_cache``."""
return _resolve_path(project_dir, ".progress_cache") return _resolve_path(project_dir, ".progress_cache")

View File

@@ -76,8 +76,8 @@ Examples:
python autonomous_agent_demo.py --project-dir my-app --testing-ratio 0 python autonomous_agent_demo.py --project-dir my-app --testing-ratio 0
Authentication: Authentication:
Uses Claude CLI authentication (run 'claude login' if not logged in) Uses Claude CLI authentication. API key (ANTHROPIC_API_KEY) is recommended.
Authentication is handled by start.bat/start.sh before this runs Alternatively run 'claude login', but note Anthropic's policy may restrict subscription auth.
""", """,
) )
@@ -176,14 +176,14 @@ Authentication:
"--testing-batch-size", "--testing-batch-size",
type=int, type=int,
default=3, default=3,
help="Number of features per testing batch (1-5, default: 3)", help="Number of features per testing batch (1-15, default: 3)",
) )
parser.add_argument( parser.add_argument(
"--batch-size", "--batch-size",
type=int, type=int,
default=3, default=3,
help="Max features per coding agent batch (1-3, default: 3)", help="Max features per coding agent batch (1-15, default: 3)",
) )
return parser.parse_args() return parser.parse_args()

View File

@@ -151,17 +151,20 @@ def feature_get_stats() -> str:
result = session.query( result = session.query(
func.count(Feature.id).label('total'), func.count(Feature.id).label('total'),
func.sum(case((Feature.passes == True, 1), else_=0)).label('passing'), func.sum(case((Feature.passes == True, 1), else_=0)).label('passing'),
func.sum(case((Feature.in_progress == True, 1), else_=0)).label('in_progress') func.sum(case((Feature.in_progress == True, 1), else_=0)).label('in_progress'),
func.sum(case((Feature.needs_human_input == True, 1), else_=0)).label('needs_human_input')
).first() ).first()
total = result.total or 0 total = result.total or 0
passing = int(result.passing or 0) passing = int(result.passing or 0)
in_progress = int(result.in_progress or 0) in_progress = int(result.in_progress or 0)
needs_human_input = int(result.needs_human_input or 0)
percentage = round((passing / total) * 100, 1) if total > 0 else 0.0 percentage = round((passing / total) * 100, 1) if total > 0 else 0.0
return json.dumps({ return json.dumps({
"passing": passing, "passing": passing,
"in_progress": in_progress, "in_progress": in_progress,
"needs_human_input": needs_human_input,
"total": total, "total": total,
"percentage": percentage "percentage": percentage
}) })
@@ -221,6 +224,7 @@ def feature_get_summary(
"name": feature.name, "name": feature.name,
"passes": feature.passes, "passes": feature.passes,
"in_progress": feature.in_progress, "in_progress": feature.in_progress,
"needs_human_input": feature.needs_human_input if feature.needs_human_input is not None else False,
"dependencies": feature.dependencies or [] "dependencies": feature.dependencies or []
}) })
finally: finally:
@@ -401,11 +405,11 @@ def feature_mark_in_progress(
""" """
session = get_session() session = get_session()
try: try:
# Atomic claim: only succeeds if feature is not already claimed or passing # Atomic claim: only succeeds if feature is not already claimed, passing, or blocked for human input
result = session.execute(text(""" result = session.execute(text("""
UPDATE features UPDATE features
SET in_progress = 1 SET in_progress = 1
WHERE id = :id AND passes = 0 AND in_progress = 0 WHERE id = :id AND passes = 0 AND in_progress = 0 AND needs_human_input = 0
"""), {"id": feature_id}) """), {"id": feature_id})
session.commit() session.commit()
@@ -418,6 +422,8 @@ def feature_mark_in_progress(
return json.dumps({"error": f"Feature with ID {feature_id} is already passing"}) return json.dumps({"error": f"Feature with ID {feature_id} is already passing"})
if feature.in_progress: if feature.in_progress:
return json.dumps({"error": f"Feature with ID {feature_id} is already in-progress"}) return json.dumps({"error": f"Feature with ID {feature_id} is already in-progress"})
if getattr(feature, 'needs_human_input', False):
return json.dumps({"error": f"Feature with ID {feature_id} is blocked waiting for human input"})
return json.dumps({"error": "Failed to mark feature in-progress for unknown reason"}) return json.dumps({"error": "Failed to mark feature in-progress for unknown reason"})
# Fetch the claimed feature # Fetch the claimed feature
@@ -455,11 +461,14 @@ def feature_claim_and_get(
if feature.passes: if feature.passes:
return json.dumps({"error": f"Feature with ID {feature_id} is already passing"}) return json.dumps({"error": f"Feature with ID {feature_id} is already passing"})
# Try atomic claim: only succeeds if not already claimed if getattr(feature, 'needs_human_input', False):
return json.dumps({"error": f"Feature with ID {feature_id} is blocked waiting for human input"})
# Try atomic claim: only succeeds if not already claimed and not blocked for human input
result = session.execute(text(""" result = session.execute(text("""
UPDATE features UPDATE features
SET in_progress = 1 SET in_progress = 1
WHERE id = :id AND passes = 0 AND in_progress = 0 WHERE id = :id AND passes = 0 AND in_progress = 0 AND needs_human_input = 0
"""), {"id": feature_id}) """), {"id": feature_id})
session.commit() session.commit()
@@ -806,6 +815,8 @@ def feature_get_ready(
for f in all_features: for f in all_features:
if f.passes or f.in_progress: if f.passes or f.in_progress:
continue continue
if getattr(f, 'needs_human_input', False):
continue
deps = f.dependencies or [] deps = f.dependencies or []
if all(dep_id in passing_ids for dep_id in deps): if all(dep_id in passing_ids for dep_id in deps):
ready.append(f.to_dict()) ready.append(f.to_dict())
@@ -888,6 +899,8 @@ def feature_get_graph() -> str:
if f.passes: if f.passes:
status = "done" status = "done"
elif getattr(f, 'needs_human_input', False):
status = "needs_human_input"
elif blocking: elif blocking:
status = "blocked" status = "blocked"
elif f.in_progress: elif f.in_progress:
@@ -984,6 +997,116 @@ def feature_set_dependencies(
return json.dumps({"error": f"Failed to set dependencies: {str(e)}"}) return json.dumps({"error": f"Failed to set dependencies: {str(e)}"})
@mcp.tool()
def feature_request_human_input(
feature_id: Annotated[int, Field(description="The ID of the feature that needs human input", ge=1)],
prompt: Annotated[str, Field(min_length=1, description="Explain what you need from the human and why")],
fields: Annotated[list[dict], Field(min_length=1, description="List of input fields to collect")]
) -> str:
"""Request structured input from a human for a feature that is blocked.
Use this ONLY when the feature genuinely cannot proceed without human intervention:
- Creating API keys or external accounts
- Choosing between design approaches that require human preference
- Configuring external services the agent cannot access
- Providing credentials or secrets
Do NOT use this for issues you can solve yourself (debugging, reading docs, etc.).
The feature will be moved out of in_progress and into a "needs human input" state.
Once the human provides their response, the feature returns to the pending queue
and will include the human's response when you pick it up again.
Args:
feature_id: The ID of the feature that needs human input
prompt: A clear explanation of what you need and why
fields: List of input fields, each with:
- id (str): Unique field identifier
- label (str): Human-readable label
- type (str): "text", "textarea", "select", or "boolean" (default: "text")
- required (bool): Whether the field is required (default: true)
- placeholder (str, optional): Placeholder text
- options (list, optional): For select type: [{value, label}]
Returns:
JSON with success confirmation or error message
"""
# Validate fields
VALID_FIELD_TYPES = {"text", "textarea", "select", "boolean"}
seen_ids: set[str] = set()
for i, field in enumerate(fields):
if "id" not in field or "label" not in field:
return json.dumps({"error": f"Field at index {i} missing required 'id' or 'label'"})
fid = field["id"]
flabel = field["label"]
if not isinstance(fid, str) or not fid.strip():
return json.dumps({"error": f"Field at index {i} has empty or invalid 'id'"})
if not isinstance(flabel, str) or not flabel.strip():
return json.dumps({"error": f"Field at index {i} has empty or invalid 'label'"})
if fid in seen_ids:
return json.dumps({"error": f"Duplicate field id '{fid}' at index {i}"})
seen_ids.add(fid)
ftype = field.get("type", "text")
if ftype not in VALID_FIELD_TYPES:
return json.dumps({"error": f"Field at index {i} has invalid type '{ftype}'. Must be one of: {', '.join(sorted(VALID_FIELD_TYPES))}"})
if ftype == "select":
options = field.get("options")
if not options or not isinstance(options, list):
return json.dumps({"error": f"Field at index {i} is type 'select' but missing or invalid 'options' array"})
for j, opt in enumerate(options):
if not isinstance(opt, dict):
return json.dumps({"error": f"Field at index {i}, option {j} must be an object with 'value' and 'label'"})
if "value" not in opt or "label" not in opt:
return json.dumps({"error": f"Field at index {i}, option {j} missing required 'value' or 'label'"})
if not isinstance(opt["value"], str) or not opt["value"].strip():
return json.dumps({"error": f"Field at index {i}, option {j} has empty or invalid 'value'"})
if not isinstance(opt["label"], str) or not opt["label"].strip():
return json.dumps({"error": f"Field at index {i}, option {j} has empty or invalid 'label'"})
elif field.get("options"):
return json.dumps({"error": f"Field at index {i} has 'options' but type is '{ftype}' (only 'select' uses options)"})
request_data = {
"prompt": prompt,
"fields": fields,
}
session = get_session()
try:
# Atomically set needs_human_input, clear in_progress, store request, clear previous response
result = session.execute(text("""
UPDATE features
SET needs_human_input = 1,
in_progress = 0,
human_input_request = :request,
human_input_response = NULL
WHERE id = :id AND passes = 0 AND in_progress = 1
"""), {"id": feature_id, "request": json.dumps(request_data)})
session.commit()
if result.rowcount == 0:
feature = session.query(Feature).filter(Feature.id == feature_id).first()
if feature is None:
return json.dumps({"error": f"Feature with ID {feature_id} not found"})
if feature.passes:
return json.dumps({"error": f"Feature with ID {feature_id} is already passing"})
if not feature.in_progress:
return json.dumps({"error": f"Feature with ID {feature_id} is not in progress"})
return json.dumps({"error": "Failed to request human input for unknown reason"})
feature = session.query(Feature).filter(Feature.id == feature_id).first()
return json.dumps({
"success": True,
"feature_id": feature_id,
"name": feature.name,
"message": f"Feature '{feature.name}' is now blocked waiting for human input"
})
except Exception as e:
session.rollback()
return json.dumps({"error": f"Failed to request human input: {str(e)}"})
finally:
session.close()
@mcp.tool() @mcp.tool()
def ask_user( def ask_user(
questions: Annotated[list[dict], Field(description="List of questions to ask, each with question, header, options (list of {label, description}), and multiSelect (bool)")] questions: Annotated[list[dict], Field(description="List of questions to ask, each with question, header, options (list of {label, description}), and multiSelect (bool)")]

View File

@@ -1,6 +1,6 @@
{ {
"name": "autoforge-ai", "name": "autoforge-ai",
"version": "0.1.12", "version": "0.1.21",
"description": "Autonomous coding agent with web UI - build complete apps with AI", "description": "Autonomous coding agent with web UI - build complete apps with AI",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"bin": { "bin": {

View File

@@ -131,7 +131,7 @@ def _dump_database_state(feature_dicts: list[dict], label: str = ""):
MAX_PARALLEL_AGENTS = 5 MAX_PARALLEL_AGENTS = 5
MAX_TOTAL_AGENTS = 10 MAX_TOTAL_AGENTS = 10
DEFAULT_CONCURRENCY = 3 DEFAULT_CONCURRENCY = 3
DEFAULT_TESTING_BATCH_SIZE = 3 # Number of features per testing batch (1-5) DEFAULT_TESTING_BATCH_SIZE = 3 # Number of features per testing batch (1-15)
POLL_INTERVAL = 5 # seconds between checking for ready features POLL_INTERVAL = 5 # seconds between checking for ready features
MAX_FEATURE_RETRIES = 3 # Maximum times to retry a failed feature MAX_FEATURE_RETRIES = 3 # Maximum times to retry a failed feature
INITIALIZER_TIMEOUT = 1800 # 30 minutes timeout for initializer INITIALIZER_TIMEOUT = 1800 # 30 minutes timeout for initializer
@@ -168,7 +168,7 @@ class ParallelOrchestrator:
yolo_mode: Whether to run in YOLO mode (skip testing agents entirely) yolo_mode: Whether to run in YOLO mode (skip testing agents entirely)
testing_agent_ratio: Number of regression testing agents to maintain (0-3). testing_agent_ratio: Number of regression testing agents to maintain (0-3).
0 = disabled, 1-3 = maintain that many testing agents running independently. 0 = disabled, 1-3 = maintain that many testing agents running independently.
testing_batch_size: Number of features to include per testing session (1-5). testing_batch_size: Number of features to include per testing session (1-15).
Each testing agent receives this many features to regression test. Each testing agent receives this many features to regression test.
on_output: Callback for agent output (feature_id, line) on_output: Callback for agent output (feature_id, line)
on_status: Callback for agent status changes (feature_id, status) on_status: Callback for agent status changes (feature_id, status)
@@ -178,8 +178,8 @@ class ParallelOrchestrator:
self.model = model self.model = model
self.yolo_mode = yolo_mode self.yolo_mode = yolo_mode
self.testing_agent_ratio = min(max(testing_agent_ratio, 0), 3) # Clamp 0-3 self.testing_agent_ratio = min(max(testing_agent_ratio, 0), 3) # Clamp 0-3
self.testing_batch_size = min(max(testing_batch_size, 1), 5) # Clamp 1-5 self.testing_batch_size = min(max(testing_batch_size, 1), 15) # Clamp 1-15
self.batch_size = min(max(batch_size, 1), 3) # Clamp 1-3 self.batch_size = min(max(batch_size, 1), 15) # Clamp 1-15
self.on_output = on_output self.on_output = on_output
self.on_status = on_status self.on_status = on_status
@@ -213,6 +213,9 @@ class ParallelOrchestrator:
# Signal handlers only set this flag; cleanup happens in the main loop # Signal handlers only set this flag; cleanup happens in the main loop
self._shutdown_requested = False self._shutdown_requested = False
# Graceful pause (drain mode) flag
self._drain_requested = False
# Session tracking for logging/debugging # Session tracking for logging/debugging
self.session_start_time: datetime | None = None self.session_start_time: datetime | None = None
@@ -493,6 +496,9 @@ class ParallelOrchestrator:
for fd in feature_dicts: for fd in feature_dicts:
if not fd.get("in_progress") or fd.get("passes"): if not fd.get("in_progress") or fd.get("passes"):
continue continue
# Skip if blocked for human input
if fd.get("needs_human_input"):
continue
# Skip if already running in this orchestrator instance # Skip if already running in this orchestrator instance
if fd["id"] in running_ids: if fd["id"] in running_ids:
continue continue
@@ -537,11 +543,14 @@ class ParallelOrchestrator:
running_ids.update(batch_ids) running_ids.update(batch_ids)
ready = [] ready = []
skipped_reasons = {"passes": 0, "in_progress": 0, "running": 0, "failed": 0, "deps": 0} skipped_reasons = {"passes": 0, "in_progress": 0, "running": 0, "failed": 0, "deps": 0, "needs_human_input": 0}
for fd in feature_dicts: for fd in feature_dicts:
if fd.get("passes"): if fd.get("passes"):
skipped_reasons["passes"] += 1 skipped_reasons["passes"] += 1
continue continue
if fd.get("needs_human_input"):
skipped_reasons["needs_human_input"] += 1
continue
if fd.get("in_progress"): if fd.get("in_progress"):
skipped_reasons["in_progress"] += 1 skipped_reasons["in_progress"] += 1
continue continue
@@ -1387,6 +1396,9 @@ class ParallelOrchestrator:
# Must happen before any debug_log.log() calls # Must happen before any debug_log.log() calls
debug_log.start_session() debug_log.start_session()
# Clear any stale drain signal from a previous session
self._clear_drain_signal()
# Log startup to debug file # Log startup to debug file
debug_log.section("ORCHESTRATOR STARTUP") debug_log.section("ORCHESTRATOR STARTUP")
debug_log.log("STARTUP", "Orchestrator run_loop starting", debug_log.log("STARTUP", "Orchestrator run_loop starting",
@@ -1508,6 +1520,34 @@ class ParallelOrchestrator:
print("\nAll features complete!", flush=True) print("\nAll features complete!", flush=True)
break break
# --- Graceful pause (drain mode) ---
if not self._drain_requested and self._check_drain_signal():
self._drain_requested = True
print("Graceful pause requested - draining running agents...", flush=True)
debug_log.log("DRAIN", "Graceful pause requested, draining running agents")
if self._drain_requested:
with self._lock:
coding_count = len(self.running_coding_agents)
testing_count = len(self.running_testing_agents)
if coding_count == 0 and testing_count == 0:
print("All agents drained - paused.", flush=True)
debug_log.log("DRAIN", "All agents drained, entering paused state")
# Wait until signal file is removed (resume) or shutdown
while self._check_drain_signal() and self.is_running and not self._shutdown_requested:
await asyncio.sleep(1)
if not self.is_running or self._shutdown_requested:
break
self._drain_requested = False
print("Resuming from graceful pause...", flush=True)
debug_log.log("DRAIN", "Resuming from graceful pause")
continue
else:
debug_log.log("DRAIN", f"Waiting for agents to finish: coding={coding_count}, testing={testing_count}")
await self._wait_for_agent_completion()
continue
# Maintain testing agents independently (runs every iteration) # Maintain testing agents independently (runs every iteration)
self._maintain_testing_agents(feature_dicts) self._maintain_testing_agents(feature_dicts)
@@ -1632,6 +1672,17 @@ class ParallelOrchestrator:
"yolo_mode": self.yolo_mode, "yolo_mode": self.yolo_mode,
} }
def _check_drain_signal(self) -> bool:
"""Check if the graceful pause (drain) signal file exists."""
from autoforge_paths import get_pause_drain_path
return get_pause_drain_path(self.project_dir).exists()
def _clear_drain_signal(self) -> None:
"""Delete the drain signal file and reset the flag."""
from autoforge_paths import get_pause_drain_path
get_pause_drain_path(self.project_dir).unlink(missing_ok=True)
self._drain_requested = False
def cleanup(self) -> None: def cleanup(self) -> None:
"""Clean up database resources. Safe to call multiple times. """Clean up database resources. Safe to call multiple times.

View File

@@ -62,26 +62,42 @@ def has_features(project_dir: Path) -> bool:
return False return False
def count_passing_tests(project_dir: Path) -> tuple[int, int, int]: def count_passing_tests(project_dir: Path) -> tuple[int, int, int, int]:
""" """
Count passing, in_progress, and total tests via direct database access. Count passing, in_progress, total, and needs_human_input tests via direct database access.
Args: Args:
project_dir: Directory containing the project project_dir: Directory containing the project
Returns: Returns:
(passing_count, in_progress_count, total_count) (passing_count, in_progress_count, total_count, needs_human_input_count)
""" """
from autoforge_paths import get_features_db_path from autoforge_paths import get_features_db_path
db_file = get_features_db_path(project_dir) db_file = get_features_db_path(project_dir)
if not db_file.exists(): if not db_file.exists():
return 0, 0, 0 return 0, 0, 0, 0
try: try:
with closing(_get_connection(db_file)) as conn: with closing(_get_connection(db_file)) as conn:
cursor = conn.cursor() cursor = conn.cursor()
# Single aggregate query instead of 3 separate COUNT queries # Single aggregate query instead of separate COUNT queries
# Handle case where in_progress column doesn't exist yet (legacy DBs) # Handle case where columns don't exist yet (legacy DBs)
try:
cursor.execute("""
SELECT
COUNT(*) as total,
SUM(CASE WHEN passes = 1 THEN 1 ELSE 0 END) as passing,
SUM(CASE WHEN in_progress = 1 THEN 1 ELSE 0 END) as in_progress,
SUM(CASE WHEN needs_human_input = 1 THEN 1 ELSE 0 END) as needs_human_input
FROM features
""")
row = cursor.fetchone()
total = row[0] or 0
passing = row[1] or 0
in_progress = row[2] or 0
needs_human_input = row[3] or 0
except sqlite3.OperationalError:
# Fallback for databases without newer columns
try: try:
cursor.execute(""" cursor.execute("""
SELECT SELECT
@@ -94,8 +110,8 @@ def count_passing_tests(project_dir: Path) -> tuple[int, int, int]:
total = row[0] or 0 total = row[0] or 0
passing = row[1] or 0 passing = row[1] or 0
in_progress = row[2] or 0 in_progress = row[2] or 0
needs_human_input = 0
except sqlite3.OperationalError: except sqlite3.OperationalError:
# Fallback for databases without in_progress column
cursor.execute(""" cursor.execute("""
SELECT SELECT
COUNT(*) as total, COUNT(*) as total,
@@ -106,10 +122,11 @@ def count_passing_tests(project_dir: Path) -> tuple[int, int, int]:
total = row[0] or 0 total = row[0] or 0
passing = row[1] or 0 passing = row[1] or 0
in_progress = 0 in_progress = 0
return passing, in_progress, total needs_human_input = 0
return passing, in_progress, total, needs_human_input
except Exception as e: except Exception as e:
print(f"[Database error in count_passing_tests: {e}]") print(f"[Database error in count_passing_tests: {e}]")
return 0, 0, 0 return 0, 0, 0, 0
def get_all_passing_features(project_dir: Path) -> list[dict]: def get_all_passing_features(project_dir: Path) -> list[dict]:
@@ -234,7 +251,7 @@ def print_session_header(session_num: int, is_initializer: bool) -> None:
def print_progress_summary(project_dir: Path) -> None: def print_progress_summary(project_dir: Path) -> None:
"""Print a summary of current progress.""" """Print a summary of current progress."""
passing, in_progress, total = count_passing_tests(project_dir) passing, in_progress, total, _needs_human_input = count_passing_tests(project_dir)
if total > 0: if total > 0:
percentage = (passing / total) * 100 percentage = (passing / total) * 100

View File

@@ -671,6 +671,7 @@ API_PROVIDERS: dict[str, dict[str, Any]] = {
"requires_auth": True, "requires_auth": True,
"auth_env_var": "ANTHROPIC_AUTH_TOKEN", "auth_env_var": "ANTHROPIC_AUTH_TOKEN",
"models": [ "models": [
{"id": "glm-5", "name": "GLM 5"},
{"id": "glm-4.7", "name": "GLM 4.7"}, {"id": "glm-4.7", "name": "GLM 4.7"},
{"id": "glm-4.5-air", "name": "GLM 4.5 Air"}, {"id": "glm-4.5-air", "name": "GLM 4.5 Air"},
], ],
@@ -743,7 +744,7 @@ def get_effective_sdk_env() -> dict[str, str]:
sdk_env[var] = value sdk_env[var] = value
return sdk_env return sdk_env
sdk_env: dict[str, str] = {} sdk_env = {}
# Explicitly clear credentials that could leak from the server process env. # Explicitly clear credentials that could leak from the server process env.
# For providers using ANTHROPIC_AUTH_TOKEN (GLM, Custom), clear ANTHROPIC_API_KEY. # For providers using ANTHROPIC_AUTH_TOKEN (GLM, Custom), clear ANTHROPIC_API_KEY.

View File

@@ -1,6 +1,6 @@
# Production runtime dependencies only # Production runtime dependencies only
# For development, use requirements.txt (includes ruff, mypy, pytest) # For development, use requirements.txt (includes ruff, mypy, pytest)
claude-agent-sdk>=0.1.0,<0.2.0 claude-agent-sdk>=0.1.39,<0.2.0
python-dotenv>=1.0.0 python-dotenv>=1.0.0
sqlalchemy>=2.0.0 sqlalchemy>=2.0.0
fastapi>=0.115.0 fastapi>=0.115.0
@@ -12,3 +12,7 @@ aiofiles>=24.0.0
apscheduler>=3.10.0,<4.0.0 apscheduler>=3.10.0,<4.0.0
pywinpty>=2.0.0; sys_platform == "win32" pywinpty>=2.0.0; sys_platform == "win32"
pyyaml>=6.0.0 pyyaml>=6.0.0
python-docx>=1.1.0
openpyxl>=3.1.0
PyPDF2>=3.0.0
python-pptx>=1.0.0

View File

@@ -1,4 +1,4 @@
claude-agent-sdk>=0.1.0,<0.2.0 claude-agent-sdk>=0.1.39,<0.2.0
python-dotenv>=1.0.0 python-dotenv>=1.0.0
sqlalchemy>=2.0.0 sqlalchemy>=2.0.0
fastapi>=0.115.0 fastapi>=0.115.0
@@ -10,6 +10,10 @@ aiofiles>=24.0.0
apscheduler>=3.10.0,<4.0.0 apscheduler>=3.10.0,<4.0.0
pywinpty>=2.0.0; sys_platform == "win32" pywinpty>=2.0.0; sys_platform == "win32"
pyyaml>=6.0.0 pyyaml>=6.0.0
python-docx>=1.1.0
openpyxl>=3.1.0
PyPDF2>=3.0.0
python-pptx>=1.0.0
# Dev dependencies # Dev dependencies
ruff>=0.8.0 ruff>=0.8.0

View File

@@ -36,6 +36,7 @@ from .routers import (
features_router, features_router,
filesystem_router, filesystem_router,
projects_router, projects_router,
scaffold_router,
schedules_router, schedules_router,
settings_router, settings_router,
spec_creation_router, spec_creation_router,
@@ -169,6 +170,7 @@ app.include_router(filesystem_router)
app.include_router(assistant_chat_router) app.include_router(assistant_chat_router)
app.include_router(settings_router) app.include_router(settings_router)
app.include_router(terminal_router) app.include_router(terminal_router)
app.include_router(scaffold_router)
# ============================================================================ # ============================================================================

View File

@@ -12,6 +12,7 @@ from .expand_project import router as expand_project_router
from .features import router as features_router from .features import router as features_router
from .filesystem import router as filesystem_router from .filesystem import router as filesystem_router
from .projects import router as projects_router from .projects import router as projects_router
from .scaffold import router as scaffold_router
from .schedules import router as schedules_router from .schedules import router as schedules_router
from .settings import router as settings_router from .settings import router as settings_router
from .spec_creation import router as spec_creation_router from .spec_creation import router as spec_creation_router
@@ -29,4 +30,5 @@ __all__ = [
"assistant_chat_router", "assistant_chat_router",
"settings_router", "settings_router",
"terminal_router", "terminal_router",
"scaffold_router",
] ]

View File

@@ -17,11 +17,11 @@ from ..utils.project_helpers import get_project_path as _get_project_path
from ..utils.validation import validate_project_name from ..utils.validation import validate_project_name
def _get_settings_defaults() -> tuple[bool, str, int, bool, int]: def _get_settings_defaults() -> tuple[bool, str, int, int, int]:
"""Get defaults from global settings. """Get defaults from global settings.
Returns: Returns:
Tuple of (yolo_mode, model, testing_agent_ratio, playwright_headless, batch_size) Tuple of (yolo_mode, model, testing_agent_ratio, batch_size, testing_batch_size)
""" """
import sys import sys
root = Path(__file__).parent.parent.parent root = Path(__file__).parent.parent.parent
@@ -40,14 +40,17 @@ def _get_settings_defaults() -> tuple[bool, str, int, bool, int]:
except (ValueError, TypeError): except (ValueError, TypeError):
testing_agent_ratio = 1 testing_agent_ratio = 1
playwright_headless = (settings.get("playwright_headless") or "true").lower() == "true"
try: try:
batch_size = int(settings.get("batch_size", "3")) batch_size = int(settings.get("batch_size", "3"))
except (ValueError, TypeError): except (ValueError, TypeError):
batch_size = 3 batch_size = 3
return yolo_mode, model, testing_agent_ratio, playwright_headless, batch_size try:
testing_batch_size = int(settings.get("testing_batch_size", "3"))
except (ValueError, TypeError):
testing_batch_size = 3
return yolo_mode, model, testing_agent_ratio, batch_size, testing_batch_size
router = APIRouter(prefix="/api/projects/{project_name}/agent", tags=["agent"]) router = APIRouter(prefix="/api/projects/{project_name}/agent", tags=["agent"])
@@ -96,7 +99,7 @@ async def start_agent(
manager = get_project_manager(project_name) manager = get_project_manager(project_name)
# Get defaults from global settings if not provided in request # Get defaults from global settings if not provided in request
default_yolo, default_model, default_testing_ratio, playwright_headless, default_batch_size = _get_settings_defaults() default_yolo, default_model, default_testing_ratio, default_batch_size, default_testing_batch_size = _get_settings_defaults()
yolo_mode = request.yolo_mode if request.yolo_mode is not None else default_yolo yolo_mode = request.yolo_mode if request.yolo_mode is not None else default_yolo
model = request.model if request.model else default_model model = request.model if request.model else default_model
@@ -104,14 +107,17 @@ async def start_agent(
testing_agent_ratio = request.testing_agent_ratio if request.testing_agent_ratio is not None else default_testing_ratio testing_agent_ratio = request.testing_agent_ratio if request.testing_agent_ratio is not None else default_testing_ratio
batch_size = default_batch_size batch_size = default_batch_size
testing_batch_size = default_testing_batch_size
# Always run headless - the embedded browser view panel replaces desktop windows
success, message = await manager.start( success, message = await manager.start(
yolo_mode=yolo_mode, yolo_mode=yolo_mode,
model=model, model=model,
max_concurrency=max_concurrency, max_concurrency=max_concurrency,
testing_agent_ratio=testing_agent_ratio, testing_agent_ratio=testing_agent_ratio,
playwright_headless=playwright_headless, playwright_headless=True,
batch_size=batch_size, batch_size=batch_size,
testing_batch_size=testing_batch_size,
) )
# Notify scheduler of manual start (to prevent auto-stop during scheduled window) # Notify scheduler of manual start (to prevent auto-stop during scheduled window)
@@ -175,3 +181,31 @@ async def resume_agent(project_name: str):
status=manager.status, status=manager.status,
message=message, message=message,
) )
@router.post("/graceful-pause", response_model=AgentActionResponse)
async def graceful_pause_agent(project_name: str):
"""Request a graceful pause (drain mode) - finish current work then pause."""
manager = get_project_manager(project_name)
success, message = await manager.graceful_pause()
return AgentActionResponse(
success=success,
status=manager.status,
message=message,
)
@router.post("/graceful-resume", response_model=AgentActionResponse)
async def graceful_resume_agent(project_name: str):
"""Resume from a graceful pause."""
manager = get_project_manager(project_name)
success, message = await manager.graceful_resume()
return AgentActionResponse(
success=success,
status=manager.status,
message=message,
)

View File

@@ -13,7 +13,7 @@ from typing import Optional
from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect
from pydantic import BaseModel, ValidationError from pydantic import BaseModel, ValidationError
from ..schemas import ImageAttachment from ..schemas import FileAttachment
from ..services.expand_chat_session import ( from ..services.expand_chat_session import (
ExpandChatSession, ExpandChatSession,
create_expand_session, create_expand_session,
@@ -181,12 +181,12 @@ async def expand_project_websocket(websocket: WebSocket, project_name: str):
user_content = message.get("content", "").strip() user_content = message.get("content", "").strip()
# Parse attachments if present # Parse attachments if present
attachments: list[ImageAttachment] = [] attachments: list[FileAttachment] = []
raw_attachments = message.get("attachments", []) raw_attachments = message.get("attachments", [])
if raw_attachments: if raw_attachments:
try: try:
for raw_att in raw_attachments: for raw_att in raw_attachments:
attachments.append(ImageAttachment(**raw_att)) attachments.append(FileAttachment(**raw_att))
except (ValidationError, Exception) as e: except (ValidationError, Exception) as e:
logger.warning(f"Invalid attachment data: {e}") logger.warning(f"Invalid attachment data: {e}")
await websocket.send_json({ await websocket.send_json({

View File

@@ -23,6 +23,7 @@ from ..schemas import (
FeatureListResponse, FeatureListResponse,
FeatureResponse, FeatureResponse,
FeatureUpdate, FeatureUpdate,
HumanInputResponse,
) )
from ..utils.project_helpers import get_project_path as _get_project_path from ..utils.project_helpers import get_project_path as _get_project_path
from ..utils.validation import validate_project_name from ..utils.validation import validate_project_name
@@ -104,6 +105,9 @@ def feature_to_response(f, passing_ids: set[int] | None = None) -> FeatureRespon
in_progress=f.in_progress if f.in_progress is not None else False, in_progress=f.in_progress if f.in_progress is not None else False,
blocked=blocked, blocked=blocked,
blocking_dependencies=blocking, blocking_dependencies=blocking,
needs_human_input=getattr(f, 'needs_human_input', False) or False,
human_input_request=getattr(f, 'human_input_request', None),
human_input_response=getattr(f, 'human_input_response', None),
) )
@@ -143,11 +147,14 @@ async def list_features(project_name: str):
pending = [] pending = []
in_progress = [] in_progress = []
done = [] done = []
needs_human_input_list = []
for f in all_features: for f in all_features:
feature_response = feature_to_response(f, passing_ids) feature_response = feature_to_response(f, passing_ids)
if f.passes: if f.passes:
done.append(feature_response) done.append(feature_response)
elif getattr(f, 'needs_human_input', False):
needs_human_input_list.append(feature_response)
elif f.in_progress: elif f.in_progress:
in_progress.append(feature_response) in_progress.append(feature_response)
else: else:
@@ -157,6 +164,7 @@ async def list_features(project_name: str):
pending=pending, pending=pending,
in_progress=in_progress, in_progress=in_progress,
done=done, done=done,
needs_human_input=needs_human_input_list,
) )
except HTTPException: except HTTPException:
raise raise
@@ -341,9 +349,11 @@ async def get_dependency_graph(project_name: str):
deps = f.dependencies or [] deps = f.dependencies or []
blocking = [d for d in deps if d not in passing_ids] blocking = [d for d in deps if d not in passing_ids]
status: Literal["pending", "in_progress", "done", "blocked"] status: Literal["pending", "in_progress", "done", "blocked", "needs_human_input"]
if f.passes: if f.passes:
status = "done" status = "done"
elif getattr(f, 'needs_human_input', False):
status = "needs_human_input"
elif blocking: elif blocking:
status = "blocked" status = "blocked"
elif f.in_progress: elif f.in_progress:
@@ -564,6 +574,71 @@ async def skip_feature(project_name: str, feature_id: int):
raise HTTPException(status_code=500, detail="Failed to skip feature") raise HTTPException(status_code=500, detail="Failed to skip feature")
@router.post("/{feature_id}/resolve-human-input", response_model=FeatureResponse)
async def resolve_human_input(project_name: str, feature_id: int, response: HumanInputResponse):
"""Resolve a human input request for a feature.
Validates all required fields have values, stores the response,
and returns the feature to the pending queue for agents to pick up.
"""
project_name = validate_project_name(project_name)
project_dir = _get_project_path(project_name)
if not project_dir:
raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")
if not project_dir.exists():
raise HTTPException(status_code=404, detail="Project directory not found")
_, Feature = _get_db_classes()
try:
with get_db_session(project_dir) as session:
feature = session.query(Feature).filter(Feature.id == feature_id).first()
if not feature:
raise HTTPException(status_code=404, detail=f"Feature {feature_id} not found")
if not getattr(feature, 'needs_human_input', False):
raise HTTPException(status_code=400, detail="Feature is not waiting for human input")
# Validate required fields
request_data = feature.human_input_request
if request_data and isinstance(request_data, dict):
for field_def in request_data.get("fields", []):
if field_def.get("required", True):
field_id = field_def.get("id")
if field_id not in response.fields or response.fields[field_id] in (None, ""):
raise HTTPException(
status_code=400,
detail=f"Required field '{field_def.get('label', field_id)}' is missing"
)
# Store response and return to pending queue
from datetime import datetime, timezone
response_data = {
"fields": {k: v for k, v in response.fields.items()},
"responded_at": datetime.now(timezone.utc).isoformat(),
}
feature.human_input_response = response_data
feature.needs_human_input = False
# Keep in_progress=False, passes=False so it returns to pending
session.commit()
session.refresh(feature)
# Compute passing IDs for response
all_features = session.query(Feature).all()
passing_ids = {f.id for f in all_features if f.passes}
return feature_to_response(feature, passing_ids)
except HTTPException:
raise
except Exception:
logger.exception("Failed to resolve human input")
raise HTTPException(status_code=500, detail="Failed to resolve human input")
# ============================================================================ # ============================================================================
# Dependency Management Endpoints # Dependency Management Endpoints
# ============================================================================ # ============================================================================

View File

@@ -102,7 +102,7 @@ def get_project_stats(project_dir: Path) -> ProjectStats:
"""Get statistics for a project.""" """Get statistics for a project."""
_init_imports() _init_imports()
assert _count_passing_tests is not None # guaranteed by _init_imports() assert _count_passing_tests is not None # guaranteed by _init_imports()
passing, in_progress, total = _count_passing_tests(project_dir) passing, in_progress, total, _needs_human_input = _count_passing_tests(project_dir)
percentage = (passing / total * 100) if total > 0 else 0.0 percentage = (passing / total * 100) if total > 0 else 0.0
return ProjectStats( return ProjectStats(
passing=passing, passing=passing,

136
server/routers/scaffold.py Normal file
View File

@@ -0,0 +1,136 @@
"""
Scaffold Router
================
SSE streaming endpoint for running project scaffold commands.
Supports templated project creation (e.g., Next.js agentic starter).
"""
import asyncio
import json
import logging
import shutil
import subprocess
import sys
from pathlib import Path
from fastapi import APIRouter, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from .filesystem import is_path_blocked
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/scaffold", tags=["scaffold"])
# Hardcoded templates — no arbitrary commands allowed
TEMPLATES: dict[str, list[str]] = {
"agentic-starter": ["npx", "create-agentic-app@latest", ".", "-y", "-p", "npm", "--skip-git"],
}
class ScaffoldRequest(BaseModel):
template: str
target_path: str
def _sse_event(data: dict) -> str:
"""Format a dict as an SSE data line."""
return f"data: {json.dumps(data)}\n\n"
async def _stream_scaffold(template: str, target_path: str, request: Request):
"""Run the scaffold command and yield SSE events."""
# Validate template
if template not in TEMPLATES:
yield _sse_event({"type": "error", "message": f"Unknown template: {template}"})
return
# Validate path
path = Path(target_path)
try:
path = path.resolve()
except (OSError, ValueError) as e:
yield _sse_event({"type": "error", "message": f"Invalid path: {e}"})
return
if is_path_blocked(path):
yield _sse_event({"type": "error", "message": "Access to this directory is not allowed"})
return
if not path.exists() or not path.is_dir():
yield _sse_event({"type": "error", "message": "Target directory does not exist"})
return
# Check npx is available
npx_name = "npx"
if sys.platform == "win32":
npx_name = "npx.cmd"
if not shutil.which(npx_name):
yield _sse_event({"type": "error", "message": "npx is not available. Please install Node.js."})
return
# Build command
argv = list(TEMPLATES[template])
if sys.platform == "win32" and not argv[0].lower().endswith(".cmd"):
argv[0] = argv[0] + ".cmd"
process = None
try:
popen_kwargs: dict = {
"stdout": subprocess.PIPE,
"stderr": subprocess.STDOUT,
"stdin": subprocess.DEVNULL,
"cwd": str(path),
}
if sys.platform == "win32":
popen_kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
process = subprocess.Popen(argv, **popen_kwargs)
logger.info("Scaffold process started: pid=%s, template=%s, path=%s", process.pid, template, target_path)
# Stream stdout lines
assert process.stdout is not None
for raw_line in iter(process.stdout.readline, b""):
# Check if client disconnected
if await request.is_disconnected():
logger.info("Client disconnected during scaffold, terminating process")
break
line = raw_line.decode("utf-8", errors="replace").rstrip("\n\r")
yield _sse_event({"type": "output", "line": line})
# Yield control to event loop so disconnect checks work
await asyncio.sleep(0)
process.wait()
exit_code = process.returncode
success = exit_code == 0
logger.info("Scaffold process completed: exit_code=%s, template=%s", exit_code, template)
yield _sse_event({"type": "complete", "success": success, "exit_code": exit_code})
except Exception as e:
logger.error("Scaffold error: %s", e)
yield _sse_event({"type": "error", "message": str(e)})
finally:
if process and process.poll() is None:
try:
process.terminate()
process.wait(timeout=5)
except Exception:
process.kill()
@router.post("/run")
async def run_scaffold(body: ScaffoldRequest, request: Request):
"""Run a scaffold template command with SSE streaming output."""
return StreamingResponse(
_stream_scaffold(body.template, body.target_path, request),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
)

View File

@@ -111,8 +111,9 @@ async def get_settings():
glm_mode=glm_mode, glm_mode=glm_mode,
ollama_mode=ollama_mode, ollama_mode=ollama_mode,
testing_agent_ratio=_parse_int(all_settings.get("testing_agent_ratio"), 1), testing_agent_ratio=_parse_int(all_settings.get("testing_agent_ratio"), 1),
playwright_headless=_parse_bool(all_settings.get("playwright_headless"), default=True), playwright_headless=True, # Always headless - embedded browser view replaces desktop windows
batch_size=_parse_int(all_settings.get("batch_size"), 3), batch_size=_parse_int(all_settings.get("batch_size"), 3),
testing_batch_size=_parse_int(all_settings.get("testing_batch_size"), 3),
api_provider=api_provider, api_provider=api_provider,
api_base_url=all_settings.get("api_base_url"), api_base_url=all_settings.get("api_base_url"),
api_has_auth_token=bool(all_settings.get("api_auth_token")), api_has_auth_token=bool(all_settings.get("api_auth_token")),
@@ -132,12 +133,15 @@ async def update_settings(update: SettingsUpdate):
if update.testing_agent_ratio is not None: if update.testing_agent_ratio is not None:
set_setting("testing_agent_ratio", str(update.testing_agent_ratio)) set_setting("testing_agent_ratio", str(update.testing_agent_ratio))
if update.playwright_headless is not None: # playwright_headless is no longer user-configurable; always headless
set_setting("playwright_headless", "true" if update.playwright_headless else "false") # with embedded browser view panel in the UI
if update.batch_size is not None: if update.batch_size is not None:
set_setting("batch_size", str(update.batch_size)) set_setting("batch_size", str(update.batch_size))
if update.testing_batch_size is not None:
set_setting("testing_batch_size", str(update.testing_batch_size))
# API provider settings # API provider settings
if update.api_provider is not None: if update.api_provider is not None:
old_provider = get_setting("api_provider", "claude") old_provider = get_setting("api_provider", "claude")
@@ -175,8 +179,9 @@ async def update_settings(update: SettingsUpdate):
glm_mode=glm_mode, glm_mode=glm_mode,
ollama_mode=ollama_mode, ollama_mode=ollama_mode,
testing_agent_ratio=_parse_int(all_settings.get("testing_agent_ratio"), 1), testing_agent_ratio=_parse_int(all_settings.get("testing_agent_ratio"), 1),
playwright_headless=_parse_bool(all_settings.get("playwright_headless"), default=True), playwright_headless=True, # Always headless - embedded browser view replaces desktop windows
batch_size=_parse_int(all_settings.get("batch_size"), 3), batch_size=_parse_int(all_settings.get("batch_size"), 3),
testing_batch_size=_parse_int(all_settings.get("testing_batch_size"), 3),
api_provider=api_provider, api_provider=api_provider,
api_base_url=all_settings.get("api_base_url"), api_base_url=all_settings.get("api_base_url"),
api_has_auth_token=bool(all_settings.get("api_auth_token")), api_has_auth_token=bool(all_settings.get("api_auth_token")),

View File

@@ -12,7 +12,7 @@ from typing import Optional
from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect
from pydantic import BaseModel, ValidationError from pydantic import BaseModel, ValidationError
from ..schemas import ImageAttachment from ..schemas import FileAttachment
from ..services.spec_chat_session import ( from ..services.spec_chat_session import (
SpecChatSession, SpecChatSession,
create_session, create_session,
@@ -242,12 +242,12 @@ async def spec_chat_websocket(websocket: WebSocket, project_name: str):
user_content = message.get("content", "").strip() user_content = message.get("content", "").strip()
# Parse attachments if present # Parse attachments if present
attachments: list[ImageAttachment] = [] attachments: list[FileAttachment] = []
raw_attachments = message.get("attachments", []) raw_attachments = message.get("attachments", [])
if raw_attachments: if raw_attachments:
try: try:
for raw_att in raw_attachments: for raw_att in raw_attachments:
attachments.append(ImageAttachment(**raw_att)) attachments.append(FileAttachment(**raw_att))
except (ValidationError, Exception) as e: except (ValidationError, Exception) as e:
logger.warning(f"Invalid attachment data: {e}") logger.warning(f"Invalid attachment data: {e}")
await websocket.send_json({ await websocket.send_json({

View File

@@ -11,7 +11,7 @@ from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Literal from typing import Literal
from pydantic import BaseModel, Field, field_validator from pydantic import BaseModel, Field, field_validator, model_validator
# Import model constants from registry (single source of truth) # Import model constants from registry (single source of truth)
_root = Path(__file__).parent.parent _root = Path(__file__).parent.parent
@@ -120,16 +120,41 @@ class FeatureResponse(FeatureBase):
in_progress: bool in_progress: bool
blocked: bool = False # Computed: has unmet dependencies blocked: bool = False # Computed: has unmet dependencies
blocking_dependencies: list[int] = Field(default_factory=list) # Computed blocking_dependencies: list[int] = Field(default_factory=list) # Computed
needs_human_input: bool = False
human_input_request: dict | None = None
human_input_response: dict | None = None
class Config: class Config:
from_attributes = True from_attributes = True
class HumanInputField(BaseModel):
"""Schema for a single human input field."""
id: str
label: str
type: Literal["text", "textarea", "select", "boolean"] = "text"
required: bool = True
placeholder: str | None = None
options: list[dict] | None = None # For select: [{value, label}]
class HumanInputRequest(BaseModel):
"""Schema for an agent's human input request."""
prompt: str
fields: list[HumanInputField]
class HumanInputResponse(BaseModel):
"""Schema for a human's response to an input request."""
fields: dict[str, str | bool | list[str]]
class FeatureListResponse(BaseModel): class FeatureListResponse(BaseModel):
"""Response containing list of features organized by status.""" """Response containing list of features organized by status."""
pending: list[FeatureResponse] pending: list[FeatureResponse]
in_progress: list[FeatureResponse] in_progress: list[FeatureResponse]
done: list[FeatureResponse] done: list[FeatureResponse]
needs_human_input: list[FeatureResponse] = Field(default_factory=list)
class FeatureBulkCreate(BaseModel): class FeatureBulkCreate(BaseModel):
@@ -153,7 +178,7 @@ class DependencyGraphNode(BaseModel):
id: int id: int
name: str name: str
category: str category: str
status: Literal["pending", "in_progress", "done", "blocked"] status: Literal["pending", "in_progress", "done", "blocked", "needs_human_input"]
priority: int priority: int
dependencies: list[int] dependencies: list[int]
@@ -217,7 +242,7 @@ class AgentStartRequest(BaseModel):
class AgentStatus(BaseModel): class AgentStatus(BaseModel):
"""Current agent status.""" """Current agent status."""
status: Literal["stopped", "running", "paused", "crashed"] status: Literal["stopped", "running", "paused", "crashed", "pausing", "paused_graceful"]
pid: int | None = None pid: int | None = None
started_at: datetime | None = None started_at: datetime | None = None
yolo_mode: bool = False yolo_mode: bool = False
@@ -257,6 +282,7 @@ class WSProgressMessage(BaseModel):
in_progress: int in_progress: int
total: int total: int
percentage: float percentage: float
needs_human_input: int = 0
class WSFeatureUpdateMessage(BaseModel): class WSFeatureUpdateMessage(BaseModel):
@@ -305,36 +331,61 @@ class WSAgentUpdateMessage(BaseModel):
# ============================================================================ # ============================================================================
# Spec Chat Schemas # Chat Attachment Schemas
# ============================================================================ # ============================================================================
# Maximum image file size: 5 MB # Size limits
MAX_IMAGE_SIZE = 5 * 1024 * 1024 MAX_IMAGE_SIZE = 5 * 1024 * 1024 # 5 MB for images
MAX_DOCUMENT_SIZE = 20 * 1024 * 1024 # 20 MB for documents
_IMAGE_MIME_TYPES = {'image/jpeg', 'image/png'}
class ImageAttachment(BaseModel): class FileAttachment(BaseModel):
"""Image attachment from client for spec creation chat.""" """File attachment from client for spec creation / expand project chat."""
filename: str = Field(..., min_length=1, max_length=255) filename: str = Field(..., min_length=1, max_length=255)
mimeType: Literal['image/jpeg', 'image/png'] mimeType: Literal[
'image/jpeg', 'image/png',
'text/plain', 'text/markdown', 'text/csv',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/pdf',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
]
base64Data: str base64Data: str
@field_validator('base64Data') @field_validator('base64Data')
@classmethod @classmethod
def validate_base64_and_size(cls, v: str) -> str: def validate_base64(cls, v: str) -> str:
"""Validate that base64 data is valid and within size limit.""" """Validate that base64 data is decodable."""
try: try:
decoded = base64.b64decode(v) base64.b64decode(v)
if len(decoded) > MAX_IMAGE_SIZE:
raise ValueError(
f'Image size ({len(decoded) / (1024 * 1024):.1f} MB) exceeds '
f'maximum of {MAX_IMAGE_SIZE // (1024 * 1024)} MB'
)
return v return v
except Exception as e: except Exception as e:
if 'Image size' in str(e):
raise
raise ValueError(f'Invalid base64 data: {e}') raise ValueError(f'Invalid base64 data: {e}')
@model_validator(mode='after')
def validate_size(self) -> 'FileAttachment':
"""Validate file size based on MIME type."""
try:
decoded = base64.b64decode(self.base64Data)
except Exception:
return self # Already caught by field validator
if self.mimeType in _IMAGE_MIME_TYPES:
max_size = MAX_IMAGE_SIZE
label = "Image"
else:
max_size = MAX_DOCUMENT_SIZE
label = "Document"
if len(decoded) > max_size:
raise ValueError(
f'{label} size ({len(decoded) / (1024 * 1024):.1f} MB) exceeds '
f'maximum of {max_size // (1024 * 1024)} MB'
)
return self
# ============================================================================ # ============================================================================
# Filesystem Schemas # Filesystem Schemas
@@ -418,7 +469,8 @@ class SettingsResponse(BaseModel):
ollama_mode: bool = False # True when api_provider is "ollama" ollama_mode: bool = False # True when api_provider is "ollama"
testing_agent_ratio: int = 1 # Regression testing agents (0-3) testing_agent_ratio: int = 1 # Regression testing agents (0-3)
playwright_headless: bool = True playwright_headless: bool = True
batch_size: int = 3 # Features per coding agent batch (1-3) batch_size: int = 3 # Features per coding agent batch (1-15)
testing_batch_size: int = 3 # Features per testing agent batch (1-15)
api_provider: str = "claude" api_provider: str = "claude"
api_base_url: str | None = None api_base_url: str | None = None
api_has_auth_token: bool = False # Never expose actual token api_has_auth_token: bool = False # Never expose actual token
@@ -437,7 +489,8 @@ class SettingsUpdate(BaseModel):
model: str | None = None model: str | None = None
testing_agent_ratio: int | None = None # 0-3 testing_agent_ratio: int | None = None # 0-3
playwright_headless: bool | None = None playwright_headless: bool | None = None
batch_size: int | None = None # Features per agent batch (1-3) batch_size: int | None = None # Features per agent batch (1-15)
testing_batch_size: int | None = None # Features per testing agent batch (1-15)
api_provider: str | None = None api_provider: str | None = None
api_base_url: str | None = Field(None, max_length=500) api_base_url: str | None = Field(None, max_length=500)
api_auth_token: str | None = Field(None, max_length=500) # Write-only, never returned api_auth_token: str | None = Field(None, max_length=500) # Write-only, never returned
@@ -474,8 +527,15 @@ class SettingsUpdate(BaseModel):
@field_validator('batch_size') @field_validator('batch_size')
@classmethod @classmethod
def validate_batch_size(cls, v: int | None) -> int | None: def validate_batch_size(cls, v: int | None) -> int | None:
if v is not None and (v < 1 or v > 3): if v is not None and (v < 1 or v > 15):
raise ValueError("batch_size must be between 1 and 3") raise ValueError("batch_size must be between 1 and 15")
return v
@field_validator('testing_batch_size')
@classmethod
def validate_testing_batch_size(cls, v: int | None) -> int | None:
if v is not None and (v < 1 or v > 15):
raise ValueError("testing_batch_size must be between 1 and 15")
return v return v

View File

@@ -25,7 +25,11 @@ from .assistant_database import (
create_conversation, create_conversation,
get_messages, get_messages,
) )
from .chat_constants import ROOT_DIR from .chat_constants import (
ROOT_DIR,
check_rate_limit_error,
safe_receive_response,
)
# Load environment variables from .env file if present # Load environment variables from .env file if present
load_dotenv() load_dotenv()
@@ -394,7 +398,8 @@ class AssistantChatSession:
full_response = "" full_response = ""
# Stream the response # Stream the response
async for msg in self.client.receive_response(): try:
async for msg in safe_receive_response(self.client, logger):
msg_type = type(msg).__name__ msg_type = type(msg).__name__
if msg_type == "AssistantMessage" and hasattr(msg, "content"): if msg_type == "AssistantMessage" and hasattr(msg, "content"):
@@ -426,6 +431,13 @@ class AssistantChatSession:
"tool": tool_name, "tool": tool_name,
"input": tool_input, "input": tool_input,
} }
except Exception as exc:
is_rate_limit, _ = check_rate_limit_error(exc)
if is_rate_limit:
logger.warning(f"Rate limited: {exc}")
yield {"type": "error", "content": "Rate limited. Please try again later."}
return
raise
# Store the complete response in the database # Store the complete response in the database
if full_response and self.conversation_id: if full_response and self.conversation_id:

View File

@@ -0,0 +1,280 @@
"""
Browser View Service
====================
Captures periodic screenshots from active playwright-cli browser sessions
and streams them to the UI via WebSocket callbacks.
Each agent gets an isolated browser session (e.g., coding-5, testing-0).
This service polls those sessions with `playwright-cli screenshot` and
delivers the frames to subscribed UI clients.
"""
import asyncio
import base64
import logging
import shutil
import threading
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Awaitable, Callable
logger = logging.getLogger(__name__)
POLL_INTERVAL = 2.0 # seconds between screenshot captures
BACKOFF_INTERVAL = 10.0 # seconds after repeated failures
MAX_FAILURES_BEFORE_BACKOFF = 10
MAX_FAILURES_BEFORE_STOP = 90 # ~3 minutes at normal rate before giving up
SCREENSHOT_TIMEOUT = 5 # seconds
@dataclass
class SessionInfo:
"""Metadata for an active browser session."""
session_name: str
agent_index: int
agent_type: str # "coding" or "testing"
feature_id: int
feature_name: str
consecutive_failures: int = 0
stopped: bool = False
@dataclass
class ScreenshotData:
"""A captured screenshot ready for delivery."""
session_name: str
agent_index: int
agent_type: str
feature_id: int
feature_name: str
image_base64: str # base64-encoded PNG
timestamp: str
class BrowserViewService:
"""Manages screenshot capture for active agent browser sessions.
Follows the same singleton-per-project pattern as DevServerProcessManager.
"""
def __init__(self, project_name: str, project_dir: Path):
self.project_name = project_name
self.project_dir = project_dir
self._active_sessions: dict[str, SessionInfo] = {}
self._subscribers = 0
self._poll_task: asyncio.Task | None = None
self._screenshot_callbacks: set[Callable[[ScreenshotData], Awaitable[None]]] = set()
self._lock = asyncio.Lock()
self._playwright_cli: str | None = None
def _get_playwright_cli(self) -> str | None:
"""Find playwright-cli executable."""
if self._playwright_cli is not None:
return self._playwright_cli
path = shutil.which("playwright-cli")
if path:
self._playwright_cli = path
else:
logger.warning("playwright-cli not found in PATH; browser view disabled")
return self._playwright_cli
async def register_session(
self,
session_name: str,
agent_index: int,
agent_type: str,
feature_id: int,
feature_name: str,
) -> None:
"""Register an agent's browser session for screenshot capture."""
async with self._lock:
self._active_sessions[session_name] = SessionInfo(
session_name=session_name,
agent_index=agent_index,
agent_type=agent_type,
feature_id=feature_id,
feature_name=feature_name,
)
logger.debug("Registered browser session: %s", session_name)
async def unregister_session(self, session_name: str) -> None:
"""Unregister a browser session when agent completes."""
async with self._lock:
removed = self._active_sessions.pop(session_name, None)
if removed:
logger.debug("Unregistered browser session: %s", session_name)
# Clean up screenshot file
self._cleanup_screenshot_file(session_name)
def add_screenshot_callback(self, callback: Callable[[ScreenshotData], Awaitable[None]]) -> None:
self._screenshot_callbacks.add(callback)
def remove_screenshot_callback(self, callback: Callable[[ScreenshotData], Awaitable[None]]) -> None:
self._screenshot_callbacks.discard(callback)
async def add_subscriber(self) -> None:
"""Called when a UI client wants browser screenshots."""
async with self._lock:
self._subscribers += 1
if self._subscribers == 1:
self._start_polling()
async def remove_subscriber(self) -> None:
"""Called when a UI client stops wanting screenshots."""
async with self._lock:
self._subscribers = max(0, self._subscribers - 1)
if self._subscribers == 0:
self._stop_polling()
async def stop(self) -> None:
"""Clean up all sessions and stop polling."""
async with self._lock:
for session_name in list(self._active_sessions):
self._cleanup_screenshot_file(session_name)
self._active_sessions.clear()
self._stop_polling()
def _start_polling(self) -> None:
"""Start the screenshot polling loop."""
if self._poll_task is not None and not self._poll_task.done():
return
self._poll_task = asyncio.create_task(self._poll_loop())
logger.info("Started browser screenshot polling for %s", self.project_name)
def _stop_polling(self) -> None:
"""Stop the screenshot polling loop."""
if self._poll_task is not None and not self._poll_task.done():
self._poll_task.cancel()
self._poll_task = None
logger.info("Stopped browser screenshot polling for %s", self.project_name)
async def _poll_loop(self) -> None:
"""Main polling loop - capture screenshots for all active sessions."""
try:
while True:
async with self._lock:
sessions = list(self._active_sessions.values())
if sessions and self._screenshot_callbacks:
# Capture screenshots with limited concurrency
sem = asyncio.Semaphore(3)
async def capture_with_sem(session: SessionInfo) -> None:
async with sem:
await self._capture_and_deliver(session)
await asyncio.gather(
*(capture_with_sem(s) for s in sessions if not s.stopped),
return_exceptions=True,
)
await asyncio.sleep(POLL_INTERVAL)
except asyncio.CancelledError:
pass
except Exception:
logger.warning("Browser screenshot polling crashed", exc_info=True)
async def _capture_and_deliver(self, session: SessionInfo) -> None:
"""Capture a screenshot for a session and deliver to callbacks."""
cli = self._get_playwright_cli()
if not cli:
return
# Determine interval based on failure count
if session.consecutive_failures >= MAX_FAILURES_BEFORE_BACKOFF:
# In backoff mode - only capture every BACKOFF_INTERVAL/POLL_INTERVAL polls
# We achieve this by checking a simple modulo on failure count
if session.consecutive_failures % int(BACKOFF_INTERVAL / POLL_INTERVAL) != 0:
return
screenshot_dir = self.project_dir / ".playwright-cli"
screenshot_dir.mkdir(parents=True, exist_ok=True)
screenshot_path = screenshot_dir / f"_view_{session.session_name}.png"
try:
proc = await asyncio.create_subprocess_exec(
cli, "-s", session.session_name, "screenshot",
f"--filename={screenshot_path}",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=str(self.project_dir),
)
_, stderr = await asyncio.wait_for(proc.communicate(), timeout=SCREENSHOT_TIMEOUT)
if proc.returncode != 0:
session.consecutive_failures += 1
if session.consecutive_failures >= MAX_FAILURES_BEFORE_STOP:
session.stopped = True
logger.debug(
"Stopped polling session %s after %d failures",
session.session_name, session.consecutive_failures,
)
return
# Read and encode the screenshot
if not screenshot_path.exists():
session.consecutive_failures += 1
return
image_bytes = screenshot_path.read_bytes()
image_base64 = base64.b64encode(image_bytes).decode("ascii")
# Reset failure counter on success
session.consecutive_failures = 0
# Re-enable if previously stopped
session.stopped = False
screenshot = ScreenshotData(
session_name=session.session_name,
agent_index=session.agent_index,
agent_type=session.agent_type,
feature_id=session.feature_id,
feature_name=session.feature_name,
image_base64=image_base64,
timestamp=datetime.now().isoformat(),
)
# Deliver to all callbacks
for callback in list(self._screenshot_callbacks):
try:
await callback(screenshot)
except Exception:
pass # Connection may be closed
except asyncio.TimeoutError:
session.consecutive_failures += 1
except Exception:
session.consecutive_failures += 1
finally:
# Clean up the screenshot file
try:
screenshot_path.unlink(missing_ok=True)
except Exception:
pass
def _cleanup_screenshot_file(self, session_name: str) -> None:
"""Remove a session's screenshot file."""
try:
path = self.project_dir / ".playwright-cli" / f"_view_{session_name}.png"
path.unlink(missing_ok=True)
except Exception:
pass
# ---------------------------------------------------------------------------
# Global instance management (thread-safe)
# ---------------------------------------------------------------------------
_services: dict[tuple[str, str], BrowserViewService] = {}
_services_lock = threading.Lock()
def get_browser_view_service(project_name: str, project_dir: Path) -> BrowserViewService:
"""Get or create a BrowserViewService for a project (thread-safe)."""
with _services_lock:
key = (project_name, str(project_dir.resolve()))
if key not in _services:
_services[key] = BrowserViewService(project_name, project_dir)
return _services[key]

View File

@@ -9,9 +9,10 @@ project root and is re-exported here for convenience so that existing
imports (``from .chat_constants import API_ENV_VARS``) continue to work. imports (``from .chat_constants import API_ENV_VARS``) continue to work.
""" """
import logging
import sys import sys
from pathlib import Path from pathlib import Path
from typing import AsyncGenerator from typing import Any, AsyncGenerator
# ------------------------------------------------------------------- # -------------------------------------------------------------------
# Root directory of the autoforge project (repository root). # Root directory of the autoforge project (repository root).
@@ -32,6 +33,95 @@ if _root_str not in sys.path:
# imports continue to work unchanged. # imports continue to work unchanged.
# ------------------------------------------------------------------- # -------------------------------------------------------------------
from env_constants import API_ENV_VARS # noqa: E402, F401 from env_constants import API_ENV_VARS # noqa: E402, F401
from rate_limit_utils import is_rate_limit_error, parse_retry_after # noqa: E402, F401
from ..schemas import FileAttachment
from ..utils.document_extraction import (
extract_text_from_document,
is_document,
is_image,
)
logger = logging.getLogger(__name__)
def check_rate_limit_error(exc: Exception) -> tuple[bool, int | None]:
"""Inspect an exception and determine if it represents a rate-limit.
Returns ``(is_rate_limit, retry_seconds)``. ``retry_seconds`` is the
parsed Retry-After value when available, otherwise ``None`` (caller
should use exponential backoff).
"""
# MessageParseError = unknown CLI message type (e.g. "rate_limit_event").
# These are informational events, NOT actual rate limit errors.
# The word "rate_limit" in the type name would false-positive the regex.
if type(exc).__name__ == "MessageParseError":
return False, None
# For all other exceptions: match error text against known rate-limit patterns
exc_str = str(exc)
if is_rate_limit_error(exc_str):
retry = parse_retry_after(exc_str)
return True, retry
return False, None
async def safe_receive_response(client: Any, log: logging.Logger) -> AsyncGenerator:
"""Wrap ``client.receive_response()`` to skip ``MessageParseError``.
The Claude Code CLI may emit message types (e.g. ``rate_limit_event``)
that the installed Python SDK does not recognise, causing
``MessageParseError`` which kills the async generator. The CLI
subprocess is still alive and the SDK uses a buffered memory channel,
so we restart ``receive_response()`` to continue reading remaining
messages without losing data.
"""
max_retries = 50
retries = 0
while True:
try:
async for msg in client.receive_response():
yield msg
return # Normal completion
except Exception as exc:
if type(exc).__name__ == "MessageParseError":
retries += 1
if retries > max_retries:
log.error(f"Too many unrecognized CLI messages ({retries}), stopping")
return
log.warning(f"Ignoring unrecognized message from Claude CLI: {exc}")
continue
raise
def build_attachment_content_blocks(attachments: list[FileAttachment]) -> list[dict]:
"""Convert FileAttachment objects to Claude API content blocks.
Images become image content blocks (passed directly to Claude's vision).
Documents are extracted to text and become text content blocks.
Raises:
DocumentExtractionError: If a document cannot be read.
"""
blocks: list[dict] = []
for att in attachments:
if is_image(att.mimeType):
blocks.append({
"type": "image",
"source": {
"type": "base64",
"media_type": att.mimeType,
"data": att.base64Data,
}
})
elif is_document(att.mimeType):
text = extract_text_from_document(att.base64Data, att.mimeType, att.filename)
blocks.append({
"type": "text",
"text": f"[Content of uploaded file: {att.filename}]\n\n{text}",
})
return blocks
async def make_multimodal_message(content_blocks: list[dict]) -> AsyncGenerator[dict, None]: async def make_multimodal_message(content_blocks: list[dict]) -> AsyncGenerator[dict, None]:

View File

@@ -21,8 +21,15 @@ from typing import Any, AsyncGenerator, Optional
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
from dotenv import load_dotenv from dotenv import load_dotenv
from ..schemas import ImageAttachment from ..schemas import FileAttachment
from .chat_constants import ROOT_DIR, make_multimodal_message from ..utils.document_extraction import DocumentExtractionError
from .chat_constants import (
ROOT_DIR,
build_attachment_content_blocks,
check_rate_limit_error,
make_multimodal_message,
safe_receive_response,
)
# Load environment variables from .env file if present # Load environment variables from .env file if present
load_dotenv() load_dotenv()
@@ -221,7 +228,7 @@ class ExpandChatSession:
async def send_message( async def send_message(
self, self,
user_message: str, user_message: str,
attachments: list[ImageAttachment] | None = None attachments: list[FileAttachment] | None = None
) -> AsyncGenerator[dict, None]: ) -> AsyncGenerator[dict, None]:
""" """
Send user message and stream Claude's response. Send user message and stream Claude's response.
@@ -268,7 +275,7 @@ class ExpandChatSession:
async def _query_claude( async def _query_claude(
self, self,
message: str, message: str,
attachments: list[ImageAttachment] | None = None attachments: list[FileAttachment] | None = None
) -> AsyncGenerator[dict, None]: ) -> AsyncGenerator[dict, None]:
""" """
Internal method to query Claude and stream responses. Internal method to query Claude and stream responses.
@@ -284,22 +291,22 @@ class ExpandChatSession:
content_blocks: list[dict[str, Any]] = [] content_blocks: list[dict[str, Any]] = []
if message: if message:
content_blocks.append({"type": "text", "text": message}) content_blocks.append({"type": "text", "text": message})
for att in attachments:
content_blocks.append({ # Add attachment blocks (images as image blocks, documents as extracted text)
"type": "image", try:
"source": { content_blocks.extend(build_attachment_content_blocks(attachments))
"type": "base64", except DocumentExtractionError as e:
"media_type": att.mimeType, yield {"type": "error", "content": str(e)}
"data": att.base64Data, return
}
})
await self.client.query(make_multimodal_message(content_blocks)) await self.client.query(make_multimodal_message(content_blocks))
logger.info(f"Sent multimodal message with {len(attachments)} image(s)") logger.info(f"Sent multimodal message with {len(attachments)} attachment(s)")
else: else:
await self.client.query(message) await self.client.query(message)
# Stream the response # Stream the response
async for msg in self.client.receive_response(): try:
async for msg in safe_receive_response(self.client, logger):
msg_type = type(msg).__name__ msg_type = type(msg).__name__
if msg_type == "AssistantMessage" and hasattr(msg, "content"): if msg_type == "AssistantMessage" and hasattr(msg, "content"):
@@ -316,6 +323,13 @@ class ExpandChatSession:
"content": text, "content": text,
"timestamp": datetime.now().isoformat() "timestamp": datetime.now().isoformat()
}) })
except Exception as exc:
is_rate_limit, _ = check_rate_limit_error(exc)
if is_rate_limit:
logger.warning(f"Rate limited: {exc}")
yield {"type": "error", "content": "Rate limited. Please try again later."}
return
raise
def get_features_created(self) -> int: def get_features_created(self) -> int:
"""Get the total number of features created in this session.""" """Get the total number of features created in this session."""

View File

@@ -77,7 +77,7 @@ class AgentProcessManager:
self.project_dir = project_dir self.project_dir = project_dir
self.root_dir = root_dir self.root_dir = root_dir
self.process: subprocess.Popen | None = None self.process: subprocess.Popen | None = None
self._status: Literal["stopped", "running", "paused", "crashed"] = "stopped" self._status: Literal["stopped", "running", "paused", "crashed", "pausing", "paused_graceful"] = "stopped"
self.started_at: datetime | None = None self.started_at: datetime | None = None
self._output_task: asyncio.Task | None = None self._output_task: asyncio.Task | None = None
self.yolo_mode: bool = False # YOLO mode for rapid prototyping self.yolo_mode: bool = False # YOLO mode for rapid prototyping
@@ -96,11 +96,11 @@ class AgentProcessManager:
self.lock_file = get_agent_lock_path(self.project_dir) self.lock_file = get_agent_lock_path(self.project_dir)
@property @property
def status(self) -> Literal["stopped", "running", "paused", "crashed"]: def status(self) -> Literal["stopped", "running", "paused", "crashed", "pausing", "paused_graceful"]:
return self._status return self._status
@status.setter @status.setter
def status(self, value: Literal["stopped", "running", "paused", "crashed"]): def status(self, value: Literal["stopped", "running", "paused", "crashed", "pausing", "paused_graceful"]):
old_status = self._status old_status = self._status
self._status = value self._status = value
if old_status != value: if old_status != value:
@@ -277,7 +277,7 @@ class AgentProcessManager:
).all() ).all()
if stuck: if stuck:
for f in stuck: for f in stuck:
f.in_progress = False f.in_progress = False # type: ignore[assignment]
session.commit() session.commit()
logger.info( logger.info(
"Cleaned up %d stuck feature(s) for %s", "Cleaned up %d stuck feature(s) for %s",
@@ -330,6 +330,12 @@ class AgentProcessManager:
for help_line in AUTH_ERROR_HELP.strip().split('\n'): for help_line in AUTH_ERROR_HELP.strip().split('\n'):
await self._broadcast_output(help_line) await self._broadcast_output(help_line)
# Detect graceful pause status transitions from orchestrator output
if "All agents drained - paused." in decoded:
self.status = "paused_graceful"
elif "Resuming from graceful pause..." in decoded:
self.status = "running"
await self._broadcast_output(sanitized) await self._broadcast_output(sanitized)
except asyncio.CancelledError: except asyncio.CancelledError:
@@ -340,7 +346,7 @@ class AgentProcessManager:
# Check if process ended # Check if process ended
if self.process and self.process.poll() is not None: if self.process and self.process.poll() is not None:
exit_code = self.process.returncode exit_code = self.process.returncode
if exit_code != 0 and self.status == "running": if exit_code != 0 and self.status in ("running", "pausing", "paused_graceful"):
# Check buffered output for auth errors if we haven't detected one yet # Check buffered output for auth errors if we haven't detected one yet
if not auth_error_detected: if not auth_error_detected:
combined_output = '\n'.join(output_buffer) combined_output = '\n'.join(output_buffer)
@@ -348,10 +354,16 @@ class AgentProcessManager:
for help_line in AUTH_ERROR_HELP.strip().split('\n'): for help_line in AUTH_ERROR_HELP.strip().split('\n'):
await self._broadcast_output(help_line) await self._broadcast_output(help_line)
self.status = "crashed" self.status = "crashed"
elif self.status == "running": elif self.status in ("running", "pausing", "paused_graceful"):
self.status = "stopped" self.status = "stopped"
self._cleanup_stale_features() self._cleanup_stale_features()
self._remove_lock() self._remove_lock()
# Clean up drain signal file if present
try:
from autoforge_paths import get_pause_drain_path
get_pause_drain_path(self.project_dir).unlink(missing_ok=True)
except Exception:
pass
async def start( async def start(
self, self,
@@ -362,6 +374,7 @@ class AgentProcessManager:
testing_agent_ratio: int = 1, testing_agent_ratio: int = 1,
playwright_headless: bool = True, playwright_headless: bool = True,
batch_size: int = 3, batch_size: int = 3,
testing_batch_size: int = 3,
) -> tuple[bool, str]: ) -> tuple[bool, str]:
""" """
Start the agent as a subprocess. Start the agent as a subprocess.
@@ -377,7 +390,7 @@ class AgentProcessManager:
Returns: Returns:
Tuple of (success, message) Tuple of (success, message)
""" """
if self.status in ("running", "paused"): if self.status in ("running", "paused", "pausing", "paused_graceful"):
return False, f"Agent is already {self.status}" return False, f"Agent is already {self.status}"
if not self._check_lock(): if not self._check_lock():
@@ -428,6 +441,9 @@ class AgentProcessManager:
# Add --batch-size flag for multi-feature batching # Add --batch-size flag for multi-feature batching
cmd.extend(["--batch-size", str(batch_size)]) cmd.extend(["--batch-size", str(batch_size)])
# Add --testing-batch-size flag for testing agent batching
cmd.extend(["--testing-batch-size", str(testing_batch_size)])
# Apply headless setting to .playwright/cli.config.json so playwright-cli # Apply headless setting to .playwright/cli.config.json so playwright-cli
# picks it up (the only mechanism it supports for headless control) # picks it up (the only mechanism it supports for headless control)
self._apply_playwright_headless(playwright_headless) self._apply_playwright_headless(playwright_headless)
@@ -526,6 +542,12 @@ class AgentProcessManager:
self._cleanup_stale_features() self._cleanup_stale_features()
self._remove_lock() self._remove_lock()
# Clean up drain signal file if present
try:
from autoforge_paths import get_pause_drain_path
get_pause_drain_path(self.project_dir).unlink(missing_ok=True)
except Exception:
pass
self.status = "stopped" self.status = "stopped"
self.process = None self.process = None
self.started_at = None self.started_at = None
@@ -586,6 +608,47 @@ class AgentProcessManager:
logger.exception("Failed to resume agent") logger.exception("Failed to resume agent")
return False, f"Failed to resume agent: {e}" return False, f"Failed to resume agent: {e}"
async def graceful_pause(self) -> tuple[bool, str]:
"""Request a graceful pause (drain mode).
Creates a signal file that the orchestrator polls. Running agents
finish their current work before the orchestrator enters a paused state.
Returns:
Tuple of (success, message)
"""
if not self.process or self.status not in ("running",):
return False, "Agent is not running"
try:
from autoforge_paths import get_pause_drain_path
drain_path = get_pause_drain_path(self.project_dir)
drain_path.parent.mkdir(parents=True, exist_ok=True)
drain_path.write_text(str(self.process.pid))
self.status = "pausing"
return True, "Graceful pause requested"
except Exception as e:
logger.exception("Failed to request graceful pause")
return False, f"Failed to request graceful pause: {e}"
async def graceful_resume(self) -> tuple[bool, str]:
"""Resume from a graceful pause by removing the drain signal file.
Returns:
Tuple of (success, message)
"""
if not self.process or self.status not in ("pausing", "paused_graceful"):
return False, "Agent is not in a graceful pause state"
try:
from autoforge_paths import get_pause_drain_path
get_pause_drain_path(self.project_dir).unlink(missing_ok=True)
self.status = "running"
return True, "Agent resumed from graceful pause"
except Exception as e:
logger.exception("Failed to resume from graceful pause")
return False, f"Failed to resume: {e}"
async def healthcheck(self) -> bool: async def healthcheck(self) -> bool:
""" """
Check if the agent process is still alive. Check if the agent process is still alive.
@@ -601,8 +664,14 @@ class AgentProcessManager:
poll = self.process.poll() poll = self.process.poll()
if poll is not None: if poll is not None:
# Process has terminated # Process has terminated
if self.status in ("running", "paused"): if self.status in ("running", "paused", "pausing", "paused_graceful"):
self._cleanup_stale_features() self._cleanup_stale_features()
# Clean up drain signal file if present
try:
from autoforge_paths import get_pause_drain_path
get_pause_drain_path(self.project_dir).unlink(missing_ok=True)
except Exception:
pass
self.status = "crashed" self.status = "crashed"
self._remove_lock() self._remove_lock()
return False return False
@@ -687,8 +756,14 @@ def cleanup_orphaned_locks() -> int:
if not project_path.exists(): if not project_path.exists():
continue continue
# Clean up stale drain signal files
from autoforge_paths import get_autoforge_dir, get_pause_drain_path
drain_file = get_pause_drain_path(project_path)
if drain_file.exists():
drain_file.unlink(missing_ok=True)
logger.info("Removed stale drain signal file for project '%s'", name)
# Check both legacy and new locations for lock files # Check both legacy and new locations for lock files
from autoforge_paths import get_autoforge_dir
lock_locations = [ lock_locations = [
project_path / ".agent.lock", project_path / ".agent.lock",
get_autoforge_dir(project_path) / ".agent.lock", get_autoforge_dir(project_path) / ".agent.lock",

View File

@@ -18,8 +18,15 @@ from typing import Any, AsyncGenerator, Optional
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
from dotenv import load_dotenv from dotenv import load_dotenv
from ..schemas import ImageAttachment from ..schemas import FileAttachment
from .chat_constants import ROOT_DIR, make_multimodal_message from ..utils.document_extraction import DocumentExtractionError
from .chat_constants import (
ROOT_DIR,
build_attachment_content_blocks,
check_rate_limit_error,
make_multimodal_message,
safe_receive_response,
)
# Load environment variables from .env file if present # Load environment variables from .env file if present
load_dotenv() load_dotenv()
@@ -196,7 +203,7 @@ class SpecChatSession:
async def send_message( async def send_message(
self, self,
user_message: str, user_message: str,
attachments: list[ImageAttachment] | None = None attachments: list[FileAttachment] | None = None
) -> AsyncGenerator[dict, None]: ) -> AsyncGenerator[dict, None]:
""" """
Send user message and stream Claude's response. Send user message and stream Claude's response.
@@ -242,7 +249,7 @@ class SpecChatSession:
async def _query_claude( async def _query_claude(
self, self,
message: str, message: str,
attachments: list[ImageAttachment] | None = None attachments: list[FileAttachment] | None = None
) -> AsyncGenerator[dict, None]: ) -> AsyncGenerator[dict, None]:
""" """
Internal method to query Claude and stream responses. Internal method to query Claude and stream responses.
@@ -268,21 +275,17 @@ class SpecChatSession:
if message: if message:
content_blocks.append({"type": "text", "text": message}) content_blocks.append({"type": "text", "text": message})
# Add image blocks # Add attachment blocks (images as image blocks, documents as extracted text)
for att in attachments: try:
content_blocks.append({ content_blocks.extend(build_attachment_content_blocks(attachments))
"type": "image", except DocumentExtractionError as e:
"source": { yield {"type": "error", "content": str(e)}
"type": "base64", return
"media_type": att.mimeType,
"data": att.base64Data,
}
})
# Send multimodal content to Claude using async generator format # Send multimodal content to Claude using async generator format
# The SDK's query() accepts AsyncIterable[dict] for custom message formats # The SDK's query() accepts AsyncIterable[dict] for custom message formats
await self.client.query(make_multimodal_message(content_blocks)) await self.client.query(make_multimodal_message(content_blocks))
logger.info(f"Sent multimodal message with {len(attachments)} image(s)") logger.info(f"Sent multimodal message with {len(attachments)} attachment(s)")
else: else:
# Text-only message: use string format # Text-only message: use string format
await self.client.query(message) await self.client.query(message)
@@ -304,8 +307,9 @@ class SpecChatSession:
# Store paths for the completion message # Store paths for the completion message
spec_path = None spec_path = None
# Stream the response using receive_response # Stream the response
async for msg in self.client.receive_response(): try:
async for msg in safe_receive_response(self.client, logger):
msg_type = type(msg).__name__ msg_type = type(msg).__name__
if msg_type == "AssistantMessage" and hasattr(msg, "content"): if msg_type == "AssistantMessage" and hasattr(msg, "content"):
@@ -415,6 +419,13 @@ class SpecChatSession:
"type": "spec_complete", "type": "spec_complete",
"path": str(spec_path) "path": str(spec_path)
} }
except Exception as exc:
is_rate_limit, _ = check_rate_limit_error(exc)
if is_rate_limit:
logger.warning(f"Rate limited: {exc}")
yield {"type": "error", "content": "Rate limited. Please try again later."}
return
raise
def is_complete(self) -> bool: def is_complete(self) -> bool:
"""Check if spec creation is complete.""" """Check if spec creation is complete."""

View File

@@ -0,0 +1,221 @@
"""
Document Extraction Utility
============================
Extracts text content from various document formats in memory (no disk I/O).
Supports: TXT, MD, CSV, DOCX, XLSX, PDF, PPTX.
"""
import base64
import csv
import io
import logging
logger = logging.getLogger(__name__)
# Maximum characters of extracted text to send to Claude
MAX_EXTRACTED_CHARS = 200_000
# Maximum rows per sheet for Excel files
MAX_EXCEL_ROWS_PER_SHEET = 10_000
MAX_EXCEL_SHEETS = 50
# MIME type classification
DOCUMENT_MIME_TYPES: dict[str, str] = {
"text/plain": ".txt",
"text/markdown": ".md",
"text/csv": ".csv",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
"application/pdf": ".pdf",
"application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx",
}
IMAGE_MIME_TYPES = {"image/jpeg", "image/png"}
ALL_ALLOWED_MIME_TYPES = IMAGE_MIME_TYPES | set(DOCUMENT_MIME_TYPES.keys())
def is_image(mime_type: str) -> bool:
"""Check if the MIME type is a supported image format."""
return mime_type in IMAGE_MIME_TYPES
def is_document(mime_type: str) -> bool:
"""Check if the MIME type is a supported document format."""
return mime_type in DOCUMENT_MIME_TYPES
class DocumentExtractionError(Exception):
"""Raised when text extraction from a document fails."""
def __init__(self, filename: str, reason: str):
self.filename = filename
self.reason = reason
super().__init__(f"Failed to read {filename}: {reason}")
def _truncate(text: str) -> str:
"""Truncate text if it exceeds the maximum character limit."""
if len(text) > MAX_EXTRACTED_CHARS:
omitted = len(text) - MAX_EXTRACTED_CHARS
return text[:MAX_EXTRACTED_CHARS] + f"\n\n[... truncated, {omitted:,} characters omitted]"
return text
def _extract_plain_text(data: bytes) -> str:
"""Extract text from plain text or markdown files."""
try:
return data.decode("utf-8")
except UnicodeDecodeError:
return data.decode("latin-1")
def _extract_csv(data: bytes) -> str:
"""Extract text from CSV files, formatted as a readable table."""
try:
text = data.decode("utf-8")
except UnicodeDecodeError:
text = data.decode("latin-1")
reader = csv.reader(io.StringIO(text))
lines = []
for i, row in enumerate(reader):
lines.append(f"Row {i + 1}: {', '.join(row)}")
return "\n".join(lines)
def _extract_docx(data: bytes) -> str:
"""Extract text from Word documents."""
from docx import Document
doc = Document(io.BytesIO(data))
paragraphs = [p.text for p in doc.paragraphs if p.text.strip()]
return "\n\n".join(paragraphs)
def _extract_xlsx(data: bytes) -> str:
"""Extract text from Excel spreadsheets."""
from openpyxl import load_workbook
wb = load_workbook(io.BytesIO(data), read_only=True, data_only=True)
sections = []
for sheet_idx, sheet_name in enumerate(wb.sheetnames):
if sheet_idx >= MAX_EXCEL_SHEETS:
sections.append(f"\n[... {len(wb.sheetnames) - MAX_EXCEL_SHEETS} more sheets omitted]")
break
ws = wb[sheet_name]
rows_text = [f"=== Sheet: {sheet_name} ==="]
row_count = 0
for row in ws.iter_rows(values_only=True):
if row_count >= MAX_EXCEL_ROWS_PER_SHEET:
rows_text.append(f"[... more rows omitted, limit {MAX_EXCEL_ROWS_PER_SHEET:,} rows/sheet]")
break
cells = [str(cell) if cell is not None else "" for cell in row]
rows_text.append("\t".join(cells))
row_count += 1
sections.append("\n".join(rows_text))
wb.close()
return "\n\n".join(sections)
def _extract_pdf(data: bytes, filename: str) -> str:
"""Extract text from PDF files."""
from PyPDF2 import PdfReader
from PyPDF2.errors import PdfReadError
try:
reader = PdfReader(io.BytesIO(data))
except PdfReadError as e:
if "encrypt" in str(e).lower() or "password" in str(e).lower():
raise DocumentExtractionError(filename, "PDF is password-protected")
raise
if reader.is_encrypted:
raise DocumentExtractionError(filename, "PDF is password-protected")
pages = []
for i, page in enumerate(reader.pages):
text = page.extract_text()
if text and text.strip():
pages.append(f"--- Page {i + 1} ---\n{text}")
return "\n\n".join(pages)
def _extract_pptx(data: bytes) -> str:
"""Extract text from PowerPoint presentations."""
from pptx import Presentation
prs = Presentation(io.BytesIO(data))
slides_text = []
for i, slide in enumerate(prs.slides):
texts = []
for shape in slide.shapes:
if shape.has_text_frame:
for paragraph in shape.text_frame.paragraphs:
text = paragraph.text.strip()
if text:
texts.append(text)
if texts:
slides_text.append(f"--- Slide {i + 1} ---\n" + "\n".join(texts))
return "\n\n".join(slides_text)
def extract_text_from_document(base64_data: str, mime_type: str, filename: str) -> str:
"""
Extract text content from a document file.
Args:
base64_data: Base64-encoded file content
mime_type: MIME type of the document
filename: Original filename (for error messages)
Returns:
Extracted text content, truncated if necessary
Raises:
DocumentExtractionError: If extraction fails
"""
if mime_type not in DOCUMENT_MIME_TYPES:
raise DocumentExtractionError(filename, f"unsupported document type: {mime_type}")
try:
data = base64.b64decode(base64_data)
except Exception as e:
raise DocumentExtractionError(filename, f"invalid base64 data: {e}")
try:
if mime_type in ("text/plain", "text/markdown"):
text = _extract_plain_text(data)
elif mime_type == "text/csv":
text = _extract_csv(data)
elif mime_type == "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
text = _extract_docx(data)
elif mime_type == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet":
text = _extract_xlsx(data)
elif mime_type == "application/pdf":
text = _extract_pdf(data, filename)
elif mime_type == "application/vnd.openxmlformats-officedocument.presentationml.presentation":
text = _extract_pptx(data)
else:
raise DocumentExtractionError(filename, f"unsupported document type: {mime_type}")
except DocumentExtractionError:
raise
except Exception as e:
logger.warning(f"Document extraction failed for {filename}: {e}")
raise DocumentExtractionError(
filename, "file appears to be corrupt or in an unexpected format"
)
if not text or not text.strip():
return f"[File {filename} is empty or contains no extractable text]"
return _truncate(text)

View File

@@ -16,6 +16,7 @@ from typing import Set
from fastapi import WebSocket, WebSocketDisconnect from fastapi import WebSocket, WebSocketDisconnect
from .schemas import AGENT_MASCOTS from .schemas import AGENT_MASCOTS
from .services.browser_view_service import get_browser_view_service
from .services.chat_constants import ROOT_DIR from .services.chat_constants import ROOT_DIR
from .services.dev_server_manager import get_devserver_manager from .services.dev_server_manager import get_devserver_manager
from .services.process_manager import get_manager from .services.process_manager import get_manager
@@ -61,7 +62,7 @@ THOUGHT_PATTERNS = [
(re.compile(r'(?:Testing|Verifying|Running tests|Validating)\s+(.+)', re.I), 'testing'), (re.compile(r'(?:Testing|Verifying|Running tests|Validating)\s+(.+)', re.I), 'testing'),
(re.compile(r'(?:Error|Failed|Cannot|Unable to|Exception)\s+(.+)', re.I), 'struggling'), (re.compile(r'(?:Error|Failed|Cannot|Unable to|Exception)\s+(.+)', re.I), 'struggling'),
# Test results # Test results
(re.compile(r'(?:PASS|passed|success)', re.I), 'success'), (re.compile(r'(?:PASS|passed|success)', re.I), 'testing'),
(re.compile(r'(?:FAIL|failed|error)', re.I), 'struggling'), (re.compile(r'(?:FAIL|failed|error)', re.I), 'struggling'),
] ]
@@ -78,6 +79,9 @@ ORCHESTRATOR_PATTERNS = {
'testing_complete': re.compile(r'Feature #(\d+) testing (completed|failed)'), 'testing_complete': re.compile(r'Feature #(\d+) testing (completed|failed)'),
'all_complete': re.compile(r'All features complete'), 'all_complete': re.compile(r'All features complete'),
'blocked_features': re.compile(r'(\d+) blocked by dependencies'), 'blocked_features': re.compile(r'(\d+) blocked by dependencies'),
'drain_start': re.compile(r'Graceful pause requested'),
'drain_complete': re.compile(r'All agents drained'),
'drain_resume': re.compile(r'Resuming from graceful pause'),
} }
@@ -562,6 +566,30 @@ class OrchestratorTracker:
'All features complete!' 'All features complete!'
) )
# Graceful pause (drain mode) events
elif ORCHESTRATOR_PATTERNS['drain_start'].search(line):
self.state = 'draining'
update = self._create_update(
'drain_start',
'Draining active agents...'
)
elif ORCHESTRATOR_PATTERNS['drain_complete'].search(line):
self.state = 'paused'
self.coding_agents = 0
self.testing_agents = 0
update = self._create_update(
'drain_complete',
'All agents drained. Paused.'
)
elif ORCHESTRATOR_PATTERNS['drain_resume'].search(line):
self.state = 'scheduling'
update = self._create_update(
'drain_resume',
'Resuming feature scheduling'
)
return update return update
def _create_update( def _create_update(
@@ -689,15 +717,19 @@ async def poll_progress(websocket: WebSocket, project_name: str, project_dir: Pa
last_in_progress = -1 last_in_progress = -1
last_total = -1 last_total = -1
last_needs_human_input = -1
while True: while True:
try: try:
passing, in_progress, total = count_passing_tests(project_dir) passing, in_progress, total, needs_human_input = count_passing_tests(project_dir)
# Only send if changed # Only send if changed
if passing != last_passing or in_progress != last_in_progress or total != last_total: if (passing != last_passing or in_progress != last_in_progress
or total != last_total or needs_human_input != last_needs_human_input):
last_passing = passing last_passing = passing
last_in_progress = in_progress last_in_progress = in_progress
last_total = total last_total = total
last_needs_human_input = needs_human_input
percentage = (passing / total * 100) if total > 0 else 0 percentage = (passing / total * 100) if total > 0 else 0
await websocket.send_json({ await websocket.send_json({
@@ -706,6 +738,7 @@ async def poll_progress(websocket: WebSocket, project_name: str, project_dir: Pa
"in_progress": in_progress, "in_progress": in_progress,
"total": total, "total": total,
"percentage": round(percentage, 1), "percentage": round(percentage, 1),
"needs_human_input": needs_human_input,
}) })
await asyncio.sleep(2) # Poll every 2 seconds await asyncio.sleep(2) # Poll every 2 seconds
@@ -755,8 +788,39 @@ async def project_websocket(websocket: WebSocket, project_name: str):
# Create orchestrator tracker for observability # Create orchestrator tracker for observability
orchestrator_tracker = OrchestratorTracker() orchestrator_tracker = OrchestratorTracker()
# Get browser view service for embedded browser screenshots
browser_view_service = get_browser_view_service(project_name, project_dir)
browser_view_subscribed = False
# Counter to mirror orchestrator's testing session naming (testing-0, testing-1, ...)
testing_session_counter = 0
# Deferred session registration: store metadata at agent start, register on first browser command.
# This avoids premature polling failures when agents spend time reading/planning before opening a browser.
# Key: session_name -> registration kwargs
pending_browser_sessions: dict[str, dict] = {}
# Track which feature IDs map to which session names (for deferred lookup)
feature_to_session: dict[int, str] = {}
async def on_screenshot(screenshot):
"""Handle browser screenshot - send to this WebSocket."""
try:
await websocket.send_json({
"type": "browser_screenshot",
"sessionName": screenshot.session_name,
"agentIndex": screenshot.agent_index,
"agentType": screenshot.agent_type,
"featureId": screenshot.feature_id,
"featureName": screenshot.feature_name,
"imageData": screenshot.image_base64,
"timestamp": screenshot.timestamp,
})
except Exception:
pass # Connection may be closed
browser_view_service.add_screenshot_callback(on_screenshot)
async def on_output(line: str): async def on_output(line: str):
"""Handle agent output - broadcast to this WebSocket.""" """Handle agent output - broadcast to this WebSocket."""
nonlocal testing_session_counter
try: try:
# Extract feature ID from line if present # Extract feature ID from line if present
feature_id = None feature_id = None
@@ -785,6 +849,48 @@ async def project_websocket(websocket: WebSocket, project_name: str):
if agent_update: if agent_update:
await websocket.send_json(agent_update) await websocket.send_json(agent_update)
# Register/unregister browser sessions based on agent lifecycle
update_state = agent_update.get("state")
update_type = agent_update.get("agentType", "coding")
update_feature_id = agent_update.get("featureId", 0)
update_feature_name = agent_update.get("featureName", "")
update_agent_index = agent_update.get("agentIndex", 0)
if update_state == "thinking" and agent_update.get("thought") in ("Starting work...", "Starting batch work..."):
# Agent just started - defer browser session registration until
# we detect an actual playwright-cli open/goto command. This avoids
# polling failures while the agent is still reading code / planning.
if update_type == "coding":
session_name = f"coding-{update_feature_id}"
else:
session_name = f"testing-{testing_session_counter}"
testing_session_counter += 1
pending_browser_sessions[session_name] = dict(
session_name=session_name,
agent_index=update_agent_index,
agent_type=update_type,
feature_id=update_feature_id,
feature_name=update_feature_name,
)
feature_to_session[update_feature_id] = session_name
elif update_state in ("success", "error"):
# Agent completed - unregister browser session
if update_type == "coding":
session_name = f"coding-{update_feature_id}"
await browser_view_service.unregister_session(session_name)
pending_browser_sessions.pop(session_name, None)
feature_to_session.pop(update_feature_id, None)
# Testing sessions are cleaned up on orchestrator stop
# Detect playwright-cli browser commands and activate deferred sessions
if feature_id is not None and "playwright-cli" in line and any(
kw in line for kw in ("open ", "goto ", "open\t", "goto\t")
):
sess_name = feature_to_session.get(feature_id)
if sess_name and sess_name in pending_browser_sessions:
reg = pending_browser_sessions.pop(sess_name)
await browser_view_service.register_session(**reg)
# Also check for orchestrator events and emit orchestrator_update messages # Also check for orchestrator events and emit orchestrator_update messages
orch_update = await orchestrator_tracker.process_line(line) orch_update = await orchestrator_tracker.process_line(line)
if orch_update: if orch_update:
@@ -794,6 +900,7 @@ async def project_websocket(websocket: WebSocket, project_name: str):
async def on_status_change(status: str): async def on_status_change(status: str):
"""Handle status change - broadcast to this WebSocket.""" """Handle status change - broadcast to this WebSocket."""
nonlocal testing_session_counter
try: try:
await websocket.send_json({ await websocket.send_json({
"type": "agent_status", "type": "agent_status",
@@ -803,6 +910,10 @@ async def project_websocket(websocket: WebSocket, project_name: str):
if status in ("stopped", "crashed"): if status in ("stopped", "crashed"):
await agent_tracker.reset() await agent_tracker.reset()
await orchestrator_tracker.reset() await orchestrator_tracker.reset()
await browser_view_service.stop()
testing_session_counter = 0
pending_browser_sessions.clear()
feature_to_session.clear()
except Exception: except Exception:
pass # Connection may be closed pass # Connection may be closed
@@ -858,7 +969,7 @@ async def project_websocket(websocket: WebSocket, project_name: str):
# Send initial progress # Send initial progress
count_passing_tests = _get_count_passing_tests() count_passing_tests = _get_count_passing_tests()
passing, in_progress, total = count_passing_tests(project_dir) passing, in_progress, total, needs_human_input = count_passing_tests(project_dir)
percentage = (passing / total * 100) if total > 0 else 0 percentage = (passing / total * 100) if total > 0 else 0
await websocket.send_json({ await websocket.send_json({
"type": "progress", "type": "progress",
@@ -866,6 +977,7 @@ async def project_websocket(websocket: WebSocket, project_name: str):
"in_progress": in_progress, "in_progress": in_progress,
"total": total, "total": total,
"percentage": round(percentage, 1), "percentage": round(percentage, 1),
"needs_human_input": needs_human_input,
}) })
# Keep connection alive and handle incoming messages # Keep connection alive and handle incoming messages
@@ -875,10 +987,23 @@ async def project_websocket(websocket: WebSocket, project_name: str):
data = await websocket.receive_text() data = await websocket.receive_text()
message = json.loads(data) message = json.loads(data)
msg_type = message.get("type")
# Handle ping # Handle ping
if message.get("type") == "ping": if msg_type == "ping":
await websocket.send_json({"type": "pong"}) await websocket.send_json({"type": "pong"})
# Handle browser view subscribe/unsubscribe
elif msg_type == "browser_view_subscribe":
if not browser_view_subscribed:
browser_view_subscribed = True
await browser_view_service.add_subscriber()
elif msg_type == "browser_view_unsubscribe":
if browser_view_subscribed:
browser_view_subscribed = False
await browser_view_service.remove_subscriber()
except WebSocketDisconnect: except WebSocketDisconnect:
break break
except json.JSONDecodeError: except json.JSONDecodeError:
@@ -902,5 +1027,10 @@ async def project_websocket(websocket: WebSocket, project_name: str):
devserver_manager.remove_output_callback(on_dev_output) devserver_manager.remove_output_callback(on_dev_output)
devserver_manager.remove_status_callback(on_dev_status_change) devserver_manager.remove_status_callback(on_dev_status_change)
# Unregister browser view callbacks and subscriber
browser_view_service.remove_screenshot_callback(on_screenshot)
if browser_view_subscribed:
await browser_view_service.remove_subscriber()
# Disconnect from manager # Disconnect from manager
await manager.disconnect(websocket, project_name) await manager.disconnect(websocket, project_name)

View File

@@ -29,11 +29,12 @@ REM verify the CLI is installed and remind the user to login if needed
set "CLAUDE_DIR=%USERPROFILE%\.claude" set "CLAUDE_DIR=%USERPROFILE%\.claude"
if exist "%CLAUDE_DIR%\" ( if exist "%CLAUDE_DIR%\" (
echo [OK] Claude CLI directory found echo [OK] Claude CLI directory found
echo ^(If you're not logged in, run: claude login^) echo ^(Set ANTHROPIC_API_KEY or run: claude login^)
) else ( ) else (
echo [!] Claude CLI not configured echo [!] Claude CLI not configured
echo. echo.
echo Please run 'claude login' to authenticate before continuing. echo Please set ANTHROPIC_API_KEY or run 'claude login' to authenticate.
echo Note: API key auth is recommended. See README for details.
echo. echo.
pause pause
) )

View File

@@ -255,7 +255,7 @@ def run_spec_creation(project_dir: Path) -> bool:
print(f"Please ensure app_spec.txt exists in: {get_project_prompts_dir(project_dir)}") print(f"Please ensure app_spec.txt exists in: {get_project_prompts_dir(project_dir)}")
# If failed with non-zero exit and no spec, might be auth issue # If failed with non-zero exit and no spec, might be auth issue
if result.returncode != 0: if result.returncode != 0:
print("\nIf you're having authentication issues, try running: claude login") print("\nIf you're having authentication issues, set ANTHROPIC_API_KEY or try: claude login")
return False return False
except FileNotFoundError: except FileNotFoundError:
@@ -416,7 +416,7 @@ def run_agent(project_name: str, project_dir: Path) -> None:
print(f"\nAgent error:\n{stderr_output.strip()}") print(f"\nAgent error:\n{stderr_output.strip()}")
# Still hint about auth if exit was unexpected # Still hint about auth if exit was unexpected
if "error" in stderr_output.lower() or "exception" in stderr_output.lower(): if "error" in stderr_output.lower() or "exception" in stderr_output.lower():
print("\nIf this is an authentication issue, try running: claude login") print("\nIf this is an authentication issue, set ANTHROPIC_API_KEY or try: claude login")
except KeyboardInterrupt: except KeyboardInterrupt:
print("\n\nAgent interrupted. Run again to resume.") print("\n\nAgent interrupted. Run again to resume.")

View File

@@ -25,11 +25,12 @@ echo "[OK] Claude CLI found"
# verify the CLI is installed and remind the user to login if needed # verify the CLI is installed and remind the user to login if needed
if [ -d "$HOME/.claude" ]; then if [ -d "$HOME/.claude" ]; then
echo "[OK] Claude CLI directory found" echo "[OK] Claude CLI directory found"
echo " (If you're not logged in, run: claude login)" echo " (Set ANTHROPIC_API_KEY or run: claude login)"
else else
echo "[!] Claude CLI not configured" echo "[!] Claude CLI not configured"
echo "" echo ""
echo "Please run 'claude login' to authenticate before continuing." echo "Please set ANTHROPIC_API_KEY or run 'claude login' to authenticate."
echo "Note: API key auth is recommended. See README for details."
echo "" echo ""
read -p "Press Enter to continue anyway, or Ctrl+C to exit..." read -p "Press Enter to continue anyway, or Ctrl+C to exit..."
fi fi

View File

@@ -16,16 +16,16 @@ if ! command -v claude &> /dev/null; then
echo " The agent requires Claude CLI to work." echo " The agent requires Claude CLI to work."
echo " Install it from: https://claude.ai/download" echo " Install it from: https://claude.ai/download"
echo "" echo ""
echo " After installing, run: claude login" echo " After installing, set ANTHROPIC_API_KEY or run: claude login"
echo "" echo ""
else else
echo "[OK] Claude CLI found" echo "[OK] Claude CLI found"
# Note: Claude CLI no longer stores credentials in ~/.claude/.credentials.json # Note: Claude CLI no longer stores credentials in ~/.claude/.credentials.json
# We can't reliably check auth status without making an API call # We can't reliably check auth status without making an API call
if [ -d "$HOME/.claude" ]; then if [ -d "$HOME/.claude" ]; then
echo " (If you're not logged in, run: claude login)" echo " (Set ANTHROPIC_API_KEY or run: claude login)"
else else
echo "[!] Claude CLI not configured - run 'claude login' first" echo "[!] Claude CLI not configured - set ANTHROPIC_API_KEY or run 'claude login'"
fi fi
fi fi
echo "" echo ""

276
ui/package-lock.json generated
View File

@@ -56,7 +56,7 @@
}, },
"..": { "..": {
"name": "autoforge-ai", "name": "autoforge-ai",
"version": "0.1.12", "version": "0.1.21",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"bin": { "bin": {
"autoforge": "bin/autoforge.js" "autoforge": "bin/autoforge.js"
@@ -1991,9 +1991,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@rollup/rollup-android-arm-eabi": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.54.0", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
"integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==", "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -2005,9 +2005,9 @@
] ]
}, },
"node_modules/@rollup/rollup-android-arm64": { "node_modules/@rollup/rollup-android-arm64": {
"version": "4.54.0", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.54.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
"integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==", "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2019,9 +2019,9 @@
] ]
}, },
"node_modules/@rollup/rollup-darwin-arm64": { "node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.54.0", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.54.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
"integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==", "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2033,9 +2033,9 @@
] ]
}, },
"node_modules/@rollup/rollup-darwin-x64": { "node_modules/@rollup/rollup-darwin-x64": {
"version": "4.54.0", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.54.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
"integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==", "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2047,9 +2047,9 @@
] ]
}, },
"node_modules/@rollup/rollup-freebsd-arm64": { "node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.54.0", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.54.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
"integrity": "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==", "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2061,9 +2061,9 @@
] ]
}, },
"node_modules/@rollup/rollup-freebsd-x64": { "node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.54.0", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.54.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
"integrity": "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==", "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2075,9 +2075,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm-gnueabihf": { "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.54.0", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.54.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
"integrity": "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==", "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -2089,9 +2089,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm-musleabihf": { "node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.54.0", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.54.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
"integrity": "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==", "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -2103,9 +2103,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm64-gnu": { "node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.54.0", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.54.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
"integrity": "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==", "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2117,9 +2117,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm64-musl": { "node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.54.0", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.54.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
"integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==", "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2131,9 +2131,23 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-loong64-gnu": { "node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.54.0", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.54.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
"integrity": "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==", "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
"integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
@@ -2145,9 +2159,23 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-ppc64-gnu": { "node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.54.0", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.54.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
"integrity": "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==", "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
"integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@@ -2159,9 +2187,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-riscv64-gnu": { "node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.54.0", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.54.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
"integrity": "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==", "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@@ -2173,9 +2201,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-riscv64-musl": { "node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.54.0", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.54.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
"integrity": "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==", "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@@ -2187,9 +2215,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-s390x-gnu": { "node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.54.0", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.54.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
"integrity": "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==", "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@@ -2201,9 +2229,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-x64-gnu": { "node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.54.0", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.54.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
"integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==", "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2215,9 +2243,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-x64-musl": { "node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.54.0", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.54.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
"integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==", "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2228,10 +2256,24 @@
"linux" "linux"
] ]
}, },
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
"integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
]
},
"node_modules/@rollup/rollup-openharmony-arm64": { "node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.54.0", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.54.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
"integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==", "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2243,9 +2285,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-arm64-msvc": { "node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.54.0", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.54.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
"integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==", "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2257,9 +2299,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-ia32-msvc": { "node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.54.0", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.54.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
"integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==", "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -2271,9 +2313,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-x64-gnu": { "node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.54.0", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.54.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
"integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==", "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2285,9 +2327,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-x64-msvc": { "node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.54.0", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
"integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==", "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -3042,24 +3084,37 @@
"typescript": ">=4.8.4 <6.0.0" "typescript": ">=4.8.4 <6.0.0"
} }
}, },
"node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "2.0.2", "version": "5.0.3",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"balanced-match": "^1.0.0" "balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
} }
}, },
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
"version": "9.0.5", "version": "9.0.8",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.8.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "integrity": "sha512-reYkDYtj/b19TeqbNZCV4q9t+Yxylf/rYBsLb42SXJatTv4/ylq5lEiAmhA/IToxO7NI2UzNMghHoHuaqDkAjw==",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"brace-expansion": "^2.0.1" "brace-expansion": "^5.0.2"
}, },
"engines": { "engines": {
"node": ">=16 || 14 >=14.17" "node": ">=16 || 14 >=14.17"
@@ -3227,9 +3282,9 @@
} }
}, },
"node_modules/ajv": { "node_modules/ajv": {
"version": "6.12.6", "version": "6.14.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -4757,9 +4812,9 @@
} }
}, },
"node_modules/lodash": { "node_modules/lodash": {
"version": "4.17.21", "version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash.merge": { "node_modules/lodash.merge": {
@@ -5664,9 +5719,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "3.1.2", "version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
@@ -6150,9 +6205,9 @@
} }
}, },
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.54.0", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
"integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==", "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -6166,28 +6221,31 @@
"npm": ">=8.0.0" "npm": ">=8.0.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.54.0", "@rollup/rollup-android-arm-eabi": "4.59.0",
"@rollup/rollup-android-arm64": "4.54.0", "@rollup/rollup-android-arm64": "4.59.0",
"@rollup/rollup-darwin-arm64": "4.54.0", "@rollup/rollup-darwin-arm64": "4.59.0",
"@rollup/rollup-darwin-x64": "4.54.0", "@rollup/rollup-darwin-x64": "4.59.0",
"@rollup/rollup-freebsd-arm64": "4.54.0", "@rollup/rollup-freebsd-arm64": "4.59.0",
"@rollup/rollup-freebsd-x64": "4.54.0", "@rollup/rollup-freebsd-x64": "4.59.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.54.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
"@rollup/rollup-linux-arm-musleabihf": "4.54.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0",
"@rollup/rollup-linux-arm64-gnu": "4.54.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0",
"@rollup/rollup-linux-arm64-musl": "4.54.0", "@rollup/rollup-linux-arm64-musl": "4.59.0",
"@rollup/rollup-linux-loong64-gnu": "4.54.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0",
"@rollup/rollup-linux-ppc64-gnu": "4.54.0", "@rollup/rollup-linux-loong64-musl": "4.59.0",
"@rollup/rollup-linux-riscv64-gnu": "4.54.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0",
"@rollup/rollup-linux-riscv64-musl": "4.54.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0",
"@rollup/rollup-linux-s390x-gnu": "4.54.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0",
"@rollup/rollup-linux-x64-gnu": "4.54.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0",
"@rollup/rollup-linux-x64-musl": "4.54.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0",
"@rollup/rollup-openharmony-arm64": "4.54.0", "@rollup/rollup-linux-x64-gnu": "4.59.0",
"@rollup/rollup-win32-arm64-msvc": "4.54.0", "@rollup/rollup-linux-x64-musl": "4.59.0",
"@rollup/rollup-win32-ia32-msvc": "4.54.0", "@rollup/rollup-openbsd-x64": "4.59.0",
"@rollup/rollup-win32-x64-gnu": "4.54.0", "@rollup/rollup-openharmony-arm64": "4.59.0",
"@rollup/rollup-win32-x64-msvc": "4.54.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0",
"@rollup/rollup-win32-ia32-msvc": "4.59.0",
"@rollup/rollup-win32-x64-gnu": "4.59.0",
"@rollup/rollup-win32-x64-msvc": "4.59.0",
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },

View File

@@ -1,624 +1,13 @@
import { useState, useEffect, useCallback } from 'react' import { AppProvider } from './contexts/AppContext'
import { useQueryClient, useQuery } from '@tanstack/react-query' import { AppShell } from './components/layout/AppShell'
import { useProjects, useFeatures, useAgentStatus, useSettings } from './hooks/useProjects' import { Modals } from './components/layout/Modals'
import { useProjectWebSocket } from './hooks/useWebSocket'
import { useFeatureSound } from './hooks/useFeatureSound'
import { useCelebration } from './hooks/useCelebration'
import { useTheme } from './hooks/useTheme'
import { ProjectSelector } from './components/ProjectSelector'
import { KanbanBoard } from './components/KanbanBoard'
import { AgentControl } from './components/AgentControl'
import { ProgressDashboard } from './components/ProgressDashboard'
import { SetupWizard } from './components/SetupWizard'
import { AddFeatureForm } from './components/AddFeatureForm'
import { FeatureModal } from './components/FeatureModal'
import { DebugLogViewer, type TabType } from './components/DebugLogViewer'
import { AgentMissionControl } from './components/AgentMissionControl'
import { CelebrationOverlay } from './components/CelebrationOverlay'
import { AssistantFAB } from './components/AssistantFAB'
import { AssistantPanel } from './components/AssistantPanel'
import { ExpandProjectModal } from './components/ExpandProjectModal'
import { SpecCreationChat } from './components/SpecCreationChat'
import { SettingsModal } from './components/SettingsModal'
import { DevServerControl } from './components/DevServerControl'
import { ViewToggle, type ViewMode } from './components/ViewToggle'
import { DependencyGraph } from './components/DependencyGraph'
import { KeyboardShortcutsHelp } from './components/KeyboardShortcutsHelp'
import { ThemeSelector } from './components/ThemeSelector'
import { ResetProjectModal } from './components/ResetProjectModal'
import { ProjectSetupRequired } from './components/ProjectSetupRequired'
import { getDependencyGraph, startAgent } from './lib/api'
import { Loader2, Settings, Moon, Sun, RotateCcw, BookOpen } from 'lucide-react'
import type { Feature } from './lib/types'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
const STORAGE_KEY = 'autoforge-selected-project'
const VIEW_MODE_KEY = 'autoforge-view-mode'
// Bottom padding for main content when debug panel is collapsed (40px header + 8px margin)
const COLLAPSED_DEBUG_PANEL_CLEARANCE = 48
type InitializerStatus = 'idle' | 'starting' | 'error'
function App() { function App() {
// Initialize selected project from localStorage
const [selectedProject, setSelectedProject] = useState<string | null>(() => {
try {
return localStorage.getItem(STORAGE_KEY)
} catch {
return null
}
})
const [showAddFeature, setShowAddFeature] = useState(false)
const [showExpandProject, setShowExpandProject] = useState(false)
const [selectedFeature, setSelectedFeature] = useState<Feature | null>(null)
const [setupComplete, setSetupComplete] = useState(true) // Start optimistic
const [debugOpen, setDebugOpen] = useState(false)
const [debugPanelHeight, setDebugPanelHeight] = useState(288) // Default height
const [debugActiveTab, setDebugActiveTab] = useState<TabType>('agent')
const [assistantOpen, setAssistantOpen] = useState(false)
const [showSettings, setShowSettings] = useState(false)
const [showKeyboardHelp, setShowKeyboardHelp] = useState(false)
const [isSpecCreating, setIsSpecCreating] = useState(false)
const [showResetModal, setShowResetModal] = useState(false)
const [showSpecChat, setShowSpecChat] = useState(false) // For "Create Spec" button in empty kanban
const [specInitializerStatus, setSpecInitializerStatus] = useState<InitializerStatus>('idle')
const [specInitializerError, setSpecInitializerError] = useState<string | null>(null)
const [viewMode, setViewMode] = useState<ViewMode>(() => {
try {
const stored = localStorage.getItem(VIEW_MODE_KEY)
return (stored === 'graph' ? 'graph' : 'kanban') as ViewMode
} catch {
return 'kanban'
}
})
const queryClient = useQueryClient()
const { data: projects, isLoading: projectsLoading } = useProjects()
const { data: features } = useFeatures(selectedProject)
const { data: settings } = useSettings()
useAgentStatus(selectedProject) // Keep polling for status updates
const wsState = useProjectWebSocket(selectedProject)
const { theme, setTheme, darkMode, toggleDarkMode, themes } = useTheme()
// Get has_spec from the selected project
const selectedProjectData = projects?.find(p => p.name === selectedProject)
const hasSpec = selectedProjectData?.has_spec ?? true
// Fetch graph data when in graph view
const { data: graphData } = useQuery({
queryKey: ['dependencyGraph', selectedProject],
queryFn: () => getDependencyGraph(selectedProject!),
enabled: !!selectedProject && viewMode === 'graph',
refetchInterval: 5000, // Refresh every 5 seconds
})
// Persist view mode to localStorage
useEffect(() => {
try {
localStorage.setItem(VIEW_MODE_KEY, viewMode)
} catch {
// localStorage not available
}
}, [viewMode])
// Play sounds when features move between columns
useFeatureSound(features)
// Celebrate when all features are complete
useCelebration(features, selectedProject)
// Persist selected project to localStorage
const handleSelectProject = useCallback((project: string | null) => {
setSelectedProject(project)
try {
if (project) {
localStorage.setItem(STORAGE_KEY, project)
} else {
localStorage.removeItem(STORAGE_KEY)
}
} catch {
// localStorage not available
}
}, [])
// Handle graph node click - memoized to prevent DependencyGraph re-renders
const handleGraphNodeClick = useCallback((nodeId: number) => {
const allFeatures = [
...(features?.pending ?? []),
...(features?.in_progress ?? []),
...(features?.done ?? [])
]
const feature = allFeatures.find(f => f.id === nodeId)
if (feature) setSelectedFeature(feature)
}, [features])
// Validate stored project exists (clear if project was deleted)
useEffect(() => {
if (selectedProject && projects && !projects.some(p => p.name === selectedProject)) {
handleSelectProject(null)
}
}, [selectedProject, projects, handleSelectProject])
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Ignore if user is typing in an input
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
return
}
// D : Toggle debug window
if (e.key === 'd' || e.key === 'D') {
e.preventDefault()
setDebugOpen(prev => !prev)
}
// T : Toggle terminal tab in debug panel
if (e.key === 't' || e.key === 'T') {
e.preventDefault()
if (!debugOpen) {
// If panel is closed, open it and switch to terminal tab
setDebugOpen(true)
setDebugActiveTab('terminal')
} else if (debugActiveTab === 'terminal') {
// If already on terminal tab, close the panel
setDebugOpen(false)
} else {
// If open but on different tab, switch to terminal
setDebugActiveTab('terminal')
}
}
// N : Add new feature (when project selected)
if ((e.key === 'n' || e.key === 'N') && selectedProject) {
e.preventDefault()
setShowAddFeature(true)
}
// E : Expand project with AI (when project selected, has spec and has features)
if ((e.key === 'e' || e.key === 'E') && selectedProject && hasSpec && features &&
(features.pending.length + features.in_progress.length + features.done.length) > 0) {
e.preventDefault()
setShowExpandProject(true)
}
// A : Toggle assistant panel (when project selected and not in spec creation)
if ((e.key === 'a' || e.key === 'A') && selectedProject && !isSpecCreating) {
e.preventDefault()
setAssistantOpen(prev => !prev)
}
// , : Open settings
if (e.key === ',') {
e.preventDefault()
setShowSettings(true)
}
// G : Toggle between Kanban and Graph view (when project selected)
if ((e.key === 'g' || e.key === 'G') && selectedProject) {
e.preventDefault()
setViewMode(prev => prev === 'kanban' ? 'graph' : 'kanban')
}
// ? : Show keyboard shortcuts help
if (e.key === '?') {
e.preventDefault()
setShowKeyboardHelp(true)
}
// R : Open reset modal (when project selected and agent not running)
if ((e.key === 'r' || e.key === 'R') && selectedProject && wsState.agentStatus !== 'running') {
e.preventDefault()
setShowResetModal(true)
}
// Escape : Close modals
if (e.key === 'Escape') {
if (showKeyboardHelp) {
setShowKeyboardHelp(false)
} else if (showResetModal) {
setShowResetModal(false)
} else if (showExpandProject) {
setShowExpandProject(false)
} else if (showSettings) {
setShowSettings(false)
} else if (assistantOpen) {
setAssistantOpen(false)
} else if (showAddFeature) {
setShowAddFeature(false)
} else if (selectedFeature) {
setSelectedFeature(null)
} else if (debugOpen) {
setDebugOpen(false)
}
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [selectedProject, showAddFeature, showExpandProject, selectedFeature, debugOpen, debugActiveTab, assistantOpen, features, showSettings, showKeyboardHelp, isSpecCreating, viewMode, showResetModal, wsState.agentStatus, hasSpec])
// Combine WebSocket progress with feature data
const progress = wsState.progress.total > 0 ? wsState.progress : {
passing: features?.done.length ?? 0,
total: (features?.pending.length ?? 0) + (features?.in_progress.length ?? 0) + (features?.done.length ?? 0),
percentage: 0,
}
if (progress.total > 0 && progress.percentage === 0) {
progress.percentage = Math.round((progress.passing / progress.total) * 100 * 10) / 10
}
if (!setupComplete) {
return <SetupWizard onComplete={() => setSetupComplete(true)} />
}
return ( return (
<div className="min-h-screen bg-background"> <AppProvider>
{/* Header */} <AppShell />
<header className="sticky top-0 z-50 bg-card/80 backdrop-blur-md text-foreground border-b-2 border-border"> <Modals />
<div className="max-w-7xl mx-auto px-4 py-3"> </AppProvider>
<TooltipProvider>
{/* Row 1: Branding + Project + Utility icons */}
<div className="flex items-center gap-3">
{/* Logo and Title */}
<div className="flex items-center gap-2 shrink-0">
<img src="/logo.png" alt="AutoForge" className="h-9 w-9 rounded-full" />
<h1 className="font-display text-2xl font-bold tracking-tight uppercase hidden md:block">
AutoForge
</h1>
</div>
{/* Project selector */}
<ProjectSelector
projects={projects ?? []}
selectedProject={selectedProject}
onSelectProject={handleSelectProject}
isLoading={projectsLoading}
onSpecCreatingChange={setIsSpecCreating}
/>
{/* Spacer */}
<div className="flex-1" />
{/* Ollama Mode Indicator */}
{selectedProject && settings?.ollama_mode && (
<div
className="hidden sm:flex items-center gap-1.5 px-2 py-1 bg-card rounded border-2 border-border shadow-sm"
title="Using Ollama local models"
>
<img src="/ollama.png" alt="Ollama" className="w-5 h-5" />
<span className="text-xs font-bold text-foreground">Ollama</span>
</div>
)}
{/* GLM Mode Badge */}
{selectedProject && settings?.glm_mode && (
<Badge
className="hidden sm:inline-flex bg-purple-500 text-white hover:bg-purple-600"
title="Using GLM API"
>
GLM
</Badge>
)}
{/* Utility icons - always visible */}
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={() => window.open('https://autoforge.cc', '_blank')}
variant="outline"
size="sm"
aria-label="Open Documentation"
>
<BookOpen size={18} />
</Button>
</TooltipTrigger>
<TooltipContent>Docs</TooltipContent>
</Tooltip>
<ThemeSelector
themes={themes}
currentTheme={theme}
onThemeChange={setTheme}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={toggleDarkMode}
variant="outline"
size="sm"
aria-label="Toggle dark mode"
>
{darkMode ? <Sun size={18} /> : <Moon size={18} />}
</Button>
</TooltipTrigger>
<TooltipContent>Toggle theme</TooltipContent>
</Tooltip>
</div>
{/* Row 2: Project controls - only when a project is selected */}
{selectedProject && (
<div className="flex items-center gap-3 mt-2 pt-2 border-t border-border/50">
<AgentControl
projectName={selectedProject}
status={wsState.agentStatus}
defaultConcurrency={selectedProjectData?.default_concurrency}
/>
<DevServerControl
projectName={selectedProject}
status={wsState.devServerStatus}
url={wsState.devServerUrl}
/>
<div className="flex-1" />
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={() => setShowSettings(true)}
variant="outline"
size="sm"
aria-label="Open Settings"
>
<Settings size={18} />
</Button>
</TooltipTrigger>
<TooltipContent>Settings (,)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={() => setShowResetModal(true)}
variant="outline"
size="sm"
aria-label="Reset Project"
disabled={wsState.agentStatus === 'running'}
>
<RotateCcw size={18} />
</Button>
</TooltipTrigger>
<TooltipContent>Reset (R)</TooltipContent>
</Tooltip>
</div>
)}
</TooltipProvider>
</div>
</header>
{/* Main Content */}
<main
className="max-w-7xl mx-auto px-4 py-8"
style={{ paddingBottom: debugOpen ? debugPanelHeight + 32 : COLLAPSED_DEBUG_PANEL_CLEARANCE }}
>
{!selectedProject ? (
<div className="text-center mt-12">
<h2 className="font-display text-2xl font-bold mb-2">
Welcome to AutoForge
</h2>
<p className="text-muted-foreground mb-4">
Select a project from the dropdown above or create a new one to get started.
</p>
</div>
) : !hasSpec ? (
<ProjectSetupRequired
projectName={selectedProject}
projectPath={selectedProjectData?.path}
onCreateWithClaude={() => setShowSpecChat(true)}
onEditManually={() => {
// Open debug panel for the user to see the project path
setDebugOpen(true)
}}
/>
) : (
<div className="space-y-8">
{/* Progress Dashboard */}
<ProgressDashboard
passing={progress.passing}
total={progress.total}
percentage={progress.percentage}
isConnected={wsState.isConnected}
logs={wsState.activeAgents.length === 0 ? wsState.logs : undefined}
agentStatus={wsState.activeAgents.length === 0 ? wsState.agentStatus : undefined}
/>
{/* Agent Mission Control - shows orchestrator status and active agents in parallel mode */}
<AgentMissionControl
agents={wsState.activeAgents}
orchestratorStatus={wsState.orchestratorStatus}
recentActivity={wsState.recentActivity}
getAgentLogs={wsState.getAgentLogs}
/>
{/* Initializing Features State - show when agent is running but no features yet */}
{features &&
features.pending.length === 0 &&
features.in_progress.length === 0 &&
features.done.length === 0 &&
wsState.agentStatus === 'running' && (
<Card className="p-8 text-center">
<CardContent className="p-0">
<Loader2 size={32} className="animate-spin mx-auto mb-4 text-primary" />
<h3 className="font-display font-bold text-xl mb-2">
Initializing Features...
</h3>
<p className="text-muted-foreground">
The agent is reading your spec and creating features. This may take a moment.
</p>
</CardContent>
</Card>
)}
{/* View Toggle - only show when there are features */}
{features && (features.pending.length + features.in_progress.length + features.done.length) > 0 && (
<div className="flex justify-center">
<ViewToggle viewMode={viewMode} onViewModeChange={setViewMode} />
</div>
)}
{/* Kanban Board or Dependency Graph based on view mode */}
{viewMode === 'kanban' ? (
<KanbanBoard
features={features}
onFeatureClick={setSelectedFeature}
onAddFeature={() => setShowAddFeature(true)}
onExpandProject={() => setShowExpandProject(true)}
activeAgents={wsState.activeAgents}
onCreateSpec={() => setShowSpecChat(true)}
hasSpec={hasSpec}
/>
) : (
<Card className="overflow-hidden" style={{ height: '600px' }}>
{graphData ? (
<DependencyGraph
graphData={graphData}
onNodeClick={handleGraphNodeClick}
activeAgents={wsState.activeAgents}
/>
) : (
<div className="h-full flex items-center justify-center">
<Loader2 size={32} className="animate-spin text-primary" />
</div>
)}
</Card>
)}
</div>
)}
</main>
{/* Add Feature Modal */}
{showAddFeature && selectedProject && (
<AddFeatureForm
projectName={selectedProject}
onClose={() => setShowAddFeature(false)}
/>
)}
{/* Feature Detail Modal */}
{selectedFeature && selectedProject && (
<FeatureModal
feature={selectedFeature}
projectName={selectedProject}
onClose={() => setSelectedFeature(null)}
/>
)}
{/* Expand Project Modal - AI-powered bulk feature creation */}
{showExpandProject && selectedProject && hasSpec && (
<ExpandProjectModal
isOpen={showExpandProject}
projectName={selectedProject}
onClose={() => setShowExpandProject(false)}
onFeaturesAdded={() => {
// Invalidate features query to refresh the kanban board
queryClient.invalidateQueries({ queryKey: ['features', selectedProject] })
}}
/>
)}
{/* Spec Creation Chat - for creating spec from empty kanban */}
{showSpecChat && selectedProject && (
<div className="fixed inset-0 z-50 bg-background">
<SpecCreationChat
projectName={selectedProject}
onComplete={async (_specPath, yoloMode) => {
setSpecInitializerStatus('starting')
try {
await startAgent(selectedProject, {
yoloMode: yoloMode ?? false,
maxConcurrency: 3,
})
// Success — close chat and refresh
setShowSpecChat(false)
setSpecInitializerStatus('idle')
queryClient.invalidateQueries({ queryKey: ['projects'] })
queryClient.invalidateQueries({ queryKey: ['features', selectedProject] })
} catch (err) {
setSpecInitializerStatus('error')
setSpecInitializerError(err instanceof Error ? err.message : 'Failed to start agent')
}
}}
onCancel={() => { setShowSpecChat(false); setSpecInitializerStatus('idle') }}
onExitToProject={() => { setShowSpecChat(false); setSpecInitializerStatus('idle') }}
initializerStatus={specInitializerStatus}
initializerError={specInitializerError}
onRetryInitializer={() => {
setSpecInitializerError(null)
setSpecInitializerStatus('idle')
}}
/>
</div>
)}
{/* Debug Log Viewer - fixed to bottom */}
{selectedProject && (
<DebugLogViewer
logs={wsState.logs}
devLogs={wsState.devLogs}
isOpen={debugOpen}
onToggle={() => setDebugOpen(!debugOpen)}
onClear={wsState.clearLogs}
onClearDevLogs={wsState.clearDevLogs}
onHeightChange={setDebugPanelHeight}
projectName={selectedProject}
activeTab={debugActiveTab}
onTabChange={setDebugActiveTab}
/>
)}
{/* Assistant FAB and Panel - hide when expand modal or spec creation is open */}
{selectedProject && !showExpandProject && !isSpecCreating && !showSpecChat && (
<>
<AssistantFAB
onClick={() => setAssistantOpen(!assistantOpen)}
isOpen={assistantOpen}
/>
<AssistantPanel
projectName={selectedProject}
isOpen={assistantOpen}
onClose={() => setAssistantOpen(false)}
/>
</>
)}
{/* Settings Modal */}
<SettingsModal isOpen={showSettings} onClose={() => setShowSettings(false)} />
{/* Keyboard Shortcuts Help */}
<KeyboardShortcutsHelp isOpen={showKeyboardHelp} onClose={() => setShowKeyboardHelp(false)} />
{/* Reset Project Modal */}
{showResetModal && selectedProject && (
<ResetProjectModal
isOpen={showResetModal}
projectName={selectedProject}
onClose={() => setShowResetModal(false)}
onResetComplete={(wasFullReset) => {
// If full reset, the spec was deleted - show spec creation chat
if (wasFullReset) {
setShowSpecChat(true)
}
}}
/>
)}
{/* Celebration Overlay - shows when a feature is completed by an agent */}
{wsState.celebration && (
<CelebrationOverlay
agentName={wsState.celebration.agentName}
featureName={wsState.celebration.featureName}
onComplete={wsState.clearCelebration}
/>
)}
</div>
) )
} }

View File

@@ -1,8 +1,9 @@
import { MessageCircle, ScrollText, X, Copy, Check, Code, FlaskConical } from 'lucide-react' import { MessageCircle, ScrollText, X, Copy, Check, Code, FlaskConical, Maximize2 } from 'lucide-react'
import { useState } from 'react' import { useState } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { AgentAvatar } from './AgentAvatar' import { AgentAvatar } from './AgentAvatar'
import type { ActiveAgent, AgentLogEntry, AgentType } from '../lib/types' import type { ActiveAgent, AgentLogEntry, AgentType, BrowserScreenshot } from '../lib/types'
import { AGENT_MASCOTS } from '../lib/types'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
@@ -10,6 +11,7 @@ import { Badge } from '@/components/ui/badge'
interface AgentCardProps { interface AgentCardProps {
agent: ActiveAgent agent: ActiveAgent
onShowLogs?: (agentIndex: number) => void onShowLogs?: (agentIndex: number) => void
browserScreenshot?: BrowserScreenshot
} }
// Get a friendly state description // Get a friendly state description
@@ -69,14 +71,56 @@ function getAgentTypeBadge(agentType: AgentType): { label: string; className: st
} }
} }
export function AgentCard({ agent, onShowLogs }: AgentCardProps) { export function AgentCard({ agent, onShowLogs, browserScreenshot }: AgentCardProps) {
const isActive = ['thinking', 'working', 'testing'].includes(agent.state) const isActive = ['thinking', 'working', 'testing'].includes(agent.state)
const hasLogs = agent.logs && agent.logs.length > 0 const hasLogs = agent.logs && agent.logs.length > 0
const typeBadge = getAgentTypeBadge(agent.agentType || 'coding') const typeBadge = getAgentTypeBadge(agent.agentType || 'coding')
const TypeIcon = typeBadge.icon const TypeIcon = typeBadge.icon
const [screenshotExpanded, setScreenshotExpanded] = useState(false)
return ( return (
<Card className={`min-w-[180px] max-w-[220px] py-3 ${isActive ? 'animate-pulse' : ''}`}> <>
{/* Expanded screenshot overlay */}
{screenshotExpanded && browserScreenshot && createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70"
onClick={() => setScreenshotExpanded(false)}
>
<div
className="relative max-w-[90vw] max-h-[90vh] bg-card border-2 border-border rounded-lg overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-3 py-2 bg-muted border-b border-border">
<div className="flex items-center gap-2">
<TypeIcon size={14} className={agent.agentType === 'testing' ? 'text-purple-500' : 'text-blue-500'} />
<span className="font-mono text-sm font-bold">
{AGENT_MASCOTS[agent.agentIndex % AGENT_MASCOTS.length]}
</span>
<Badge variant="outline" className="text-[10px] h-4">
{agent.agentType || 'coding'}
</Badge>
<span className="text-xs text-muted-foreground truncate">
{agent.featureName}
</span>
</div>
<button
onClick={() => setScreenshotExpanded(false)}
className="p-1 hover:bg-accent rounded transition-colors cursor-pointer"
>
<X size={16} />
</button>
</div>
<img
src={browserScreenshot.imageDataUrl}
alt={`Browser view - ${agent.featureName}`}
className="max-w-full max-h-[calc(90vh-3rem)] object-contain"
/>
</div>
</div>,
document.body
)}
<Card className={`min-w-[180px] ${browserScreenshot ? 'max-w-[280px]' : 'max-w-[220px]'} py-3 ${isActive ? 'animate-pulse' : ''}`}>
<CardContent className="p-3 space-y-2"> <CardContent className="p-3 space-y-2">
{/* Agent type badge */} {/* Agent type badge */}
<div className="flex justify-end"> <div className="flex justify-end">
@@ -133,6 +177,29 @@ export function AgentCard({ agent, onShowLogs }: AgentCardProps) {
)} )}
</div> </div>
{/* Browser screenshot thumbnail with expand */}
{browserScreenshot && (
<div className="pt-1 relative group">
<div
className="cursor-pointer overflow-hidden rounded border border-border/50"
onClick={() => setScreenshotExpanded(true)}
>
<img
src={browserScreenshot.imageDataUrl}
alt="Browser view"
className="w-full h-auto max-h-[120px] object-cover object-top"
/>
</div>
<button
onClick={() => setScreenshotExpanded(true)}
className="absolute top-2.5 right-1.5 p-0.5 bg-black/50 hover:bg-black/70 rounded transition-opacity opacity-0 group-hover:opacity-100 cursor-pointer"
title="Expand screenshot"
>
<Maximize2 size={12} className="text-white" />
</button>
</div>
)}
{/* Thought bubble */} {/* Thought bubble */}
{agent.thought && ( {agent.thought && (
<div className="pt-2 border-t border-border/50"> <div className="pt-2 border-t border-border/50">
@@ -149,6 +216,7 @@ export function AgentCard({ agent, onShowLogs }: AgentCardProps) {
)} )}
</CardContent> </CardContent>
</Card> </Card>
</>
) )
} }

View File

@@ -1,8 +1,10 @@
import { useState, useEffect, useRef, useCallback } from 'react' import { useState, useEffect, useRef, useCallback } from 'react'
import { Play, Square, Loader2, GitBranch, Clock } from 'lucide-react' import { Play, Square, Loader2, GitBranch, Clock, Pause, PlayCircle } from 'lucide-react'
import { import {
useStartAgent, useStartAgent,
useStopAgent, useStopAgent,
useGracefulPauseAgent,
useGracefulResumeAgent,
useSettings, useSettings,
useUpdateProjectSettings, useUpdateProjectSettings,
} from '../hooks/useProjects' } from '../hooks/useProjects'
@@ -60,12 +62,14 @@ export function AgentControl({ projectName, status, defaultConcurrency = 3 }: Ag
const startAgent = useStartAgent(projectName) const startAgent = useStartAgent(projectName)
const stopAgent = useStopAgent(projectName) const stopAgent = useStopAgent(projectName)
const gracefulPause = useGracefulPauseAgent(projectName)
const gracefulResume = useGracefulResumeAgent(projectName)
const { data: nextRun } = useNextScheduledRun(projectName) const { data: nextRun } = useNextScheduledRun(projectName)
const [showScheduleModal, setShowScheduleModal] = useState(false) const [showScheduleModal, setShowScheduleModal] = useState(false)
const isLoading = startAgent.isPending || stopAgent.isPending const isLoading = startAgent.isPending || stopAgent.isPending || gracefulPause.isPending || gracefulResume.isPending
const isRunning = status === 'running' || status === 'paused' const isRunning = status === 'running' || status === 'paused' || status === 'pausing' || status === 'paused_graceful'
const isLoadingStatus = status === 'loading' const isLoadingStatus = status === 'loading'
const isParallel = concurrency > 1 const isParallel = concurrency > 1
@@ -126,7 +130,7 @@ export function AgentControl({ projectName, status, defaultConcurrency = 3 }: Ag
</Badge> </Badge>
)} )}
{/* Start/Stop button */} {/* Start/Stop/Pause/Resume buttons */}
{isLoadingStatus ? ( {isLoadingStatus ? (
<Button disabled variant="outline" size="sm"> <Button disabled variant="outline" size="sm">
<Loader2 size={18} className="animate-spin" /> <Loader2 size={18} className="animate-spin" />
@@ -146,19 +150,69 @@ export function AgentControl({ projectName, status, defaultConcurrency = 3 }: Ag
)} )}
</Button> </Button>
) : ( ) : (
<div className="flex items-center gap-1.5">
{/* Pausing indicator */}
{status === 'pausing' && (
<Badge variant="secondary" className="gap-1 animate-pulse">
<Loader2 size={12} className="animate-spin" />
Pausing...
</Badge>
)}
{/* Paused indicator + Resume button */}
{status === 'paused_graceful' && (
<>
<Badge variant="outline" className="gap-1">
Paused
</Badge>
<Button
onClick={() => gracefulResume.mutate()}
disabled={isLoading}
variant="default"
size="sm"
title="Resume agent"
>
{gracefulResume.isPending ? (
<Loader2 size={18} className="animate-spin" />
) : (
<PlayCircle size={18} />
)}
</Button>
</>
)}
{/* Graceful pause button (only when running normally) */}
{status === 'running' && (
<Button
onClick={() => gracefulPause.mutate()}
disabled={isLoading}
variant="outline"
size="sm"
title="Pause agent (finish current work first)"
>
{gracefulPause.isPending ? (
<Loader2 size={18} className="animate-spin" />
) : (
<Pause size={18} />
)}
</Button>
)}
{/* Stop button (always available) */}
<Button <Button
onClick={handleStop} onClick={handleStop}
disabled={isLoading} disabled={isLoading}
variant="destructive" variant="destructive"
size="sm" size="sm"
title={yoloMode ? 'Stop Agent (YOLO Mode)' : 'Stop Agent'} title="Stop Agent (immediate)"
> >
{isLoading ? ( {stopAgent.isPending ? (
<Loader2 size={18} className="animate-spin" /> <Loader2 size={18} className="animate-spin" />
) : ( ) : (
<Square size={18} /> <Square size={18} />
)} )}
</Button> </Button>
</div>
)} )}
{/* Clock button to open schedule modal */} {/* Clock button to open schedule modal */}

View File

@@ -3,7 +3,7 @@ import { useState } from 'react'
import { AgentCard, AgentLogModal } from './AgentCard' import { AgentCard, AgentLogModal } from './AgentCard'
import { ActivityFeed } from './ActivityFeed' import { ActivityFeed } from './ActivityFeed'
import { OrchestratorStatusCard } from './OrchestratorStatusCard' import { OrchestratorStatusCard } from './OrchestratorStatusCard'
import type { ActiveAgent, AgentLogEntry, OrchestratorStatus } from '../lib/types' import type { ActiveAgent, AgentLogEntry, BrowserScreenshot, OrchestratorStatus } from '../lib/types'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -21,6 +21,7 @@ interface AgentMissionControlProps {
}> }>
isExpanded?: boolean isExpanded?: boolean
getAgentLogs?: (agentIndex: number) => AgentLogEntry[] getAgentLogs?: (agentIndex: number) => AgentLogEntry[]
browserScreenshots?: Map<string, BrowserScreenshot>
} }
export function AgentMissionControl({ export function AgentMissionControl({
@@ -29,6 +30,7 @@ export function AgentMissionControl({
recentActivity, recentActivity,
isExpanded: defaultExpanded = true, isExpanded: defaultExpanded = true,
getAgentLogs, getAgentLogs,
browserScreenshots,
}: AgentMissionControlProps) { }: AgentMissionControlProps) {
const [isExpanded, setIsExpanded] = useState(defaultExpanded) const [isExpanded, setIsExpanded] = useState(defaultExpanded)
const [activityCollapsed, setActivityCollapsed] = useState(() => { const [activityCollapsed, setActivityCollapsed] = useState(() => {
@@ -72,6 +74,10 @@ export function AgentMissionControl({
? `${agents.length} ${agents.length === 1 ? 'agent' : 'agents'} active` ? `${agents.length} ${agents.length === 1 ? 'agent' : 'agents'} active`
: orchestratorStatus?.state === 'initializing' : orchestratorStatus?.state === 'initializing'
? 'Initializing' ? 'Initializing'
: orchestratorStatus?.state === 'draining'
? 'Draining'
: orchestratorStatus?.state === 'paused'
? 'Paused'
: orchestratorStatus?.state === 'complete' : orchestratorStatus?.state === 'complete'
? 'Complete' ? 'Complete'
: 'Orchestrating' : 'Orchestrating'
@@ -101,7 +107,12 @@ export function AgentMissionControl({
{/* Agent Cards Row */} {/* Agent Cards Row */}
{agents.length > 0 && ( {agents.length > 0 && (
<div className="flex gap-4 overflow-x-auto pb-4"> <div className="flex gap-4 overflow-x-auto pb-4">
{agents.map((agent) => ( {agents.map((agent) => {
// Find browser screenshot for this agent by matching agentIndex
const screenshot = browserScreenshots
? Array.from(browserScreenshots.values()).find(s => s.agentIndex === agent.agentIndex)
: undefined
return (
<AgentCard <AgentCard
key={`agent-${agent.agentIndex}`} key={`agent-${agent.agentIndex}`}
agent={agent} agent={agent}
@@ -111,8 +122,10 @@ export function AgentMissionControl({
setSelectedAgentForLogs(agentToShow) setSelectedAgentForLogs(agentToShow)
} }
}} }}
browserScreenshot={screenshot}
/> />
))} )
})}
</div> </div>
)} )}

View File

@@ -63,7 +63,7 @@ export function AgentThought({ logs, agentStatus }: AgentThoughtProps) {
// Determine if component should be visible // Determine if component should be visible
const shouldShow = useMemo(() => { const shouldShow = useMemo(() => {
if (!thought) return false if (!thought) return false
if (agentStatus === 'running') return true if (agentStatus === 'running' || agentStatus === 'pausing') return true
if (agentStatus === 'paused') { if (agentStatus === 'paused') {
return Date.now() - lastLogTimestamp < IDLE_TIMEOUT return Date.now() - lastLogTimestamp < IDLE_TIMEOUT
} }

View File

@@ -0,0 +1,150 @@
/**
* Browser View Panel
*
* Displays live screenshots from each agent's browser session.
* Subscribes to screenshot streaming on mount, unsubscribes on unmount.
*/
import { useEffect, useState } from 'react'
import { Monitor, X, Maximize2, Code, FlaskConical } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { AGENT_MASCOTS } from '@/lib/types'
import type { BrowserScreenshot } from '@/lib/types'
interface BrowserViewPanelProps {
screenshots: Map<string, BrowserScreenshot>
onSubscribe: () => void
onUnsubscribe: () => void
}
export function BrowserViewPanel({ screenshots, onSubscribe, onUnsubscribe }: BrowserViewPanelProps) {
const [expandedSession, setExpandedSession] = useState<string | null>(null)
// Subscribe on mount, unsubscribe on unmount
useEffect(() => {
onSubscribe()
return () => onUnsubscribe()
}, [onSubscribe, onUnsubscribe])
const screenshotList = Array.from(screenshots.values())
if (screenshotList.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
<Monitor size={24} />
<span className="text-sm">No active browser sessions</span>
<span className="text-xs">Screenshots will appear when agents open browsers</span>
</div>
)
}
const expanded = expandedSession ? screenshots.get(expandedSession) : null
return (
<div className="h-full overflow-auto p-3">
{/* Expanded overlay */}
{expanded && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70"
onClick={() => setExpandedSession(null)}
>
<div
className="relative max-w-[90vw] max-h-[90vh] bg-card border-2 border-border rounded-lg overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-3 py-2 bg-muted border-b border-border">
<div className="flex items-center gap-2">
{expanded.agentType === 'coding' ? (
<Code size={14} className="text-blue-500" />
) : (
<FlaskConical size={14} className="text-purple-500" />
)}
<span className="font-mono text-sm font-bold">
{AGENT_MASCOTS[expanded.agentIndex % AGENT_MASCOTS.length]}
</span>
<Badge variant="outline" className="text-[10px] h-4">
{expanded.agentType}
</Badge>
<span className="text-xs text-muted-foreground truncate">
{expanded.featureName}
</span>
</div>
<button
onClick={() => setExpandedSession(null)}
className="p-1 hover:bg-accent rounded transition-colors cursor-pointer"
>
<X size={16} />
</button>
</div>
<img
src={expanded.imageDataUrl}
alt={`Browser view - ${expanded.featureName}`}
className="max-w-full max-h-[calc(90vh-3rem)] object-contain"
/>
</div>
</div>
)}
{/* Screenshot grid — responsive 1/2/3 columns */}
<div className="grid gap-3 grid-cols-1 md:grid-cols-2 xl:grid-cols-3">
{screenshotList.map((screenshot) => (
<div
key={screenshot.sessionName}
className="border-2 border-border rounded-lg overflow-hidden bg-card hover:border-foreground/30 transition-colors"
>
{/* Card header */}
<div className="flex items-center justify-between px-2.5 py-1.5 bg-muted border-b border-border">
<div className="flex items-center gap-2 min-w-0">
{screenshot.agentType === 'coding' ? (
<Code size={12} className="text-blue-500 shrink-0" />
) : (
<FlaskConical size={12} className="text-purple-500 shrink-0" />
)}
<span className="font-mono text-xs font-bold">
{AGENT_MASCOTS[screenshot.agentIndex % AGENT_MASCOTS.length]}
</span>
<Badge variant="outline" className="text-[9px] h-3.5 px-1">
{screenshot.agentType}
</Badge>
<span className="text-[11px] text-muted-foreground truncate">
{screenshot.featureName}
</span>
</div>
<button
onClick={() => setExpandedSession(screenshot.sessionName)}
className="p-0.5 hover:bg-accent rounded transition-colors cursor-pointer shrink-0"
title="Expand"
>
<Maximize2 size={12} className="text-muted-foreground" />
</button>
</div>
{/* Screenshot image — capped height for compact grid */}
<div
className="cursor-pointer overflow-hidden"
onClick={() => setExpandedSession(screenshot.sessionName)}
>
<img
src={screenshot.imageDataUrl}
alt={`Browser - ${screenshot.featureName}`}
className="w-full h-auto max-h-[280px] object-cover object-top"
/>
</div>
{/* Timestamp footer */}
<div className="px-2.5 py-1 bg-muted border-t border-border">
<span className="text-[10px] text-muted-foreground font-mono">
{new Date(screenshot.timestamp).toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})}
</span>
</div>
</div>
))}
</div>
</div>
)
}

View File

@@ -6,10 +6,11 @@
*/ */
import { memo } from 'react' import { memo } from 'react'
import { Bot, User, Info } from 'lucide-react' import { Bot, User, Info, FileText } from 'lucide-react'
import ReactMarkdown, { type Components } from 'react-markdown' import ReactMarkdown, { type Components } from 'react-markdown'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
import type { ChatMessage as ChatMessageType } from '../lib/types' import type { ChatMessage as ChatMessageType } from '../lib/types'
import { isImageAttachment } from '../lib/types'
import { Card } from '@/components/ui/card' import { Card } from '@/components/ui/card'
interface ChatMessageProps { interface ChatMessageProps {
@@ -104,11 +105,13 @@ export const ChatMessage = memo(function ChatMessage({ message }: ChatMessagePro
</div> </div>
)} )}
{/* Display image attachments */} {/* Display file attachments */}
{attachments && attachments.length > 0 && ( {attachments && attachments.length > 0 && (
<div className={`flex flex-wrap gap-2 ${content ? 'mt-3' : ''}`}> <div className={`flex flex-wrap gap-2 ${content ? 'mt-3' : ''}`}>
{attachments.map((attachment) => ( {attachments.map((attachment) => (
<div key={attachment.id} className="border border-border rounded p-1 bg-card"> <div key={attachment.id} className="border border-border rounded p-1 bg-card">
{isImageAttachment(attachment) ? (
<>
<img <img
src={attachment.previewUrl} src={attachment.previewUrl}
alt={attachment.filename} alt={attachment.filename}
@@ -119,6 +122,18 @@ export const ChatMessage = memo(function ChatMessage({ message }: ChatMessagePro
<span className="text-xs text-muted-foreground block mt-1 text-center"> <span className="text-xs text-muted-foreground block mt-1 text-center">
{attachment.filename} {attachment.filename}
</span> </span>
</>
) : (
<div className="flex items-center gap-2 px-2 py-1">
<FileText size={16} className="text-muted-foreground flex-shrink-0" />
<span className="text-xs text-muted-foreground">
{attachment.filename}
</span>
<span className="text-xs text-muted-foreground/60">
({(attachment.size / 1024).toFixed(0)} KB)
</span>
</div>
)}
</div> </div>
))} ))}
</div> </div>

View File

@@ -7,11 +7,12 @@
*/ */
import { useEffect, useRef, useState, useCallback } from 'react' import { useEffect, useRef, useState, useCallback } from 'react'
import { ChevronUp, ChevronDown, Trash2, Terminal as TerminalIcon, GripHorizontal, Cpu, Server } from 'lucide-react' import { ChevronUp, ChevronDown, Trash2, Terminal as TerminalIcon, GripHorizontal, Cpu, Server, Monitor } from 'lucide-react'
import { Terminal } from './Terminal' import { Terminal } from './Terminal'
import { TerminalTabs } from './TerminalTabs' import { TerminalTabs } from './TerminalTabs'
import { BrowserViewPanel } from './BrowserViewPanel'
import { listTerminals, createTerminal, renameTerminal, deleteTerminal } from '@/lib/api' import { listTerminals, createTerminal, renameTerminal, deleteTerminal } from '@/lib/api'
import type { TerminalInfo } from '@/lib/types' import type { TerminalInfo, BrowserScreenshot } from '@/lib/types'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
@@ -21,7 +22,7 @@ const DEFAULT_HEIGHT = 288
const STORAGE_KEY = 'debug-panel-height' const STORAGE_KEY = 'debug-panel-height'
const TAB_STORAGE_KEY = 'debug-panel-tab' const TAB_STORAGE_KEY = 'debug-panel-tab'
type TabType = 'agent' | 'devserver' | 'terminal' type TabType = 'agent' | 'devserver' | 'terminal' | 'browsers'
interface DebugLogViewerProps { interface DebugLogViewerProps {
logs: Array<{ line: string; timestamp: string }> logs: Array<{ line: string; timestamp: string }>
@@ -34,6 +35,9 @@ interface DebugLogViewerProps {
projectName: string projectName: string
activeTab?: TabType activeTab?: TabType
onTabChange?: (tab: TabType) => void onTabChange?: (tab: TabType) => void
browserScreenshots?: Map<string, BrowserScreenshot>
onSubscribeBrowserView?: () => void
onUnsubscribeBrowserView?: () => void
} }
type LogLevel = 'error' | 'warn' | 'debug' | 'info' type LogLevel = 'error' | 'warn' | 'debug' | 'info'
@@ -49,6 +53,9 @@ export function DebugLogViewer({
projectName, projectName,
activeTab: controlledActiveTab, activeTab: controlledActiveTab,
onTabChange, onTabChange,
browserScreenshots,
onSubscribeBrowserView,
onUnsubscribeBrowserView,
}: DebugLogViewerProps) { }: DebugLogViewerProps) {
const scrollRef = useRef<HTMLDivElement>(null) const scrollRef = useRef<HTMLDivElement>(null)
const devScrollRef = useRef<HTMLDivElement>(null) const devScrollRef = useRef<HTMLDivElement>(null)
@@ -395,11 +402,28 @@ export function DebugLogViewer({
T T
</Badge> </Badge>
</Button> </Button>
<Button
variant={activeTab === 'browsers' ? 'secondary' : 'ghost'}
size="sm"
onClick={(e: React.MouseEvent) => {
e.stopPropagation()
setActiveTab('browsers')
}}
className="h-7 text-xs font-mono gap-1.5"
>
<Monitor size={12} />
Browsers
{browserScreenshots && browserScreenshots.size > 0 && (
<Badge variant="default" className="h-4 px-1.5 text-[10px]">
{browserScreenshots.size}
</Badge>
)}
</Button>
</div> </div>
)} )}
{/* Log count and status - only for log tabs */} {/* Log count and status - only for log tabs */}
{isOpen && activeTab !== 'terminal' && ( {isOpen && activeTab !== 'terminal' && activeTab !== 'browsers' && (
<> <>
{getCurrentLogCount() > 0 && ( {getCurrentLogCount() > 0 && (
<Badge variant="secondary" className="ml-2 font-mono"> <Badge variant="secondary" className="ml-2 font-mono">
@@ -417,7 +441,7 @@ export function DebugLogViewer({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* Clear button - only for log tabs */} {/* Clear button - only for log tabs */}
{isOpen && activeTab !== 'terminal' && ( {isOpen && activeTab !== 'terminal' && activeTab !== 'browsers' && (
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -576,6 +600,15 @@ export function DebugLogViewer({
</div> </div>
</div> </div>
)} )}
{/* Browsers Tab */}
{activeTab === 'browsers' && browserScreenshots && onSubscribeBrowserView && onUnsubscribeBrowserView && (
<BrowserViewPanel
screenshots={browserScreenshots}
onSubscribe={onSubscribeBrowserView}
onUnsubscribe={onUnsubscribeBrowserView}
/>
)}
</div> </div>
)} )}
</div> </div>

View File

@@ -15,7 +15,7 @@ import {
Handle, Handle,
} from '@xyflow/react' } from '@xyflow/react'
import dagre from 'dagre' import dagre from 'dagre'
import { CheckCircle2, Circle, Loader2, AlertTriangle, RefreshCw } from 'lucide-react' import { CheckCircle2, Circle, Loader2, AlertTriangle, RefreshCw, UserCircle } from 'lucide-react'
import type { DependencyGraph as DependencyGraphData, GraphNode, ActiveAgent, AgentMascot, AgentState } from '../lib/types' import type { DependencyGraph as DependencyGraphData, GraphNode, ActiveAgent, AgentMascot, AgentState } from '../lib/types'
import { AgentAvatar } from './AgentAvatar' import { AgentAvatar } from './AgentAvatar'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -93,18 +93,20 @@ class GraphErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryStat
// Custom node component // Custom node component
function FeatureNode({ data }: { data: GraphNode & { onClick?: () => void; agent?: NodeAgentInfo } }) { function FeatureNode({ data }: { data: GraphNode & { onClick?: () => void; agent?: NodeAgentInfo } }) {
const statusColors = { const statusColors: Record<string, string> = {
pending: 'bg-yellow-100 border-yellow-300 dark:bg-yellow-900/30 dark:border-yellow-700', pending: 'bg-yellow-100 border-yellow-300 dark:bg-yellow-900/30 dark:border-yellow-700',
in_progress: 'bg-cyan-100 border-cyan-300 dark:bg-cyan-900/30 dark:border-cyan-700', in_progress: 'bg-cyan-100 border-cyan-300 dark:bg-cyan-900/30 dark:border-cyan-700',
done: 'bg-green-100 border-green-300 dark:bg-green-900/30 dark:border-green-700', done: 'bg-green-100 border-green-300 dark:bg-green-900/30 dark:border-green-700',
blocked: 'bg-red-50 border-red-300 dark:bg-red-900/20 dark:border-red-700', blocked: 'bg-red-50 border-red-300 dark:bg-red-900/20 dark:border-red-700',
needs_human_input: 'bg-amber-100 border-amber-300 dark:bg-amber-900/30 dark:border-amber-700',
} }
const textColors = { const textColors: Record<string, string> = {
pending: 'text-yellow-900 dark:text-yellow-100', pending: 'text-yellow-900 dark:text-yellow-100',
in_progress: 'text-cyan-900 dark:text-cyan-100', in_progress: 'text-cyan-900 dark:text-cyan-100',
done: 'text-green-900 dark:text-green-100', done: 'text-green-900 dark:text-green-100',
blocked: 'text-red-900 dark:text-red-100', blocked: 'text-red-900 dark:text-red-100',
needs_human_input: 'text-amber-900 dark:text-amber-100',
} }
const StatusIcon = () => { const StatusIcon = () => {
@@ -115,6 +117,8 @@ function FeatureNode({ data }: { data: GraphNode & { onClick?: () => void; agent
return <Loader2 size={16} className={`${textColors[data.status]} animate-spin`} /> return <Loader2 size={16} className={`${textColors[data.status]} animate-spin`} />
case 'blocked': case 'blocked':
return <AlertTriangle size={16} className="text-destructive" /> return <AlertTriangle size={16} className="text-destructive" />
case 'needs_human_input':
return <UserCircle size={16} className={textColors[data.status]} />
default: default:
return <Circle size={16} className={textColors[data.status]} /> return <Circle size={16} className={textColors[data.status]} />
} }
@@ -323,6 +327,8 @@ function DependencyGraphInner({ graphData, onNodeClick, activeAgents = [] }: Dep
return '#06b6d4' // cyan-500 return '#06b6d4' // cyan-500
case 'blocked': case 'blocked':
return '#ef4444' // red-500 return '#ef4444' // red-500
case 'needs_human_input':
return '#f59e0b' // amber-500
default: default:
return '#eab308' // yellow-500 return '#eab308' // yellow-500
} }

View File

@@ -6,20 +6,22 @@
*/ */
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { Send, X, CheckCircle2, AlertCircle, Wifi, WifiOff, RotateCcw, Paperclip, Plus } from 'lucide-react' import { Send, X, CheckCircle2, AlertCircle, Wifi, WifiOff, RotateCcw, Paperclip, Plus, FileText } from 'lucide-react'
import { useExpandChat } from '../hooks/useExpandChat' import { useExpandChat } from '../hooks/useExpandChat'
import { ChatMessage } from './ChatMessage' import { ChatMessage } from './ChatMessage'
import { TypingIndicator } from './TypingIndicator' import { TypingIndicator } from './TypingIndicator'
import type { ImageAttachment } from '../lib/types' import type { FileAttachment } from '../lib/types'
import { ALL_ALLOWED_MIME_TYPES, IMAGE_MIME_TYPES, isImageAttachment, resolveMimeType } from '../lib/types'
import { isSubmitEnter } from '../lib/keyboard' import { isSubmitEnter } from '../lib/keyboard'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { Alert, AlertDescription } from '@/components/ui/alert' import { Alert, AlertDescription } from '@/components/ui/alert'
// Image upload validation constants // File upload validation constants
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5 MB const MAX_IMAGE_SIZE = 5 * 1024 * 1024 // 5 MB for images
const ALLOWED_TYPES = ['image/jpeg', 'image/png'] const MAX_DOCUMENT_SIZE = 20 * 1024 * 1024 // 20 MB for documents
const ALLOWED_EXTENSIONS = ['md', 'txt', 'csv', 'docx', 'xlsx', 'pdf', 'pptx', 'jpg', 'jpeg', 'png']
interface ExpandProjectChatProps { interface ExpandProjectChatProps {
projectName: string projectName: string
@@ -34,7 +36,7 @@ export function ExpandProjectChat({
}: ExpandProjectChatProps) { }: ExpandProjectChatProps) {
const [input, setInput] = useState('') const [input, setInput] = useState('')
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [pendingAttachments, setPendingAttachments] = useState<ImageAttachment[]>([]) const [pendingAttachments, setPendingAttachments] = useState<FileAttachment[]>([])
const messagesEndRef = useRef<HTMLDivElement>(null) const messagesEndRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
@@ -95,20 +97,33 @@ export function ExpandProjectChat({
} }
} }
// File handling for image attachments // File handling for attachments (images and documents)
const handleFileSelect = useCallback((files: FileList | null) => { const handleFileSelect = useCallback((files: FileList | null) => {
if (!files) return if (!files) return
Array.from(files).forEach((file) => { Array.from(files).forEach((file) => {
// Validate file type // Resolve MIME type (browsers may not set it for .md files)
if (!ALLOWED_TYPES.includes(file.type)) { let mimeType = file.type
setError(`Invalid file type: ${file.name}. Only JPEG and PNG are supported.`) if (!mimeType || !ALL_ALLOWED_MIME_TYPES.includes(mimeType)) {
return mimeType = resolveMimeType(file.name)
} }
// Validate file size // Validate file type
if (file.size > MAX_FILE_SIZE) { if (!ALL_ALLOWED_MIME_TYPES.includes(mimeType)) {
setError(`File too large: ${file.name}. Maximum size is 5 MB.`) const ext = file.name.split('.').pop()?.toLowerCase()
if (!ext || !ALLOWED_EXTENSIONS.includes(ext)) {
setError(`Unsupported file type: ${file.name}. Supported: images (JPEG, PNG) and documents (MD, TXT, CSV, DOCX, XLSX, PDF, PPTX).`)
return
}
mimeType = resolveMimeType(file.name)
}
// Validate size based on type
const isImage = (IMAGE_MIME_TYPES as readonly string[]).includes(mimeType)
const maxSize = isImage ? MAX_IMAGE_SIZE : MAX_DOCUMENT_SIZE
const maxLabel = isImage ? '5 MB' : '20 MB'
if (file.size > maxSize) {
setError(`File too large: ${file.name}. Maximum size is ${maxLabel}.`)
return return
} }
@@ -118,12 +133,12 @@ export function ExpandProjectChat({
const dataUrl = e.target?.result as string const dataUrl = e.target?.result as string
const base64Data = dataUrl.split(',')[1] const base64Data = dataUrl.split(',')[1]
const attachment: ImageAttachment = { const attachment: FileAttachment = {
id: `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`, id: `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
filename: file.name, filename: file.name,
mimeType: file.type as 'image/jpeg' | 'image/png', mimeType: mimeType as FileAttachment['mimeType'],
base64Data, base64Data,
previewUrl: dataUrl, previewUrl: isImage ? dataUrl : '',
size: file.size, size: file.size,
} }
@@ -291,11 +306,17 @@ export function ExpandProjectChat({
key={attachment.id} key={attachment.id}
className="relative group border-2 border-border p-1 bg-card rounded shadow-sm" className="relative group border-2 border-border p-1 bg-card rounded shadow-sm"
> >
{isImageAttachment(attachment) ? (
<img <img
src={attachment.previewUrl} src={attachment.previewUrl}
alt={attachment.filename} alt={attachment.filename}
className="w-16 h-16 object-cover rounded" className="w-16 h-16 object-cover rounded"
/> />
) : (
<div className="w-16 h-16 flex items-center justify-center bg-muted rounded">
<FileText size={24} className="text-muted-foreground" />
</div>
)}
<button <button
onClick={() => handleRemoveAttachment(attachment.id)} onClick={() => handleRemoveAttachment(attachment.id)}
className="absolute -top-2 -right-2 bg-destructive text-destructive-foreground rounded-full p-0.5 border-2 border-border hover:scale-110 transition-transform" className="absolute -top-2 -right-2 bg-destructive text-destructive-foreground rounded-full p-0.5 border-2 border-border hover:scale-110 transition-transform"
@@ -318,7 +339,7 @@ export function ExpandProjectChat({
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
accept="image/jpeg,image/png" accept="image/jpeg,image/png,.md,.txt,.csv,.docx,.xlsx,.pdf,.pptx"
multiple multiple
onChange={(e) => handleFileSelect(e.target.files)} onChange={(e) => handleFileSelect(e.target.files)}
className="hidden" className="hidden"
@@ -330,7 +351,7 @@ export function ExpandProjectChat({
disabled={connectionStatus !== 'connected'} disabled={connectionStatus !== 'connected'}
variant="ghost" variant="ghost"
size="icon" size="icon"
title="Attach image (JPEG, PNG - max 5MB)" title="Attach files (images: JPEG/PNG up to 5MB, documents: MD, TXT, CSV, DOCX, XLSX, PDF, PPTX up to 20MB)"
> >
<Paperclip size={18} /> <Paperclip size={18} />
</Button> </Button>
@@ -364,7 +385,7 @@ export function ExpandProjectChat({
{/* Help text */} {/* Help text */}
<p className="text-xs text-muted-foreground mt-2"> <p className="text-xs text-muted-foreground mt-2">
Press Enter to send. Drag & drop or click <Paperclip size={12} className="inline" /> to attach images. Press Enter to send. Drag & drop or click <Paperclip size={12} className="inline" /> to attach files.
</p> </p>
</div> </div>
)} )}

View File

@@ -1,4 +1,4 @@
import { CheckCircle2, Circle, Loader2, MessageCircle } from 'lucide-react' import { CheckCircle2, Circle, Loader2, MessageCircle, UserCircle } from 'lucide-react'
import type { Feature, ActiveAgent } from '../lib/types' import type { Feature, ActiveAgent } from '../lib/types'
import { DependencyBadge } from './DependencyBadge' import { DependencyBadge } from './DependencyBadge'
import { AgentAvatar } from './AgentAvatar' import { AgentAvatar } from './AgentAvatar'
@@ -45,7 +45,8 @@ export function FeatureCard({ feature, onClick, isInProgress, allFeatures = [],
cursor-pointer transition-all hover:border-primary py-3 cursor-pointer transition-all hover:border-primary py-3
${isInProgress ? 'animate-pulse' : ''} ${isInProgress ? 'animate-pulse' : ''}
${feature.passes ? 'border-primary/50' : ''} ${feature.passes ? 'border-primary/50' : ''}
${isBlocked && !feature.passes ? 'border-destructive/50 opacity-80' : ''} ${feature.needs_human_input ? 'border-amber-500/50' : ''}
${isBlocked && !feature.passes && !feature.needs_human_input ? 'border-destructive/50 opacity-80' : ''}
${hasActiveAgent ? 'ring-2 ring-primary ring-offset-2' : ''} ${hasActiveAgent ? 'ring-2 ring-primary ring-offset-2' : ''}
`} `}
> >
@@ -105,6 +106,11 @@ export function FeatureCard({ feature, onClick, isInProgress, allFeatures = [],
<CheckCircle2 size={16} className="text-primary" /> <CheckCircle2 size={16} className="text-primary" />
<span className="text-primary font-medium">Complete</span> <span className="text-primary font-medium">Complete</span>
</> </>
) : feature.needs_human_input ? (
<>
<UserCircle size={16} className="text-amber-500" />
<span className="text-amber-500 font-medium">Needs Your Input</span>
</>
) : isBlocked ? ( ) : isBlocked ? (
<> <>
<Circle size={16} className="text-destructive" /> <Circle size={16} className="text-destructive" />

View File

@@ -1,7 +1,8 @@
import { useState } from 'react' import { useState } from 'react'
import { X, CheckCircle2, Circle, SkipForward, Trash2, Loader2, AlertCircle, Pencil, Link2, AlertTriangle } from 'lucide-react' import { X, CheckCircle2, Circle, SkipForward, Trash2, Loader2, AlertCircle, Pencil, Link2, AlertTriangle, UserCircle } from 'lucide-react'
import { useSkipFeature, useDeleteFeature, useFeatures } from '../hooks/useProjects' import { useSkipFeature, useDeleteFeature, useFeatures, useResolveHumanInput } from '../hooks/useProjects'
import { EditFeatureForm } from './EditFeatureForm' import { EditFeatureForm } from './EditFeatureForm'
import { HumanInputForm } from './HumanInputForm'
import type { Feature } from '../lib/types' import type { Feature } from '../lib/types'
import { import {
Dialog, Dialog,
@@ -50,10 +51,12 @@ export function FeatureModal({ feature, projectName, onClose }: FeatureModalProp
const deleteFeature = useDeleteFeature(projectName) const deleteFeature = useDeleteFeature(projectName)
const { data: allFeatures } = useFeatures(projectName) const { data: allFeatures } = useFeatures(projectName)
const resolveHumanInput = useResolveHumanInput(projectName)
// Build a map of feature ID to feature for looking up dependency names // Build a map of feature ID to feature for looking up dependency names
const featureMap = new Map<number, Feature>() const featureMap = new Map<number, Feature>()
if (allFeatures) { if (allFeatures) {
;[...allFeatures.pending, ...allFeatures.in_progress, ...allFeatures.done].forEach(f => { ;[...allFeatures.pending, ...allFeatures.in_progress, ...allFeatures.done, ...(allFeatures.needs_human_input || [])].forEach(f => {
featureMap.set(f.id, f) featureMap.set(f.id, f)
}) })
} }
@@ -141,6 +144,11 @@ export function FeatureModal({ feature, projectName, onClose }: FeatureModalProp
<CheckCircle2 size={24} className="text-primary" /> <CheckCircle2 size={24} className="text-primary" />
<span className="font-semibold text-primary">COMPLETE</span> <span className="font-semibold text-primary">COMPLETE</span>
</> </>
) : feature.needs_human_input ? (
<>
<UserCircle size={24} className="text-amber-500" />
<span className="font-semibold text-amber-500">NEEDS YOUR INPUT</span>
</>
) : ( ) : (
<> <>
<Circle size={24} className="text-muted-foreground" /> <Circle size={24} className="text-muted-foreground" />
@@ -152,6 +160,38 @@ export function FeatureModal({ feature, projectName, onClose }: FeatureModalProp
</span> </span>
</div> </div>
{/* Human Input Request */}
{feature.needs_human_input && feature.human_input_request && (
<HumanInputForm
request={feature.human_input_request}
onSubmit={async (fields) => {
setError(null)
try {
await resolveHumanInput.mutateAsync({ featureId: feature.id, fields })
onClose()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to submit response')
}
}}
isLoading={resolveHumanInput.isPending}
/>
)}
{/* Previous Human Input Response */}
{feature.human_input_response && !feature.needs_human_input && (
<Alert className="border-green-500 bg-green-50 dark:bg-green-950/20">
<CheckCircle2 className="h-4 w-4 text-green-600" />
<AlertDescription>
<h4 className="font-semibold mb-1 text-green-700 dark:text-green-400">Human Input Provided</h4>
<p className="text-sm text-green-600 dark:text-green-300">
Response submitted{feature.human_input_response.responded_at
? ` at ${new Date(feature.human_input_response.responded_at).toLocaleString()}`
: ''}.
</p>
</AlertDescription>
</Alert>
)}
{/* Description */} {/* Description */}
<div> <div>
<h3 className="font-semibold mb-2 text-sm uppercase tracking-wide text-muted-foreground"> <h3 className="font-semibold mb-2 text-sm uppercase tracking-wide text-muted-foreground">

View File

@@ -0,0 +1,150 @@
import { useState } from 'react'
import { Loader2, UserCircle, Send } from 'lucide-react'
import type { HumanInputRequest } from '../lib/types'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Switch } from '@/components/ui/switch'
interface HumanInputFormProps {
request: HumanInputRequest
onSubmit: (fields: Record<string, string | boolean | string[]>) => Promise<void>
isLoading: boolean
}
export function HumanInputForm({ request, onSubmit, isLoading }: HumanInputFormProps) {
const [values, setValues] = useState<Record<string, string | boolean | string[]>>(() => {
const initial: Record<string, string | boolean | string[]> = {}
for (const field of request.fields) {
if (field.type === 'boolean') {
initial[field.id] = false
} else {
initial[field.id] = ''
}
}
return initial
})
const [validationError, setValidationError] = useState<string | null>(null)
const handleSubmit = async () => {
// Validate required fields
for (const field of request.fields) {
if (field.required) {
const val = values[field.id]
if (val === undefined || val === null || val === '') {
setValidationError(`"${field.label}" is required`)
return
}
}
}
setValidationError(null)
await onSubmit(values)
}
return (
<Alert className="border-amber-500 bg-amber-50 dark:bg-amber-950/20">
<UserCircle className="h-5 w-5 text-amber-600" />
<AlertDescription className="space-y-4">
<div>
<h4 className="font-semibold text-amber-700 dark:text-amber-400">Agent needs your help</h4>
<p className="text-sm text-amber-600 dark:text-amber-300 mt-1">
{request.prompt}
</p>
</div>
<div className="space-y-3">
{request.fields.map((field) => (
<div key={field.id} className="space-y-1.5">
<Label htmlFor={`human-input-${field.id}`} className="text-sm font-medium text-foreground">
{field.label}
{field.required && <span className="text-destructive ml-1">*</span>}
</Label>
{field.type === 'text' && (
<Input
id={`human-input-${field.id}`}
value={values[field.id] as string}
onChange={(e) => setValues(prev => ({ ...prev, [field.id]: e.target.value }))}
placeholder={field.placeholder || ''}
disabled={isLoading}
/>
)}
{field.type === 'textarea' && (
<Textarea
id={`human-input-${field.id}`}
value={values[field.id] as string}
onChange={(e) => setValues(prev => ({ ...prev, [field.id]: e.target.value }))}
placeholder={field.placeholder || ''}
disabled={isLoading}
rows={3}
/>
)}
{field.type === 'select' && field.options && (
<div className="space-y-1.5">
{field.options.map((option) => (
<label
key={option.value}
className={`flex items-center gap-2 p-2 rounded-md border cursor-pointer transition-colors
${values[field.id] === option.value
? 'border-primary bg-primary/10'
: 'border-border hover:border-primary/50'}`}
>
<input
type="radio"
name={`human-input-${field.id}`}
value={option.value}
checked={values[field.id] === option.value}
onChange={(e) => setValues(prev => ({ ...prev, [field.id]: e.target.value }))}
disabled={isLoading}
className="accent-primary"
/>
<span className="text-sm">{option.label}</span>
</label>
))}
</div>
)}
{field.type === 'boolean' && (
<div className="flex items-center gap-2">
<Switch
id={`human-input-${field.id}`}
checked={values[field.id] as boolean}
onCheckedChange={(checked) => setValues(prev => ({ ...prev, [field.id]: checked }))}
disabled={isLoading}
/>
<Label htmlFor={`human-input-${field.id}`} className="text-sm">
{values[field.id] ? 'Yes' : 'No'}
</Label>
</div>
)}
</div>
))}
</div>
{validationError && (
<p className="text-sm text-destructive">{validationError}</p>
)}
<Button
onClick={handleSubmit}
disabled={isLoading}
className="w-full"
>
{isLoading ? (
<Loader2 size={16} className="animate-spin" />
) : (
<>
<Send size={16} />
Submit Response
</>
)}
</Button>
</AlertDescription>
</Alert>
)
}

View File

@@ -13,13 +13,16 @@ interface KanbanBoardProps {
} }
export function KanbanBoard({ features, onFeatureClick, onAddFeature, onExpandProject, activeAgents = [], onCreateSpec, hasSpec = true }: KanbanBoardProps) { export function KanbanBoard({ features, onFeatureClick, onAddFeature, onExpandProject, activeAgents = [], onCreateSpec, hasSpec = true }: KanbanBoardProps) {
const hasFeatures = features && (features.pending.length + features.in_progress.length + features.done.length) > 0 const hasFeatures = features && (features.pending.length + features.in_progress.length + features.done.length + (features.needs_human_input?.length || 0)) > 0
// Combine all features for dependency status calculation // Combine all features for dependency status calculation
const allFeatures = features const allFeatures = features
? [...features.pending, ...features.in_progress, ...features.done] ? [...features.pending, ...features.in_progress, ...features.done, ...(features.needs_human_input || [])]
: [] : []
const needsInputCount = features?.needs_human_input?.length || 0
const showNeedsInput = needsInputCount > 0
if (!features) { if (!features) {
return ( return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
@@ -40,7 +43,7 @@ export function KanbanBoard({ features, onFeatureClick, onAddFeature, onExpandPr
} }
return ( return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6"> <div className={`grid grid-cols-1 ${showNeedsInput ? 'md:grid-cols-4' : 'md:grid-cols-3'} gap-6`}>
<KanbanColumn <KanbanColumn
title="Pending" title="Pending"
count={features.pending.length} count={features.pending.length}
@@ -64,6 +67,17 @@ export function KanbanBoard({ features, onFeatureClick, onAddFeature, onExpandPr
color="progress" color="progress"
onFeatureClick={onFeatureClick} onFeatureClick={onFeatureClick}
/> />
{showNeedsInput && (
<KanbanColumn
title="Needs Input"
count={needsInputCount}
features={features.needs_human_input}
allFeatures={allFeatures}
activeAgents={activeAgents}
color="human_input"
onFeatureClick={onFeatureClick}
/>
)}
<KanbanColumn <KanbanColumn
title="Done" title="Done"
count={features.done.length} count={features.done.length}

View File

@@ -11,7 +11,7 @@ interface KanbanColumnProps {
features: Feature[] features: Feature[]
allFeatures?: Feature[] allFeatures?: Feature[]
activeAgents?: ActiveAgent[] activeAgents?: ActiveAgent[]
color: 'pending' | 'progress' | 'done' color: 'pending' | 'progress' | 'done' | 'human_input'
onFeatureClick: (feature: Feature) => void onFeatureClick: (feature: Feature) => void
onAddFeature?: () => void onAddFeature?: () => void
onExpandProject?: () => void onExpandProject?: () => void
@@ -24,6 +24,7 @@ const colorMap = {
pending: 'border-t-4 border-t-muted', pending: 'border-t-4 border-t-muted',
progress: 'border-t-4 border-t-primary', progress: 'border-t-4 border-t-primary',
done: 'border-t-4 border-t-primary', done: 'border-t-4 border-t-primary',
human_input: 'border-t-4 border-t-amber-500',
} }
export function KanbanColumn({ export function KanbanColumn({

View File

@@ -16,13 +16,18 @@ interface Shortcut {
const shortcuts: Shortcut[] = [ const shortcuts: Shortcut[] = [
{ key: '?', description: 'Show keyboard shortcuts' }, { key: '?', description: 'Show keyboard shortcuts' },
{ key: 'D', description: 'Toggle debug panel' }, { key: 'H', description: 'Dashboard view' },
{ key: 'T', description: 'Toggle terminal tab' }, { key: 'K', description: 'Kanban board' },
{ key: 'G', description: 'Dependency graph' },
{ key: 'B', description: 'Browser screenshots' },
{ key: 'T', description: 'Terminal' },
{ key: 'D', description: 'Logs' },
{ key: 'A', description: 'Toggle AI assistant', context: 'with project' },
{ key: '[', description: 'Toggle sidebar' },
{ key: 'N', description: 'Add new feature', context: 'with project' }, { key: 'N', description: 'Add new feature', context: 'with project' },
{ key: 'E', description: 'Expand project with AI', context: 'with spec & features' }, { key: 'E', description: 'Expand project with AI', context: 'with spec & features' },
{ key: 'A', description: 'Toggle AI assistant', context: 'with project' }, { key: ',', description: 'Settings' },
{ key: 'G', description: 'Toggle Kanban/Graph view', context: 'with project' }, { key: 'R', description: 'Reset project', context: 'with project' },
{ key: ',', description: 'Open settings' },
{ key: 'Esc', description: 'Close modal/panel' }, { key: 'Esc', description: 'Close modal/panel' },
] ]

View File

@@ -4,14 +4,15 @@
* Multi-step modal for creating new projects: * Multi-step modal for creating new projects:
* 1. Enter project name * 1. Enter project name
* 2. Select project folder * 2. Select project folder
* 3. Choose spec method (Claude or manual) * 3. Choose project template (blank or agentic starter)
* 4a. If Claude: Show SpecCreationChat * 4. Choose spec method (Claude or manual)
* 4b. If manual: Create project and close * 5a. If Claude: Show SpecCreationChat
* 5b. If manual: Create project and close
*/ */
import { useState } from 'react' import { useRef, useState } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { Bot, FileEdit, ArrowRight, ArrowLeft, Loader2, CheckCircle2, Folder } from 'lucide-react' import { Bot, FileEdit, ArrowRight, ArrowLeft, Loader2, CheckCircle2, Folder, Zap, FileCode2, AlertCircle, RotateCcw } from 'lucide-react'
import { useCreateProject } from '../hooks/useProjects' import { useCreateProject } from '../hooks/useProjects'
import { SpecCreationChat } from './SpecCreationChat' import { SpecCreationChat } from './SpecCreationChat'
import { FolderBrowser } from './FolderBrowser' import { FolderBrowser } from './FolderBrowser'
@@ -32,8 +33,9 @@ import { Badge } from '@/components/ui/badge'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
type InitializerStatus = 'idle' | 'starting' | 'error' type InitializerStatus = 'idle' | 'starting' | 'error'
type ScaffoldStatus = 'idle' | 'running' | 'success' | 'error'
type Step = 'name' | 'folder' | 'method' | 'chat' | 'complete' type Step = 'name' | 'folder' | 'template' | 'method' | 'chat' | 'complete'
type SpecMethod = 'claude' | 'manual' type SpecMethod = 'claude' | 'manual'
interface NewProjectModalProps { interface NewProjectModalProps {
@@ -57,6 +59,10 @@ export function NewProjectModal({
const [initializerStatus, setInitializerStatus] = useState<InitializerStatus>('idle') const [initializerStatus, setInitializerStatus] = useState<InitializerStatus>('idle')
const [initializerError, setInitializerError] = useState<string | null>(null) const [initializerError, setInitializerError] = useState<string | null>(null)
const [yoloModeSelected, setYoloModeSelected] = useState(false) const [yoloModeSelected, setYoloModeSelected] = useState(false)
const [scaffoldStatus, setScaffoldStatus] = useState<ScaffoldStatus>('idle')
const [scaffoldOutput, setScaffoldOutput] = useState<string[]>([])
const [scaffoldError, setScaffoldError] = useState<string | null>(null)
const scaffoldLogRef = useRef<HTMLDivElement>(null)
// Suppress unused variable warning - specMethod may be used in future // Suppress unused variable warning - specMethod may be used in future
void _specMethod void _specMethod
@@ -91,13 +97,84 @@ export function NewProjectModal({
const handleFolderSelect = (path: string) => { const handleFolderSelect = (path: string) => {
setProjectPath(path) setProjectPath(path)
changeStep('method') changeStep('template')
} }
const handleFolderCancel = () => { const handleFolderCancel = () => {
changeStep('name') changeStep('name')
} }
const handleTemplateSelect = async (choice: 'blank' | 'agentic-starter') => {
if (choice === 'blank') {
changeStep('method')
return
}
if (!projectPath) return
setScaffoldStatus('running')
setScaffoldOutput([])
setScaffoldError(null)
try {
const res = await fetch('/api/scaffold/run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ template: 'agentic-starter', target_path: projectPath }),
})
if (!res.ok || !res.body) {
setScaffoldStatus('error')
setScaffoldError(`Server error: ${res.status}`)
return
}
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (!line.startsWith('data: ')) continue
try {
const event = JSON.parse(line.slice(6))
if (event.type === 'output') {
setScaffoldOutput(prev => {
const next = [...prev, event.line]
return next.length > 100 ? next.slice(-100) : next
})
// Auto-scroll
setTimeout(() => scaffoldLogRef.current?.scrollTo(0, scaffoldLogRef.current.scrollHeight), 0)
} else if (event.type === 'complete') {
if (event.success) {
setScaffoldStatus('success')
setTimeout(() => changeStep('method'), 1200)
} else {
setScaffoldStatus('error')
setScaffoldError(`Scaffold exited with code ${event.exit_code}`)
}
} else if (event.type === 'error') {
setScaffoldStatus('error')
setScaffoldError(event.message)
}
} catch {
// skip malformed SSE lines
}
}
}
} catch (err) {
setScaffoldStatus('error')
setScaffoldError(err instanceof Error ? err.message : 'Failed to run scaffold')
}
}
const handleMethodSelect = async (method: SpecMethod) => { const handleMethodSelect = async (method: SpecMethod) => {
setSpecMethod(method) setSpecMethod(method)
@@ -188,13 +265,21 @@ export function NewProjectModal({
setInitializerStatus('idle') setInitializerStatus('idle')
setInitializerError(null) setInitializerError(null)
setYoloModeSelected(false) setYoloModeSelected(false)
setScaffoldStatus('idle')
setScaffoldOutput([])
setScaffoldError(null)
onClose() onClose()
} }
const handleBack = () => { const handleBack = () => {
if (step === 'method') { if (step === 'method') {
changeStep('folder') changeStep('template')
setSpecMethod(null) setSpecMethod(null)
} else if (step === 'template') {
changeStep('folder')
setScaffoldStatus('idle')
setScaffoldOutput([])
setScaffoldError(null)
} else if (step === 'folder') { } else if (step === 'folder') {
changeStep('name') changeStep('name')
setProjectPath(null) setProjectPath(null)
@@ -255,6 +340,7 @@ export function NewProjectModal({
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
{step === 'name' && 'Create New Project'} {step === 'name' && 'Create New Project'}
{step === 'template' && 'Choose Project Template'}
{step === 'method' && 'Choose Setup Method'} {step === 'method' && 'Choose Setup Method'}
{step === 'complete' && 'Project Created!'} {step === 'complete' && 'Project Created!'}
</DialogTitle> </DialogTitle>
@@ -294,7 +380,127 @@ export function NewProjectModal({
</form> </form>
)} )}
{/* Step 2: Spec Method */} {/* Step 2: Project Template */}
{step === 'template' && (
<div className="space-y-4">
{scaffoldStatus === 'idle' && (
<>
<DialogDescription>
Start with a blank project or use a pre-configured template.
</DialogDescription>
<div className="space-y-3">
<Card
className="cursor-pointer hover:border-primary transition-colors"
onClick={() => handleTemplateSelect('blank')}
>
<CardContent className="p-4">
<div className="flex items-start gap-4">
<div className="p-2 bg-secondary rounded-lg">
<FileCode2 size={24} className="text-secondary-foreground" />
</div>
<div className="flex-1">
<span className="font-semibold">Blank Project</span>
<p className="text-sm text-muted-foreground mt-1">
Start from scratch. AutoForge will scaffold your app based on the spec you define.
</p>
</div>
</div>
</CardContent>
</Card>
<Card
className="cursor-pointer hover:border-primary transition-colors"
onClick={() => handleTemplateSelect('agentic-starter')}
>
<CardContent className="p-4">
<div className="flex items-start gap-4">
<div className="p-2 bg-primary/10 rounded-lg">
<Zap size={24} className="text-primary" />
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-semibold">Agentic Starter</span>
<Badge variant="secondary">Next.js</Badge>
</div>
<p className="text-sm text-muted-foreground mt-1">
Pre-configured Next.js app with BetterAuth, Drizzle ORM, Postgres, and AI capabilities.
</p>
</div>
</div>
</CardContent>
</Card>
</div>
<DialogFooter className="sm:justify-start">
<Button variant="ghost" onClick={handleBack}>
<ArrowLeft size={16} />
Back
</Button>
</DialogFooter>
</>
)}
{scaffoldStatus === 'running' && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Loader2 size={16} className="animate-spin text-primary" />
<span className="font-medium">Setting up Agentic Starter...</span>
</div>
<div
ref={scaffoldLogRef}
className="bg-muted rounded-lg p-3 max-h-60 overflow-y-auto font-mono text-xs leading-relaxed"
>
{scaffoldOutput.map((line, i) => (
<div key={i} className="whitespace-pre-wrap break-all">{line}</div>
))}
</div>
</div>
)}
{scaffoldStatus === 'success' && (
<div className="text-center py-6">
<div className="inline-flex items-center justify-center w-12 h-12 bg-primary/10 rounded-full mb-3">
<CheckCircle2 size={24} className="text-primary" />
</div>
<p className="font-medium">Template ready!</p>
<p className="text-sm text-muted-foreground mt-1">Proceeding to setup method...</p>
</div>
)}
{scaffoldStatus === 'error' && (
<div className="space-y-3">
<Alert variant="destructive">
<AlertCircle size={16} />
<AlertDescription>
{scaffoldError || 'An unknown error occurred'}
</AlertDescription>
</Alert>
{scaffoldOutput.length > 0 && (
<div className="bg-muted rounded-lg p-3 max-h-40 overflow-y-auto font-mono text-xs leading-relaxed">
{scaffoldOutput.slice(-10).map((line, i) => (
<div key={i} className="whitespace-pre-wrap break-all">{line}</div>
))}
</div>
)}
<DialogFooter className="sm:justify-start gap-2">
<Button variant="ghost" onClick={handleBack}>
<ArrowLeft size={16} />
Back
</Button>
<Button variant="outline" onClick={() => handleTemplateSelect('agentic-starter')}>
<RotateCcw size={16} />
Retry
</Button>
</DialogFooter>
</div>
)}
</div>
)}
{/* Step 3: Spec Method */}
{step === 'method' && ( {step === 'method' && (
<div className="space-y-4"> <div className="space-y-4">
<DialogDescription> <DialogDescription>

View File

@@ -103,6 +103,10 @@ function getStateAnimation(state: OrchestratorState): string {
return 'animate-working' return 'animate-working'
case 'monitoring': case 'monitoring':
return 'animate-bounce-gentle' return 'animate-bounce-gentle'
case 'draining':
return 'animate-thinking'
case 'paused':
return ''
case 'complete': case 'complete':
return 'animate-celebrate' return 'animate-celebrate'
default: default:
@@ -121,6 +125,10 @@ function getStateGlow(state: OrchestratorState): string {
return 'shadow-[0_0_16px_rgba(124,58,237,0.6)]' return 'shadow-[0_0_16px_rgba(124,58,237,0.6)]'
case 'monitoring': case 'monitoring':
return 'shadow-[0_0_8px_rgba(167,139,250,0.4)]' return 'shadow-[0_0_8px_rgba(167,139,250,0.4)]'
case 'draining':
return 'shadow-[0_0_10px_rgba(251,191,36,0.5)]'
case 'paused':
return ''
case 'complete': case 'complete':
return 'shadow-[0_0_20px_rgba(112,224,0,0.6)]' return 'shadow-[0_0_20px_rgba(112,224,0,0.6)]'
default: default:
@@ -141,6 +149,10 @@ function getStateDescription(state: OrchestratorState): string {
return 'spawning agents' return 'spawning agents'
case 'monitoring': case 'monitoring':
return 'monitoring progress' return 'monitoring progress'
case 'draining':
return 'draining active agents'
case 'paused':
return 'paused'
case 'complete': case 'complete':
return 'all features complete' return 'all features complete'
default: default:

View File

@@ -25,6 +25,10 @@ function getStateText(state: OrchestratorState): string {
return 'Watching progress...' return 'Watching progress...'
case 'complete': case 'complete':
return 'Mission accomplished!' return 'Mission accomplished!'
case 'draining':
return 'Draining agents...'
case 'paused':
return 'Paused'
default: default:
return 'Orchestrating...' return 'Orchestrating...'
} }
@@ -42,6 +46,10 @@ function getStateColor(state: OrchestratorState): string {
return 'text-primary' return 'text-primary'
case 'initializing': case 'initializing':
return 'text-yellow-600 dark:text-yellow-400' return 'text-yellow-600 dark:text-yellow-400'
case 'draining':
return 'text-amber-600 dark:text-amber-400'
case 'paused':
return 'text-muted-foreground'
default: default:
return 'text-muted-foreground' return 'text-muted-foreground'
} }

View File

@@ -55,7 +55,7 @@ export function ProgressDashboard({
const showThought = useMemo(() => { const showThought = useMemo(() => {
if (!thought) return false if (!thought) return false
if (agentStatus === 'running') return true if (agentStatus === 'running' || agentStatus === 'pausing') return true
if (agentStatus === 'paused') { if (agentStatus === 'paused') {
return Date.now() - lastLogTimestamp < IDLE_TIMEOUT return Date.now() - lastLogTimestamp < IDLE_TIMEOUT
} }

View File

@@ -1,5 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import { Loader2, AlertCircle, Check, Moon, Sun, Eye, EyeOff, ShieldCheck } from 'lucide-react' import { Loader2, AlertCircle, AlertTriangle, Check, Moon, Sun, Eye, EyeOff, ShieldCheck } from 'lucide-react'
import { useSettings, useUpdateSettings, useAvailableModels, useAvailableProviders } from '../hooks/useProjects' import { useSettings, useUpdateSettings, useAvailableModels, useAvailableProviders } from '../hooks/useProjects'
import { useTheme, THEMES } from '../hooks/useTheme' import { useTheme, THEMES } from '../hooks/useTheme'
import type { ProviderInfo } from '../lib/types' import type { ProviderInfo } from '../lib/types'
@@ -10,6 +10,7 @@ import {
DialogTitle, DialogTitle,
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { Slider } from '@/components/ui/slider'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Alert, AlertDescription } from '@/components/ui/alert' import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -20,7 +21,7 @@ interface SettingsModalProps {
} }
const PROVIDER_INFO_TEXT: Record<string, string> = { const PROVIDER_INFO_TEXT: Record<string, string> = {
claude: 'Default provider. Uses your Claude CLI credentials.', claude: 'Default provider. Uses Claude CLI credentials. API key auth is recommended.',
kimi: 'Get an API key at kimi.com', kimi: 'Get an API key at kimi.com',
glm: 'Get an API key at open.bigmodel.cn', glm: 'Get an API key at open.bigmodel.cn',
ollama: 'Run models locally. Install from ollama.com', ollama: 'Run models locally. Install from ollama.com',
@@ -63,6 +64,12 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
} }
} }
const handleTestingBatchSizeChange = (size: number) => {
if (!updateSettings.isPending) {
updateSettings.mutate({ testing_batch_size: size })
}
}
const handleProviderChange = (providerId: string) => { const handleProviderChange = (providerId: string) => {
if (!updateSettings.isPending) { if (!updateSettings.isPending) {
updateSettings.mutate({ api_provider: providerId }) updateSettings.mutate({ api_provider: providerId })
@@ -238,6 +245,15 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
{PROVIDER_INFO_TEXT[currentProvider] ?? ''} {PROVIDER_INFO_TEXT[currentProvider] ?? ''}
</p> </p>
{currentProvider === 'claude' && (
<Alert className="border-amber-500/50 bg-amber-50 dark:bg-amber-950/20 mt-2">
<AlertTriangle className="h-4 w-4 text-amber-600" />
<AlertDescription className="text-xs text-amber-700 dark:text-amber-300">
Anthropic's policy may not permit using subscription-based auth (<code className="text-xs">claude login</code>) with third-party agents. Consider using an API key provider or setting the <code className="text-xs">ANTHROPIC_API_KEY</code> environment variable to avoid potential account issues.
</AlertDescription>
</Alert>
)}
{/* Auth Token Field */} {/* Auth Token Field */}
{showAuthField && ( {showAuthField && (
<div className="space-y-2 pt-1"> <div className="space-y-2 pt-1">
@@ -390,24 +406,6 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
/> />
</div> </div>
{/* Headless Browser Toggle */}
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="playwright-headless" className="font-medium">
Headless Browser
</Label>
<p className="text-sm text-muted-foreground">
Run browser without visible window (saves CPU)
</p>
</div>
<Switch
id="playwright-headless"
checked={settings.playwright_headless}
onCheckedChange={() => updateSettings.mutate({ playwright_headless: !settings.playwright_headless })}
disabled={isSaving}
/>
</div>
{/* Regression Agents */} {/* Regression Agents */}
<div className="space-y-2"> <div className="space-y-2">
<Label className="font-medium">Regression Agents</Label> <Label className="font-medium">Regression Agents</Label>
@@ -432,28 +430,34 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
</div> </div>
</div> </div>
{/* Features per Agent */} {/* Features per Coding Agent */}
<div className="space-y-2"> <div className="space-y-2">
<Label className="font-medium">Features per Agent</Label> <Label className="font-medium">Features per Coding Agent</Label>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Number of features assigned to each coding agent Number of features assigned to each coding agent session
</p> </p>
<div className="flex rounded-lg border overflow-hidden"> <Slider
{[1, 2, 3].map((size) => ( min={1}
<button max={15}
key={size} value={settings.batch_size ?? 3}
onClick={() => handleBatchSizeChange(size)} onChange={handleBatchSizeChange}
disabled={isSaving} disabled={isSaving}
className={`flex-1 py-2 px-3 text-sm font-medium transition-colors ${ />
(settings.batch_size ?? 1) === size
? 'bg-primary text-primary-foreground'
: 'bg-background text-foreground hover:bg-muted'
} ${isSaving ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{size}
</button>
))}
</div> </div>
{/* Features per Testing Agent */}
<div className="space-y-2">
<Label className="font-medium">Features per Testing Agent</Label>
<p className="text-sm text-muted-foreground">
Number of features assigned to each testing agent session
</p>
<Slider
min={1}
max={15}
value={settings.testing_batch_size ?? 3}
onChange={handleTestingBatchSizeChange}
disabled={isSaving}
/>
</div> </div>
{/* Update Error */} {/* Update Error */}

View File

@@ -11,16 +11,18 @@ import { useSpecChat } from '../hooks/useSpecChat'
import { ChatMessage } from './ChatMessage' import { ChatMessage } from './ChatMessage'
import { QuestionOptions } from './QuestionOptions' import { QuestionOptions } from './QuestionOptions'
import { TypingIndicator } from './TypingIndicator' import { TypingIndicator } from './TypingIndicator'
import type { ImageAttachment } from '../lib/types' import type { FileAttachment } from '../lib/types'
import { ALL_ALLOWED_MIME_TYPES, IMAGE_MIME_TYPES, isImageAttachment, resolveMimeType } from '../lib/types'
import { isSubmitEnter } from '../lib/keyboard' import { isSubmitEnter } from '../lib/keyboard'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { Alert, AlertDescription } from '@/components/ui/alert' import { Alert, AlertDescription } from '@/components/ui/alert'
// Image upload validation constants // File upload validation constants
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5 MB const MAX_IMAGE_SIZE = 5 * 1024 * 1024 // 5 MB for images
const ALLOWED_TYPES = ['image/jpeg', 'image/png'] const MAX_DOCUMENT_SIZE = 20 * 1024 * 1024 // 20 MB for documents
const ALLOWED_EXTENSIONS = ['md', 'txt', 'csv', 'docx', 'xlsx', 'pdf', 'pptx', 'jpg', 'jpeg', 'png']
// Sample prompt for quick testing // Sample prompt for quick testing
const SAMPLE_PROMPT = `Let's call it Simple Todo. This is a really simple web app that I can use to track my to-do items using a Kanban board. I should be able to add to-dos and then drag and drop them through the Kanban board. The different columns in the Kanban board are: const SAMPLE_PROMPT = `Let's call it Simple Todo. This is a really simple web app that I can use to track my to-do items using a Kanban board. I should be able to add to-dos and then drag and drop them through the Kanban board. The different columns in the Kanban board are:
@@ -64,7 +66,7 @@ export function SpecCreationChat({
const [input, setInput] = useState('') const [input, setInput] = useState('')
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [yoloEnabled, setYoloEnabled] = useState(false) const [yoloEnabled, setYoloEnabled] = useState(false)
const [pendingAttachments, setPendingAttachments] = useState<ImageAttachment[]>([]) const [pendingAttachments, setPendingAttachments] = useState<FileAttachment[]>([])
const messagesEndRef = useRef<HTMLDivElement>(null) const messagesEndRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLTextAreaElement>(null) const inputRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
@@ -138,20 +140,33 @@ export function SpecCreationChat({
sendAnswer(answers) sendAnswer(answers)
} }
// File handling for image attachments // File handling for attachments (images and documents)
const handleFileSelect = useCallback((files: FileList | null) => { const handleFileSelect = useCallback((files: FileList | null) => {
if (!files) return if (!files) return
Array.from(files).forEach((file) => { Array.from(files).forEach((file) => {
// Validate file type // Resolve MIME type (browsers may not set it for .md files)
if (!ALLOWED_TYPES.includes(file.type)) { let mimeType = file.type
setError(`Invalid file type: ${file.name}. Only JPEG and PNG are supported.`) if (!mimeType || !ALL_ALLOWED_MIME_TYPES.includes(mimeType)) {
return mimeType = resolveMimeType(file.name)
} }
// Validate file size // Validate file type
if (file.size > MAX_FILE_SIZE) { if (!ALL_ALLOWED_MIME_TYPES.includes(mimeType)) {
setError(`File too large: ${file.name}. Maximum size is 5 MB.`) const ext = file.name.split('.').pop()?.toLowerCase()
if (!ext || !ALLOWED_EXTENSIONS.includes(ext)) {
setError(`Unsupported file type: ${file.name}. Supported: images (JPEG, PNG) and documents (MD, TXT, CSV, DOCX, XLSX, PDF, PPTX).`)
return
}
mimeType = resolveMimeType(file.name)
}
// Validate size based on type
const isImage = (IMAGE_MIME_TYPES as readonly string[]).includes(mimeType)
const maxSize = isImage ? MAX_IMAGE_SIZE : MAX_DOCUMENT_SIZE
const maxLabel = isImage ? '5 MB' : '20 MB'
if (file.size > maxSize) {
setError(`File too large: ${file.name}. Maximum size is ${maxLabel}.`)
return return
} }
@@ -159,15 +174,14 @@ export function SpecCreationChat({
const reader = new FileReader() const reader = new FileReader()
reader.onload = (e) => { reader.onload = (e) => {
const dataUrl = e.target?.result as string const dataUrl = e.target?.result as string
// dataUrl is "data:image/png;base64,XXXXXX"
const base64Data = dataUrl.split(',')[1] const base64Data = dataUrl.split(',')[1]
const attachment: ImageAttachment = { const attachment: FileAttachment = {
id: `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`, id: `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
filename: file.name, filename: file.name,
mimeType: file.type as 'image/jpeg' | 'image/png', mimeType: mimeType as FileAttachment['mimeType'],
base64Data, base64Data,
previewUrl: dataUrl, previewUrl: isImage ? dataUrl : '',
size: file.size, size: file.size,
} }
@@ -364,11 +378,17 @@ export function SpecCreationChat({
key={attachment.id} key={attachment.id}
className="relative group border-2 border-border p-1 bg-card rounded shadow-sm" className="relative group border-2 border-border p-1 bg-card rounded shadow-sm"
> >
{isImageAttachment(attachment) ? (
<img <img
src={attachment.previewUrl} src={attachment.previewUrl}
alt={attachment.filename} alt={attachment.filename}
className="w-16 h-16 object-cover rounded" className="w-16 h-16 object-cover rounded"
/> />
) : (
<div className="w-16 h-16 flex items-center justify-center bg-muted rounded">
<FileText size={24} className="text-muted-foreground" />
</div>
)}
<button <button
onClick={() => handleRemoveAttachment(attachment.id)} onClick={() => handleRemoveAttachment(attachment.id)}
className="absolute -top-2 -right-2 bg-destructive text-destructive-foreground rounded-full p-0.5 border-2 border-border hover:scale-110 transition-transform" className="absolute -top-2 -right-2 bg-destructive text-destructive-foreground rounded-full p-0.5 border-2 border-border hover:scale-110 transition-transform"
@@ -391,7 +411,7 @@ export function SpecCreationChat({
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
accept="image/jpeg,image/png" accept="image/jpeg,image/png,.md,.txt,.csv,.docx,.xlsx,.pdf,.pptx"
multiple multiple
onChange={(e) => handleFileSelect(e.target.files)} onChange={(e) => handleFileSelect(e.target.files)}
className="hidden" className="hidden"
@@ -403,7 +423,7 @@ export function SpecCreationChat({
disabled={connectionStatus !== 'connected'} disabled={connectionStatus !== 'connected'}
variant="ghost" variant="ghost"
size="icon" size="icon"
title="Attach image (JPEG, PNG - max 5MB)" title="Attach files (images: JPEG/PNG up to 5MB, documents: MD, TXT, CSV, DOCX, XLSX, PDF, PPTX up to 20MB)"
> >
<Paperclip size={18} /> <Paperclip size={18} />
</Button> </Button>
@@ -444,7 +464,7 @@ export function SpecCreationChat({
{/* Help text */} {/* Help text */}
<p className="text-xs text-muted-foreground mt-2"> <p className="text-xs text-muted-foreground mt-2">
Press Enter to send, Shift+Enter for new line. Drag & drop or click <Paperclip size={12} className="inline" /> to attach images (JPEG/PNG, max 5MB). Press Enter to send, Shift+Enter for new line. Drag & drop or click <Paperclip size={12} className="inline" /> to attach files.
</p> </p>
</div> </div>
)} )}

View File

@@ -0,0 +1,25 @@
import { useAppContext } from '@/contexts/AppContext'
import { Sidebar } from './Sidebar'
import { ControlBar } from './ControlBar'
import { ContentArea } from './ContentArea'
/**
* Top-level layout component that composes the three structural regions:
* Sidebar | ControlBar + ContentArea
*
* The sidebar sits on the left. The right column stacks the ControlBar
* (shown only when a project is selected) above the scrollable ContentArea.
*/
export function AppShell() {
const { selectedProject } = useAppContext()
return (
<div className="flex h-screen overflow-hidden bg-background">
<Sidebar />
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
{selectedProject && <ControlBar />}
<ContentArea />
</div>
</div>
)
}

View File

@@ -0,0 +1,88 @@
/**
* Content Area - View Router
*
* Renders the active view based on the current `activeView` state from AppContext.
* Also handles pre-conditions: setup wizard, project selection, and spec creation.
*/
import { useAppContext } from '@/contexts/AppContext'
import { SetupWizard } from '../SetupWizard'
import { ProjectSetupRequired } from '../ProjectSetupRequired'
import { DashboardView } from '../views/DashboardView'
import { KanbanView } from '../views/KanbanView'
import { GraphView } from '../views/GraphView'
import { BrowsersView } from '../views/BrowsersView'
import { TerminalView } from '../views/TerminalView'
import { LogsView } from '../views/LogsView'
import { AssistantView } from '../views/AssistantView'
import { SettingsView } from '../views/SettingsView'
export function ContentArea() {
const {
selectedProject,
hasSpec,
setupComplete,
setSetupComplete,
setShowSpecChat,
activeView,
selectedProjectData,
} = useAppContext()
// Step 1: First-run setup wizard
if (!setupComplete) {
return <SetupWizard onComplete={() => setSetupComplete(true)} />
}
// Settings is always accessible regardless of project state
if (activeView === 'settings') {
return <SettingsView />
}
// Step 2: No project selected - show welcome message
if (!selectedProject) {
return (
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<h2 className="font-display text-2xl font-bold mb-2">Welcome to AutoForge</h2>
<p className="text-muted-foreground">Select a project from the sidebar to get started.</p>
</div>
</div>
)
}
// Step 3: Project exists but has no spec - prompt user to create one
if (!hasSpec) {
return (
<div className="flex-1 overflow-y-auto p-6">
<ProjectSetupRequired
projectName={selectedProject}
projectPath={selectedProjectData?.path}
onCreateWithClaude={() => setShowSpecChat(true)}
onEditManually={() => {
/* Could navigate to terminal view */
}}
/>
</div>
)
}
// Step 4: Render the active view
switch (activeView) {
case 'dashboard':
return <DashboardView />
case 'kanban':
return <KanbanView />
case 'graph':
return <GraphView />
case 'browsers':
return <BrowsersView />
case 'terminal':
return <TerminalView />
case 'logs':
return <LogsView />
case 'assistant':
return <AssistantView />
default:
return <DashboardView />
}
}

View File

@@ -0,0 +1,68 @@
import { useAppContext } from '@/contexts/AppContext'
import { AgentControl } from '../AgentControl'
import { DevServerControl } from '../DevServerControl'
import { RotateCcw } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
/**
* Compact horizontal control bar at the top of the content area.
* Houses agent controls, dev server controls, mode badges, and reset.
* Sticky within the scrollable content region.
*/
export function ControlBar() {
const {
selectedProject,
selectedProjectData,
wsState,
settings,
setShowResetModal,
} = useAppContext()
return (
<div className="sticky top-0 z-20 w-full flex items-center gap-3 bg-card/80 backdrop-blur-md border-b border-border px-4 py-2 shrink-0">
<AgentControl
projectName={selectedProject!}
status={wsState.agentStatus}
defaultConcurrency={selectedProjectData?.default_concurrency}
/>
<DevServerControl
projectName={selectedProject!}
status={wsState.devServerStatus}
url={wsState.devServerUrl}
/>
<div className="flex-1" />
{settings?.ollama_mode && (
<div className="hidden sm:flex items-center gap-1.5 px-2 py-1 bg-card rounded-lg border border-border shadow-sm">
<img src="/ollama.png" alt="Ollama" className="w-4 h-4" />
<span className="text-[11px] font-bold text-foreground">Ollama</span>
</div>
)}
{settings?.glm_mode && (
<Badge className="hidden sm:inline-flex bg-purple-500 text-white hover:bg-purple-600 text-[11px]">
GLM
</Badge>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={() => setShowResetModal(true)}
variant="outline"
size="sm"
disabled={['running', 'pausing', 'paused_graceful'].includes(wsState.agentStatus)}
className="h-8"
>
<RotateCcw size={16} />
</Button>
</TooltipTrigger>
<TooltipContent>Reset (R)</TooltipContent>
</Tooltip>
</div>
)
}

View File

@@ -0,0 +1,144 @@
import { useAppContext } from '@/contexts/AppContext'
import { AddFeatureForm } from '../AddFeatureForm'
import { FeatureModal } from '../FeatureModal'
import { ExpandProjectModal } from '../ExpandProjectModal'
import { SpecCreationChat } from '../SpecCreationChat'
import { KeyboardShortcutsHelp } from '../KeyboardShortcutsHelp'
import { ResetProjectModal } from '../ResetProjectModal'
import { AssistantFAB } from '../AssistantFAB'
import { AssistantPanel } from '../AssistantPanel'
import { CelebrationOverlay } from '../CelebrationOverlay'
import { startAgent } from '@/lib/api'
/**
* Renders all modal dialogs, overlays, and floating UI elements.
*
* Extracted from App.tsx so the main shell remains focused on layout while
* this component owns the conditional rendering of every overlay surface.
* All state is read from AppContext -- no props required.
*/
export function Modals() {
const {
selectedProject,
selectedFeature, setSelectedFeature,
showAddFeature, setShowAddFeature,
showExpandProject, setShowExpandProject,
showSpecChat, setShowSpecChat,
showKeyboardHelp, setShowKeyboardHelp,
showResetModal, setShowResetModal,
assistantOpen, setAssistantOpen,
isSpecCreating,
hasSpec,
specInitializerStatus, setSpecInitializerStatus,
specInitializerError, setSpecInitializerError,
wsState,
queryClient,
} = useAppContext()
return (
<>
{/* Add Feature Modal */}
{showAddFeature && selectedProject && (
<AddFeatureForm
projectName={selectedProject}
onClose={() => setShowAddFeature(false)}
/>
)}
{/* Feature Detail Modal */}
{selectedFeature && selectedProject && (
<FeatureModal
feature={selectedFeature}
projectName={selectedProject}
onClose={() => setSelectedFeature(null)}
/>
)}
{/* Expand Project Modal */}
{showExpandProject && selectedProject && hasSpec && (
<ExpandProjectModal
isOpen={showExpandProject}
projectName={selectedProject}
onClose={() => setShowExpandProject(false)}
onFeaturesAdded={() => {
queryClient.invalidateQueries({ queryKey: ['features', selectedProject] })
}}
/>
)}
{/* Spec Creation Chat - full screen overlay */}
{showSpecChat && selectedProject && (
<div className="fixed inset-0 z-50 bg-background">
<SpecCreationChat
projectName={selectedProject}
onComplete={async (_specPath, yoloMode) => {
setSpecInitializerStatus('starting')
try {
await startAgent(selectedProject, {
yoloMode: yoloMode ?? false,
maxConcurrency: 3,
})
setShowSpecChat(false)
setSpecInitializerStatus('idle')
queryClient.invalidateQueries({ queryKey: ['projects'] })
queryClient.invalidateQueries({ queryKey: ['features', selectedProject] })
} catch (err) {
setSpecInitializerStatus('error')
setSpecInitializerError(err instanceof Error ? err.message : 'Failed to start agent')
}
}}
onCancel={() => { setShowSpecChat(false); setSpecInitializerStatus('idle') }}
onExitToProject={() => { setShowSpecChat(false); setSpecInitializerStatus('idle') }}
initializerStatus={specInitializerStatus}
initializerError={specInitializerError}
onRetryInitializer={() => {
setSpecInitializerError(null)
setSpecInitializerStatus('idle')
}}
/>
</div>
)}
{/* Assistant FAB and Panel - hide when expand modal or spec creation is open */}
{selectedProject && !showExpandProject && !isSpecCreating && !showSpecChat && (
<>
<AssistantFAB
onClick={() => setAssistantOpen(!assistantOpen)}
isOpen={assistantOpen}
/>
<AssistantPanel
projectName={selectedProject}
isOpen={assistantOpen}
onClose={() => setAssistantOpen(false)}
/>
</>
)}
{/* Keyboard Shortcuts Help */}
<KeyboardShortcutsHelp isOpen={showKeyboardHelp} onClose={() => setShowKeyboardHelp(false)} />
{/* Reset Project Modal */}
{showResetModal && selectedProject && (
<ResetProjectModal
isOpen={showResetModal}
projectName={selectedProject}
onClose={() => setShowResetModal(false)}
onResetComplete={(wasFullReset) => {
if (wasFullReset) {
setShowSpecChat(true)
}
}}
/>
)}
{/* Celebration Overlay - shows when a feature is completed by an agent */}
{wsState.celebration && (
<CelebrationOverlay
agentName={wsState.celebration.agentName}
featureName={wsState.celebration.featureName}
onComplete={wsState.clearCelebration}
/>
)}
</>
)
}

View File

@@ -0,0 +1,310 @@
import { useAppContext } from '@/contexts/AppContext'
import { SidebarItem } from './SidebarItem'
import { ProjectSelector } from '../ProjectSelector'
import {
LayoutDashboard,
Columns3,
GitBranch,
Monitor,
Terminal,
ScrollText,
Bot,
Settings,
Moon,
Sun,
BookOpen,
PanelLeftClose,
PanelLeftOpen,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
/**
* Collapsible left sidebar for view navigation.
*
* Design approach: precision-engineered utility. Clean separation between
* navigation groups, quiet bottom utility row, smooth width transitions.
* All colours come from theme-aware --sidebar-* CSS variables.
*/
export function Sidebar() {
const {
activeView,
setActiveView,
sidebarCollapsed,
toggleSidebar,
selectedProject,
projects,
projectsLoading,
setSelectedProject,
darkMode,
toggleDarkMode,
wsState,
setIsSpecCreating,
} = useAppContext()
const browserCount = wsState.browserScreenshots.size
const logCount = wsState.logs.length
return (
<aside
className={cn(
'h-screen flex flex-col shrink-0 z-30 overflow-hidden',
'bg-sidebar text-sidebar-foreground',
'border-r border-sidebar-border',
'transition-[width] duration-200 ease-[cubic-bezier(0.4,0,0.2,1)]',
sidebarCollapsed ? 'w-[64px]' : 'w-[240px]',
)}
>
{/* ───────────────────────────────────────────────────────────────────
Header: logo, title, project selector
─────────────────────────────────────────────────────────────────── */}
<div className="flex-shrink-0 px-3 pt-4 pb-3">
{/* Logo row */}
<div
className={cn(
'flex items-center',
sidebarCollapsed ? 'justify-center' : 'gap-2.5',
)}
>
<img
src="/logo.png"
alt="AutoForge"
className={cn(
'rounded-full shrink-0 transition-all duration-200',
sidebarCollapsed ? 'h-8 w-8' : 'h-7 w-7',
)}
/>
<div
className={cn(
'overflow-hidden transition-all duration-200',
sidebarCollapsed ? 'w-0 opacity-0' : 'w-auto opacity-100',
)}
>
<span className="font-bold text-base tracking-tight uppercase whitespace-nowrap block">
AutoForge
</span>
</div>
</div>
{/* Project selector — hidden when collapsed */}
<div
className={cn(
'overflow-hidden transition-all duration-200',
sidebarCollapsed ? 'max-h-0 opacity-0 mt-0' : 'max-h-20 opacity-100 mt-3',
)}
>
<ProjectSelector
projects={projects ?? []}
selectedProject={selectedProject}
onSelectProject={setSelectedProject}
isLoading={projectsLoading}
onSpecCreatingChange={setIsSpecCreating}
/>
</div>
</div>
{/* Subtle divider */}
<div className="mx-3 h-px bg-sidebar-border" />
{/* ───────────────────────────────────────────────────────────────────
Navigation items
─────────────────────────────────────────────────────────────────── */}
<nav className="flex-1 overflow-y-auto py-3 px-2 space-y-0.5">
{selectedProject ? (
<>
{/* Section label (expanded only) */}
{!sidebarCollapsed && (
<p className="px-3 pb-1 text-[10px] font-semibold uppercase tracking-widest text-sidebar-foreground/40">
Views
</p>
)}
<SidebarItem
icon={LayoutDashboard}
label="Dashboard"
isActive={activeView === 'dashboard'}
isCollapsed={sidebarCollapsed}
onClick={() => setActiveView('dashboard')}
shortcutKey="H"
/>
<SidebarItem
icon={Columns3}
label="Kanban"
isActive={activeView === 'kanban'}
isCollapsed={sidebarCollapsed}
onClick={() => setActiveView('kanban')}
shortcutKey="K"
/>
<SidebarItem
icon={GitBranch}
label="Graph"
isActive={activeView === 'graph'}
isCollapsed={sidebarCollapsed}
onClick={() => setActiveView('graph')}
shortcutKey="G"
/>
<SidebarItem
icon={Monitor}
label="Browsers"
isActive={activeView === 'browsers'}
isCollapsed={sidebarCollapsed}
onClick={() => setActiveView('browsers')}
shortcutKey="B"
badge={browserCount > 0 ? browserCount : undefined}
/>
{/* Divider between groups */}
<div className="my-2 mx-2 h-px bg-sidebar-border/60" />
{/* Section label */}
{!sidebarCollapsed && (
<p className="px-3 pb-1 text-[10px] font-semibold uppercase tracking-widest text-sidebar-foreground/40">
Tools
</p>
)}
<SidebarItem
icon={Terminal}
label="Terminal"
isActive={activeView === 'terminal'}
isCollapsed={sidebarCollapsed}
onClick={() => setActiveView('terminal')}
shortcutKey="T"
/>
<SidebarItem
icon={ScrollText}
label="Logs"
isActive={activeView === 'logs'}
isCollapsed={sidebarCollapsed}
onClick={() => setActiveView('logs')}
shortcutKey="D"
badge={logCount > 0 ? logCount : undefined}
/>
<SidebarItem
icon={Bot}
label="Assistant"
isActive={activeView === 'assistant'}
isCollapsed={sidebarCollapsed}
onClick={() => setActiveView('assistant')}
shortcutKey="A"
/>
</>
) : (
/* Prompt when no project selected */
!sidebarCollapsed && (
<div className="px-3 py-8 text-center">
<p className="text-sm text-sidebar-foreground/40 leading-relaxed">
Select a project to get started
</p>
</div>
)
)}
</nav>
{/* ───────────────────────────────────────────────────────────────────
Bottom utility section
─────────────────────────────────────────────────────────────────── */}
<div className="flex-shrink-0 mt-auto px-2 py-2.5 border-t border-sidebar-border/60">
<div className={cn('flex flex-col', sidebarCollapsed ? 'items-center gap-1' : 'gap-0.5')}>
{/* Settings - navigates to settings view */}
<UtilityButton
icon={Settings}
label="Settings"
shortcut=","
collapsed={sidebarCollapsed}
onClick={() => setActiveView('settings')}
/>
{/* Dark mode toggle */}
<UtilityButton
icon={darkMode ? Sun : Moon}
label={darkMode ? 'Light mode' : 'Dark mode'}
collapsed={sidebarCollapsed}
onClick={toggleDarkMode}
/>
{/* Docs link */}
<UtilityButton
icon={BookOpen}
label="Docs"
collapsed={sidebarCollapsed}
onClick={() => window.open('https://autoforge.cc', '_blank')}
/>
{/* Collapse / expand toggle */}
<div className={cn('mt-1 pt-1', !sidebarCollapsed && 'border-t border-sidebar-border/40')}>
<UtilityButton
icon={sidebarCollapsed ? PanelLeftOpen : PanelLeftClose}
label={sidebarCollapsed ? 'Expand sidebar' : 'Collapse'}
shortcut="["
collapsed={sidebarCollapsed}
onClick={toggleSidebar}
/>
</div>
</div>
</div>
</aside>
)
}
/* ─────────────────────────────────────────────────────────────────────────────
Small utility button used in the bottom section.
Separated to keep the main component readable.
───────────────────────────────────────────────────────────────────────────── */
function UtilityButton({
icon: Icon,
label,
shortcut,
collapsed,
onClick,
}: {
icon: React.ComponentType<{ size?: number; className?: string }>
label: string
shortcut?: string
collapsed: boolean
onClick: () => void
}) {
if (collapsed) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={onClick}
aria-label={label}
className="h-8 w-8 text-sidebar-foreground/60 hover:text-sidebar-foreground hover:bg-sidebar-accent transition-colors duration-150"
>
<Icon size={16} />
</Button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{label}
{shortcut && <kbd className="ml-2 text-[10px] font-mono opacity-60">{shortcut}</kbd>}
</TooltipContent>
</Tooltip>
)
}
return (
<button
onClick={onClick}
className={cn(
'group flex items-center gap-3 w-full h-8 px-3 rounded-lg text-sm',
'text-sidebar-foreground/60 hover:text-sidebar-foreground',
'hover:bg-sidebar-accent transition-all duration-150',
'active:scale-[0.98]',
)}
>
<Icon size={16} className="shrink-0" />
<span className="flex-1 text-left truncate text-[13px]">{label}</span>
{shortcut && (
<kbd className="text-[10px] font-mono opacity-0 group-hover:opacity-60 transition-opacity duration-200">
{shortcut}
</kbd>
)}
</button>
)
}

View File

@@ -0,0 +1,131 @@
import { type LucideIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
import { Badge } from '@/components/ui/badge'
interface SidebarItemProps {
icon: LucideIcon
label: string
isActive: boolean
isCollapsed: boolean
onClick: () => void
badge?: number | string
shortcutKey?: string
}
/**
* A single sidebar navigation item that adapts between collapsed (icon-only)
* and expanded (icon + label + optional badge/shortcut) states.
*
* Active state uses a subtle left-edge accent line and primary background.
* Hover state applies a gentle lift and background shift for tactile feedback.
*/
export function SidebarItem({
icon: Icon,
label,
isActive,
isCollapsed,
onClick,
badge,
shortcutKey,
}: SidebarItemProps) {
const button = (
<button
onClick={onClick}
aria-label={label}
className={cn(
// Base layout
'group relative flex items-center rounded-lg w-full',
'transition-all duration-200 ease-[cubic-bezier(0.4,0,0.2,1)]',
'outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring',
// Active state: vivid primary with left accent stripe
isActive && [
'bg-sidebar-primary text-sidebar-primary-foreground',
'shadow-sm',
],
// Inactive: subtle hover lift
!isActive && [
'text-sidebar-foreground/70',
'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
'hover:shadow-sm',
'active:scale-[0.98]',
],
// Sizing
isCollapsed ? 'h-11 w-11 justify-center mx-auto' : 'h-9 px-3 gap-3',
)}
>
{/* Left accent bar for active state (expanded only) */}
{isActive && !isCollapsed && (
<span
className="absolute left-0 top-1.5 bottom-1.5 w-[3px] rounded-r-full bg-sidebar-primary-foreground/40"
/>
)}
{/* Icon with subtle scale on active */}
<Icon
size={isCollapsed ? 20 : 18}
className={cn(
'shrink-0 transition-transform duration-200',
isActive && 'scale-110',
!isActive && 'group-hover:translate-x-0.5',
)}
/>
{/* Label and accessories — expanded mode only */}
{!isCollapsed && (
<>
<span className="truncate text-sm font-medium flex-1 text-left">
{label}
</span>
{badge !== undefined && (
<Badge
variant="secondary"
className={cn(
'text-[10px] px-1.5 py-0 h-5 min-w-5 tabular-nums',
'transition-opacity duration-200',
isActive && 'bg-sidebar-primary-foreground/20 text-sidebar-primary-foreground',
)}
>
{badge}
</Badge>
)}
{shortcutKey && badge === undefined && (
<kbd
className={cn(
'text-[10px] font-mono leading-none px-1 py-0.5 rounded',
'opacity-0 group-hover:opacity-100 transition-opacity duration-200',
isActive
? 'text-sidebar-primary-foreground/50'
: 'text-muted-foreground/60 bg-sidebar-accent/50',
)}
>
{shortcutKey}
</kbd>
)}
</>
)}
</button>
)
// In collapsed mode, wrap with a tooltip so the label is discoverable
if (isCollapsed) {
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent side="right" sideOffset={8} className="font-medium">
{label}
{shortcutKey && (
<kbd className="ml-2 text-[10px] font-mono opacity-60">{shortcutKey}</kbd>
)}
</TooltipContent>
</Tooltip>
)
}
return button
}

View File

@@ -0,0 +1,44 @@
import * as React from "react"
import { cn } from "@/lib/utils"
interface SliderProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> {
min: number
max: number
value: number
onChange: (value: number) => void
label?: string
}
function Slider({
className,
min,
max,
value,
onChange,
disabled,
...props
}: SliderProps) {
return (
<div className={cn("flex items-center gap-3", className)}>
<input
type="range"
min={min}
max={max}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
disabled={disabled}
className={cn(
"slider-input h-2 w-full cursor-pointer appearance-none rounded-full bg-input transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
disabled && "cursor-not-allowed opacity-50"
)}
{...props}
/>
<span className="min-w-[2ch] text-center text-sm font-semibold tabular-nums">
{value}
</span>
</div>
)
}
export { Slider }

View File

@@ -0,0 +1,134 @@
/**
* Assistant View
*
* Full-page project assistant chat view with conversation management.
* Reuses the same conversation persistence and lifecycle logic as
* AssistantPanel, but renders inline rather than as a slide-in overlay.
*/
import { useState, useEffect, useCallback } from 'react'
import { useAppContext } from '@/contexts/AppContext'
import { AssistantChat } from '../AssistantChat'
import { useConversation } from '@/hooks/useConversations'
import { Bot } from 'lucide-react'
import type { ChatMessage } from '@/lib/types'
const STORAGE_KEY_PREFIX = 'assistant-conversation-'
function getStoredConversationId(projectName: string): number | null {
try {
const stored = localStorage.getItem(`${STORAGE_KEY_PREFIX}${projectName}`)
if (stored) {
const data = JSON.parse(stored)
return data.conversationId || null
}
} catch {
// Invalid stored data, ignore
}
return null
}
function setStoredConversationId(projectName: string, conversationId: number | null) {
const key = `${STORAGE_KEY_PREFIX}${projectName}`
if (conversationId) {
localStorage.setItem(key, JSON.stringify({ conversationId }))
} else {
localStorage.removeItem(key)
}
}
export function AssistantView() {
const { selectedProject } = useAppContext()
const projectName = selectedProject ?? ''
// Load the last-used conversation ID from localStorage
const [conversationId, setConversationId] = useState<number | null>(() =>
getStoredConversationId(projectName),
)
// Fetch conversation details when we have a valid ID
const {
data: conversationDetail,
isLoading: isLoadingConversation,
error: conversationError,
} = useConversation(projectName || null, conversationId)
// Clear stored conversation ID on 404 (conversation was deleted or never existed)
useEffect(() => {
if (conversationError && conversationId) {
const message = conversationError.message.toLowerCase()
if (message.includes('not found') || message.includes('404')) {
console.warn(`Conversation ${conversationId} not found, clearing stored ID`)
setConversationId(null)
}
}
}, [conversationError, conversationId])
// Convert API message format to the ChatMessage format expected by AssistantChat
const initialMessages: ChatMessage[] | undefined = conversationDetail?.messages.map(msg => ({
id: `db-${msg.id}`,
role: msg.role,
content: msg.content,
timestamp: msg.timestamp ? new Date(msg.timestamp) : new Date(),
}))
// Persist conversation ID changes to localStorage
useEffect(() => {
if (projectName) {
setStoredConversationId(projectName, conversationId)
}
}, [projectName, conversationId])
// Reset conversation ID when the project changes
useEffect(() => {
setConversationId(getStoredConversationId(projectName))
}, [projectName])
// Start a brand-new chat
const handleNewChat = useCallback(() => {
setConversationId(null)
}, [])
// Select a conversation from the history list
const handleSelectConversation = useCallback((id: number) => {
setConversationId(id)
}, [])
// WebSocket notifies us that a new conversation was created
const handleConversationCreated = useCallback((id: number) => {
setConversationId(id)
}, [])
return (
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
{/* Header */}
<div className="flex items-center gap-2 px-4 py-3 border-b border-border bg-primary text-primary-foreground">
<div className="bg-card text-foreground border border-border p-1.5 rounded">
<Bot size={18} />
</div>
<div>
<h2 className="font-semibold">Project Assistant</h2>
{projectName && (
<p className="text-xs opacity-80 font-mono">{projectName}</p>
)}
</div>
</div>
{/* Chat area */}
<div className="flex-1 overflow-hidden">
{projectName && (
<AssistantChat
projectName={projectName}
conversationId={conversationId}
initialMessages={initialMessages}
isLoadingConversation={isLoadingConversation}
onNewChat={handleNewChat}
onSelectConversation={handleSelectConversation}
onConversationCreated={handleConversationCreated}
/>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,23 @@
/**
* Browsers View
*
* Full-page live browser screenshots from each agent's browser session.
* BrowserViewPanel handles subscribe/unsubscribe internally via useEffect.
*/
import { useAppContext } from '@/contexts/AppContext'
import { BrowserViewPanel } from '../BrowserViewPanel'
export function BrowsersView() {
const { wsState } = useAppContext()
return (
<div className="flex-1 overflow-hidden">
<BrowserViewPanel
screenshots={wsState.browserScreenshots}
onSubscribe={wsState.subscribeBrowserView}
onUnsubscribe={wsState.unsubscribeBrowserView}
/>
</div>
)
}

View File

@@ -0,0 +1,68 @@
/**
* Dashboard View
*
* The command center: shows project progress and agent mission control.
* The kanban board is a separate view accessible from the sidebar.
*/
import { useAppContext } from '@/contexts/AppContext'
import { ProgressDashboard } from '../ProgressDashboard'
import { AgentMissionControl } from '../AgentMissionControl'
import { Loader2 } from 'lucide-react'
import { Card, CardContent } from '@/components/ui/card'
export function DashboardView() {
const {
progress,
wsState,
features,
} = useAppContext()
// Determine whether the agent is initializing features: the feature lists
// are all empty, yet the agent is running (reading the spec and creating them).
const isInitializingFeatures =
features &&
features.pending.length === 0 &&
features.in_progress.length === 0 &&
features.done.length === 0 &&
(features.needs_human_input?.length || 0) === 0 &&
wsState.agentStatus === 'running'
return (
<div className="overflow-y-auto flex-1 p-6 space-y-6">
{/* Progress overview */}
<ProgressDashboard
passing={progress.passing}
total={progress.total}
percentage={progress.percentage}
isConnected={wsState.isConnected}
logs={wsState.activeAgents.length === 0 ? wsState.logs : undefined}
agentStatus={wsState.activeAgents.length === 0 ? wsState.agentStatus : undefined}
/>
{/* Agent Mission Control - orchestrator status and active agents */}
<AgentMissionControl
agents={wsState.activeAgents}
orchestratorStatus={wsState.orchestratorStatus}
recentActivity={wsState.recentActivity}
getAgentLogs={wsState.getAgentLogs}
browserScreenshots={wsState.browserScreenshots}
/>
{/* Initializing Features - shown when agent is running but no features exist yet */}
{isInitializingFeatures && (
<Card className="p-8 text-center">
<CardContent className="p-0">
<Loader2 size={32} className="animate-spin mx-auto mb-4 text-primary" />
<h3 className="font-display font-bold text-xl mb-2">
Initializing Features...
</h3>
<p className="text-muted-foreground">
The agent is reading your spec and creating features. This may take a moment.
</p>
</CardContent>
</Card>
)}
</div>
)
}

View File

@@ -0,0 +1,33 @@
/**
* Graph View
*
* Full-page dependency graph visualization.
* Shows feature nodes and their dependency edges using dagre layout.
* Falls back to a loading spinner when graph data is not yet available.
*/
import { useAppContext } from '@/contexts/AppContext'
import { DependencyGraph } from '../DependencyGraph'
import { Loader2 } from 'lucide-react'
export function GraphView() {
const { graphData, handleGraphNodeClick, wsState } = useAppContext()
return (
<div className="flex-1 flex flex-col min-h-0 overflow-hidden p-6">
{graphData ? (
<div className="flex-1 min-h-0">
<DependencyGraph
graphData={graphData}
onNodeClick={handleGraphNodeClick}
activeAgents={wsState.activeAgents}
/>
</div>
) : (
<div className="flex-1 flex items-center justify-center">
<Loader2 size={32} className="animate-spin text-primary" />
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,35 @@
/**
* Kanban View
*
* Full-page kanban board for managing features across columns
* (pending, in progress, done, needs human input).
*/
import { useAppContext } from '@/contexts/AppContext'
import { KanbanBoard } from '../KanbanBoard'
export function KanbanView() {
const {
features,
hasSpec,
wsState,
setSelectedFeature,
setShowAddFeature,
setShowExpandProject,
setShowSpecChat,
} = useAppContext()
return (
<div className="overflow-y-auto flex-1 p-6">
<KanbanBoard
features={features}
onFeatureClick={setSelectedFeature}
onAddFeature={() => setShowAddFeature(true)}
onExpandProject={() => setShowExpandProject(true)}
activeAgents={wsState.activeAgents}
onCreateSpec={() => setShowSpecChat(true)}
hasSpec={hasSpec}
/>
</div>
)
}

View File

@@ -0,0 +1,262 @@
/**
* Logs View
*
* Full-page log viewer with sub-tabs for Agent and Dev Server logs.
* Extracted from the log rendering logic previously in DebugLogViewer.
* Supports auto-scroll, log-level colorization, and timestamp formatting.
*/
import { useState, useEffect, useRef, useCallback } from 'react'
import { useAppContext } from '@/contexts/AppContext'
import { Trash2, Cpu, Server } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
type LogTab = 'agent' | 'devserver'
type LogLevel = 'error' | 'warn' | 'debug' | 'info'
const TAB_STORAGE_KEY = 'autoforge-logs-tab'
/** Parse log level from line content. */
function getLogLevel(line: string): LogLevel {
const lower = line.toLowerCase()
if (lower.includes('error') || lower.includes('exception') || lower.includes('traceback')) {
return 'error'
}
if (lower.includes('warn') || lower.includes('warning')) {
return 'warn'
}
if (lower.includes('debug')) {
return 'debug'
}
return 'info'
}
/** Map log level to a Tailwind text-color class. */
function getLogColor(level: LogLevel): string {
switch (level) {
case 'error':
return 'text-red-500'
case 'warn':
return 'text-yellow-500'
case 'debug':
return 'text-blue-400'
case 'info':
default:
return 'text-foreground'
}
}
/** Format an ISO timestamp to HH:MM:SS for compact log display. */
function formatTimestamp(timestamp: string): string {
try {
const date = new Date(timestamp)
return date.toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
} catch {
return ''
}
}
export function LogsView() {
const { wsState } = useAppContext()
// Sub-tab state, persisted to localStorage
const [activeLogTab, setActiveLogTab] = useState<LogTab>(() => {
try {
const stored = localStorage.getItem(TAB_STORAGE_KEY)
return stored === 'devserver' ? 'devserver' : 'agent'
} catch {
return 'agent'
}
})
// Auto-scroll tracking per tab
const [autoScroll, setAutoScroll] = useState(true)
const [devAutoScroll, setDevAutoScroll] = useState(true)
const scrollRef = useRef<HTMLDivElement>(null)
const devScrollRef = useRef<HTMLDivElement>(null)
// Persist the active tab to localStorage
const handleTabChange = useCallback((tab: LogTab) => {
setActiveLogTab(tab)
try {
localStorage.setItem(TAB_STORAGE_KEY, tab)
} catch {
// localStorage not available
}
}, [])
// Auto-scroll agent logs when new entries arrive
useEffect(() => {
if (autoScroll && scrollRef.current && activeLogTab === 'agent') {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
}
}, [wsState.logs, autoScroll, activeLogTab])
// Auto-scroll dev server logs when new entries arrive
useEffect(() => {
if (devAutoScroll && devScrollRef.current && activeLogTab === 'devserver') {
devScrollRef.current.scrollTop = devScrollRef.current.scrollHeight
}
}, [wsState.devLogs, devAutoScroll, activeLogTab])
// Detect whether the user has scrolled away from the bottom (agent tab)
const handleAgentScroll = (e: React.UIEvent<HTMLDivElement>) => {
const el = e.currentTarget
const isAtBottom = el.scrollHeight - el.scrollTop <= el.clientHeight + 50
setAutoScroll(isAtBottom)
}
// Detect whether the user has scrolled away from the bottom (devserver tab)
const handleDevScroll = (e: React.UIEvent<HTMLDivElement>) => {
const el = e.currentTarget
const isAtBottom = el.scrollHeight - el.scrollTop <= el.clientHeight + 50
setDevAutoScroll(isAtBottom)
}
// Clear handler dispatches to the correct log source
const handleClear = () => {
if (activeLogTab === 'agent') {
wsState.clearLogs()
} else {
wsState.clearDevLogs()
}
}
// Determine if auto-scroll is paused for the active tab
const isScrollPaused = activeLogTab === 'agent' ? !autoScroll : !devAutoScroll
return (
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
{/* Tab header bar */}
<div className="flex items-center gap-2 px-4 py-2 border-b border-border bg-card/50">
<Button
variant={activeLogTab === 'agent' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => handleTabChange('agent')}
className="h-7 text-xs font-mono gap-1.5"
>
<Cpu size={12} />
Agent
{wsState.logs.length > 0 && (
<Badge variant="default" className="h-4 px-1.5 text-[10px]">
{wsState.logs.length}
</Badge>
)}
</Button>
<Button
variant={activeLogTab === 'devserver' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => handleTabChange('devserver')}
className="h-7 text-xs font-mono gap-1.5"
>
<Server size={12} />
Dev Server
{wsState.devLogs.length > 0 && (
<Badge variant="default" className="h-4 px-1.5 text-[10px]">
{wsState.devLogs.length}
</Badge>
)}
</Button>
{/* Spacer */}
<div className="flex-1" />
{/* Auto-scroll paused indicator */}
{isScrollPaused && (
<Badge variant="default" className="bg-yellow-500 text-yellow-950">
Paused
</Badge>
)}
{/* Clear logs button */}
<Button
variant="ghost"
size="icon"
onClick={handleClear}
className="h-7 w-7"
title="Clear logs"
>
<Trash2 size={14} className="text-muted-foreground" />
</Button>
</div>
{/* Log content area */}
{activeLogTab === 'agent' ? (
<div
ref={scrollRef}
onScroll={handleAgentScroll}
className="flex-1 overflow-y-auto p-3 font-mono text-sm"
>
{wsState.logs.length === 0 ? (
<div className="flex items-center justify-center h-full text-muted-foreground">
No logs yet. Start the agent to see output.
</div>
) : (
<div className="space-y-0.5">
{wsState.logs.map((log, index) => {
const level = getLogLevel(log.line)
const colorClass = getLogColor(level)
const timestamp = formatTimestamp(log.timestamp)
return (
<div
key={`${log.timestamp}-${index}`}
className="flex gap-2 hover:bg-muted px-1 py-0.5 rounded"
>
<span className="text-muted-foreground select-none shrink-0">
{timestamp}
</span>
<span className={`${colorClass} whitespace-pre-wrap break-all`}>
{log.line}
</span>
</div>
)
})}
</div>
)}
</div>
) : (
<div
ref={devScrollRef}
onScroll={handleDevScroll}
className="flex-1 overflow-y-auto p-3 font-mono text-sm"
>
{wsState.devLogs.length === 0 ? (
<div className="flex items-center justify-center h-full text-muted-foreground">
No dev server logs yet.
</div>
) : (
<div className="space-y-0.5">
{wsState.devLogs.map((log, index) => {
const level = getLogLevel(log.line)
const colorClass = getLogColor(level)
const timestamp = formatTimestamp(log.timestamp)
return (
<div
key={`${log.timestamp}-${index}`}
className="flex gap-2 hover:bg-muted px-1 py-0.5 rounded"
>
<span className="text-muted-foreground select-none shrink-0">
{timestamp}
</span>
<span className={`${colorClass} whitespace-pre-wrap break-all`}>
{log.line}
</span>
</div>
)
})}
</div>
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,493 @@
/**
* Settings View
*
* Full-page settings view with the same controls as SettingsModal,
* rendered in a scrollable centered layout with Card-based section
* groupings instead of inside a Dialog.
*/
import { useState } from 'react'
import { Loader2, AlertCircle, AlertTriangle, Check, Moon, Sun, Eye, EyeOff, ShieldCheck, Settings } from 'lucide-react'
import { useSettings, useUpdateSettings, useAvailableModels, useAvailableProviders } from '@/hooks/useProjects'
import { useTheme, THEMES } from '@/hooks/useTheme'
import type { ProviderInfo } from '@/lib/types'
import { Switch } from '@/components/ui/switch'
import { Slider } from '@/components/ui/slider'
import { Label } from '@/components/ui/label'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
const PROVIDER_INFO_TEXT: Record<string, string> = {
claude: 'Default provider. Uses Claude CLI credentials. API key auth is recommended.',
kimi: 'Get an API key at kimi.com',
glm: 'Get an API key at open.bigmodel.cn',
ollama: 'Run models locally. Install from ollama.com',
custom: 'Connect to any OpenAI-compatible API endpoint.',
}
export function SettingsView() {
const { data: settings, isLoading, isError, refetch } = useSettings()
const { data: modelsData } = useAvailableModels()
const { data: providersData } = useAvailableProviders()
const updateSettings = useUpdateSettings()
const { theme, setTheme, darkMode, toggleDarkMode } = useTheme()
const [showAuthToken, setShowAuthToken] = useState(false)
const [authTokenInput, setAuthTokenInput] = useState('')
const [customModelInput, setCustomModelInput] = useState('')
const [customBaseUrlInput, setCustomBaseUrlInput] = useState('')
const handleYoloToggle = () => {
if (settings && !updateSettings.isPending) {
updateSettings.mutate({ yolo_mode: !settings.yolo_mode })
}
}
const handleModelChange = (modelId: string) => {
if (!updateSettings.isPending) {
updateSettings.mutate({ api_model: modelId })
}
}
const handleTestingRatioChange = (ratio: number) => {
if (!updateSettings.isPending) {
updateSettings.mutate({ testing_agent_ratio: ratio })
}
}
const handleBatchSizeChange = (size: number) => {
if (!updateSettings.isPending) {
updateSettings.mutate({ batch_size: size })
}
}
const handleTestingBatchSizeChange = (size: number) => {
if (!updateSettings.isPending) {
updateSettings.mutate({ testing_batch_size: size })
}
}
const handleProviderChange = (providerId: string) => {
if (!updateSettings.isPending) {
updateSettings.mutate({ api_provider: providerId })
setAuthTokenInput('')
setShowAuthToken(false)
setCustomModelInput('')
setCustomBaseUrlInput('')
}
}
const handleSaveAuthToken = () => {
if (authTokenInput.trim() && !updateSettings.isPending) {
updateSettings.mutate({ api_auth_token: authTokenInput.trim() })
setAuthTokenInput('')
setShowAuthToken(false)
}
}
const handleSaveCustomBaseUrl = () => {
if (customBaseUrlInput.trim() && !updateSettings.isPending) {
updateSettings.mutate({ api_base_url: customBaseUrlInput.trim() })
setCustomBaseUrlInput('')
}
}
const handleSaveCustomModel = () => {
if (customModelInput.trim() && !updateSettings.isPending) {
updateSettings.mutate({ api_model: customModelInput.trim() })
setCustomModelInput('')
}
}
const providers = providersData?.providers ?? []
const models = modelsData?.models ?? []
const isSaving = updateSettings.isPending
const currentProvider = settings?.api_provider ?? 'claude'
const currentProviderInfo: ProviderInfo | undefined = providers.find(p => p.id === currentProvider)
const isAlternativeProvider = currentProvider !== 'claude'
const showAuthField = isAlternativeProvider && currentProviderInfo?.requires_auth
const showBaseUrlField = currentProvider === 'custom' || currentProvider === 'azure'
const showCustomModelInput = currentProvider === 'custom' || currentProvider === 'ollama'
return (
<div className="flex-1 overflow-y-auto">
<div className="max-w-2xl mx-auto p-6 space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<Settings size={24} className="text-primary" />
<h1 className="text-2xl font-bold">Settings</h1>
{isSaving && <Loader2 className="animate-spin" size={16} />}
</div>
{/* Loading State */}
{isLoading && (
<div className="flex items-center justify-center py-8">
<Loader2 className="animate-spin" size={24} />
<span className="ml-2">Loading settings...</span>
</div>
)}
{/* Error State */}
{isError && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Failed to load settings
<Button
variant="link"
onClick={() => refetch()}
className="ml-2 p-0 h-auto"
>
Retry
</Button>
</AlertDescription>
</Alert>
)}
{/* Settings Content */}
{settings && !isLoading && (
<>
{/* Appearance Card */}
<Card>
<CardHeader>
<CardTitle>Appearance</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* Theme Selection */}
<div className="space-y-3">
<Label className="font-medium">Theme</Label>
<div className="grid gap-2">
{THEMES.map((themeOption) => (
<button
key={themeOption.id}
onClick={() => setTheme(themeOption.id)}
className={`flex items-center gap-3 p-3 rounded-lg border-2 transition-colors text-left ${
theme === themeOption.id
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/50 hover:bg-muted/50'
}`}
>
{/* Color swatches */}
<div className="flex gap-0.5 shrink-0">
<div
className="w-5 h-5 rounded-sm border border-border/50"
style={{ backgroundColor: themeOption.previewColors.background }}
/>
<div
className="w-5 h-5 rounded-sm border border-border/50"
style={{ backgroundColor: themeOption.previewColors.primary }}
/>
<div
className="w-5 h-5 rounded-sm border border-border/50"
style={{ backgroundColor: themeOption.previewColors.accent }}
/>
</div>
{/* Theme info */}
<div className="flex-1 min-w-0">
<div className="font-medium text-sm">{themeOption.name}</div>
<div className="text-xs text-muted-foreground">
{themeOption.description}
</div>
</div>
{/* Checkmark */}
{theme === themeOption.id && (
<Check size={18} className="text-primary shrink-0" />
)}
</button>
))}
</div>
</div>
{/* Dark Mode Toggle */}
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="dark-mode" className="font-medium">
Dark Mode
</Label>
<p className="text-sm text-muted-foreground">
Switch between light and dark appearance
</p>
</div>
<Button
id="dark-mode"
variant="outline"
size="sm"
onClick={toggleDarkMode}
className="gap-2"
>
{darkMode ? <Sun size={16} /> : <Moon size={16} />}
{darkMode ? 'Light' : 'Dark'}
</Button>
</div>
</CardContent>
</Card>
{/* API Configuration Card */}
<Card>
<CardHeader>
<CardTitle>API Configuration</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* API Provider Selection */}
<div className="space-y-3">
<Label className="font-medium">API Provider</Label>
<div className="flex flex-wrap gap-1.5">
{providers.map((provider) => (
<button
key={provider.id}
onClick={() => handleProviderChange(provider.id)}
disabled={isSaving}
className={`py-1.5 px-3 text-sm font-medium rounded-md border transition-colors ${
currentProvider === provider.id
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background text-foreground border-border hover:bg-muted'
} ${isSaving ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{provider.name.split(' (')[0]}
</button>
))}
</div>
<p className="text-xs text-muted-foreground">
{PROVIDER_INFO_TEXT[currentProvider] ?? ''}
</p>
{currentProvider === 'claude' && (
<Alert className="border-amber-500/50 bg-amber-50 dark:bg-amber-950/20 mt-2">
<AlertTriangle className="h-4 w-4 text-amber-600" />
<AlertDescription className="text-xs text-amber-700 dark:text-amber-300">
Anthropic's policy may not permit using subscription-based auth (<code className="text-xs">claude login</code>) with third-party agents. Consider using an API key provider or setting the <code className="text-xs">ANTHROPIC_API_KEY</code> environment variable to avoid potential account issues.
</AlertDescription>
</Alert>
)}
{/* Auth Token Field */}
{showAuthField && (
<div className="space-y-2 pt-1">
<Label className="text-sm">API Key</Label>
{settings.api_has_auth_token && !authTokenInput && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<ShieldCheck size={14} className="text-green-500" />
<span>Configured</span>
<Button
variant="ghost"
size="sm"
className="h-auto py-0.5 px-2 text-xs"
onClick={() => setAuthTokenInput(' ')}
>
Change
</Button>
</div>
)}
{(!settings.api_has_auth_token || authTokenInput) && (
<div className="flex gap-2">
<div className="relative flex-1">
<input
type={showAuthToken ? 'text' : 'password'}
value={authTokenInput.trim()}
onChange={(e) => setAuthTokenInput(e.target.value)}
placeholder="Enter API key..."
className="w-full py-1.5 px-3 pe-9 text-sm border rounded-md bg-background"
/>
<button
type="button"
onClick={() => setShowAuthToken(!showAuthToken)}
className="absolute end-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showAuthToken ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
<Button
size="sm"
onClick={handleSaveAuthToken}
disabled={!authTokenInput.trim() || isSaving}
>
Save
</Button>
</div>
)}
</div>
)}
{/* Custom Base URL Field */}
{showBaseUrlField && (
<div className="space-y-2 pt-1">
<Label className="text-sm">Base URL</Label>
{settings.api_base_url && !customBaseUrlInput && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<ShieldCheck size={14} className="text-green-500" />
<span className="truncate">{settings.api_base_url}</span>
<Button
variant="ghost"
size="sm"
className="h-auto py-0.5 px-2 text-xs shrink-0"
onClick={() => setCustomBaseUrlInput(settings.api_base_url || '')}
>
Change
</Button>
</div>
)}
{(!settings.api_base_url || customBaseUrlInput) && (
<div className="flex gap-2">
<input
type="text"
value={customBaseUrlInput}
onChange={(e) => setCustomBaseUrlInput(e.target.value)}
placeholder={currentProvider === 'azure' ? 'https://your-resource.services.ai.azure.com/anthropic' : 'https://api.example.com/v1'}
className="flex-1 py-1.5 px-3 text-sm border rounded-md bg-background"
/>
<Button
size="sm"
onClick={handleSaveCustomBaseUrl}
disabled={!customBaseUrlInput.trim() || isSaving}
>
Save
</Button>
</div>
)}
</div>
)}
</div>
{/* Model Selection */}
<div className="space-y-2">
<Label className="font-medium">Model</Label>
{models.length > 0 && (
<div className="flex rounded-lg border overflow-hidden">
{models.map((model) => (
<button
key={model.id}
onClick={() => handleModelChange(model.id)}
disabled={isSaving}
className={`flex-1 py-2 px-3 text-sm font-medium transition-colors ${
(settings.api_model ?? settings.model) === model.id
? 'bg-primary text-primary-foreground'
: 'bg-background text-foreground hover:bg-muted'
} ${isSaving ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<span className="block">{model.name}</span>
<span className="block text-xs opacity-60">{model.id}</span>
</button>
))}
</div>
)}
{/* Custom model input for Ollama/Custom */}
{showCustomModelInput && (
<div className="flex gap-2 pt-1">
<input
type="text"
value={customModelInput}
onChange={(e) => setCustomModelInput(e.target.value)}
placeholder="Custom model name..."
className="flex-1 py-1.5 px-3 text-sm border rounded-md bg-background"
onKeyDown={(e) => e.key === 'Enter' && handleSaveCustomModel()}
/>
<Button
size="sm"
onClick={handleSaveCustomModel}
disabled={!customModelInput.trim() || isSaving}
>
Set
</Button>
</div>
)}
</div>
</CardContent>
</Card>
{/* Agent Configuration Card */}
<Card>
<CardHeader>
<CardTitle>Agent Configuration</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* YOLO Mode Toggle */}
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="yolo-mode" className="font-medium">
YOLO Mode
</Label>
<p className="text-sm text-muted-foreground">
Skip testing for rapid prototyping
</p>
</div>
<Switch
id="yolo-mode"
checked={settings.yolo_mode}
onCheckedChange={handleYoloToggle}
disabled={isSaving}
/>
</div>
{/* Regression Agents */}
<div className="space-y-2">
<Label className="font-medium">Regression Agents</Label>
<p className="text-sm text-muted-foreground">
Number of regression testing agents (0 = disabled)
</p>
<div className="flex rounded-lg border overflow-hidden">
{[0, 1, 2, 3].map((ratio) => (
<button
key={ratio}
onClick={() => handleTestingRatioChange(ratio)}
disabled={isSaving}
className={`flex-1 py-2 px-3 text-sm font-medium transition-colors ${
settings.testing_agent_ratio === ratio
? 'bg-primary text-primary-foreground'
: 'bg-background text-foreground hover:bg-muted'
} ${isSaving ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{ratio}
</button>
))}
</div>
</div>
{/* Features per Coding Agent */}
<div className="space-y-2">
<Label className="font-medium">Features per Coding Agent</Label>
<p className="text-sm text-muted-foreground">
Number of features assigned to each coding agent session
</p>
<Slider
min={1}
max={15}
value={settings.batch_size ?? 3}
onChange={handleBatchSizeChange}
disabled={isSaving}
/>
</div>
{/* Features per Testing Agent */}
<div className="space-y-2">
<Label className="font-medium">Features per Testing Agent</Label>
<p className="text-sm text-muted-foreground">
Number of features assigned to each testing agent session
</p>
<Slider
min={1}
max={15}
value={settings.testing_batch_size ?? 3}
onChange={handleTestingBatchSizeChange}
disabled={isSaving}
/>
</div>
</CardContent>
</Card>
{/* Update Error */}
{updateSettings.isError && (
<Alert variant="destructive">
<AlertDescription>
Failed to save settings. Please try again.
</AlertDescription>
</Alert>
)}
</>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,171 @@
/**
* Terminal View
*
* Full-page terminal with tab management. Owns the terminal lifecycle
* state (create, rename, close) that was previously embedded in DebugLogViewer.
* Terminal buffers are preserved across tab switches by rendering all terminals
* stacked and using CSS transforms to show/hide the active one.
*/
import { useState, useCallback, useEffect } from 'react'
import { useAppContext } from '@/contexts/AppContext'
import { Terminal } from '../Terminal'
import { TerminalTabs } from '../TerminalTabs'
import { listTerminals, createTerminal, renameTerminal, deleteTerminal } from '@/lib/api'
import type { TerminalInfo } from '@/lib/types'
export function TerminalView() {
const { selectedProject } = useAppContext()
const projectName = selectedProject ?? ''
// Terminal management state
const [terminals, setTerminals] = useState<TerminalInfo[]>([])
const [activeTerminalId, setActiveTerminalId] = useState<string | null>(null)
const [isLoadingTerminals, setIsLoadingTerminals] = useState(false)
// Fetch all terminals for the current project
const fetchTerminals = useCallback(async () => {
if (!projectName) return
setIsLoadingTerminals(true)
try {
const terminalList = await listTerminals(projectName)
setTerminals(terminalList)
// Default to the first terminal if the active one is gone
if (terminalList.length > 0) {
setActiveTerminalId(prev => {
if (!prev || !terminalList.find(t => t.id === prev)) {
return terminalList[0].id
}
return prev
})
}
} catch (err) {
console.error('Failed to fetch terminals:', err)
} finally {
setIsLoadingTerminals(false)
}
}, [projectName])
// Create a new terminal session
const handleCreateTerminal = useCallback(async () => {
if (!projectName) return
try {
const newTerminal = await createTerminal(projectName)
setTerminals(prev => [...prev, newTerminal])
setActiveTerminalId(newTerminal.id)
} catch (err) {
console.error('Failed to create terminal:', err)
}
}, [projectName])
// Rename an existing terminal
const handleRenameTerminal = useCallback(
async (terminalId: string, newName: string) => {
if (!projectName) return
try {
const updated = await renameTerminal(projectName, terminalId, newName)
setTerminals(prev =>
prev.map(t => (t.id === terminalId ? updated : t)),
)
} catch (err) {
console.error('Failed to rename terminal:', err)
}
},
[projectName],
)
// Close a terminal (minimum one must remain)
const handleCloseTerminal = useCallback(
async (terminalId: string) => {
if (!projectName || terminals.length <= 1) return
try {
await deleteTerminal(projectName, terminalId)
setTerminals(prev => prev.filter(t => t.id !== terminalId))
// If the closed terminal was active, switch to the first remaining one
if (activeTerminalId === terminalId) {
const remaining = terminals.filter(t => t.id !== terminalId)
if (remaining.length > 0) {
setActiveTerminalId(remaining[0].id)
}
}
} catch (err) {
console.error('Failed to close terminal:', err)
}
},
[projectName, terminals, activeTerminalId],
)
// Re-fetch terminals whenever the project changes
useEffect(() => {
if (projectName) {
fetchTerminals()
} else {
setTerminals([])
setActiveTerminalId(null)
}
}, [projectName]) // eslint-disable-line react-hooks/exhaustive-deps
return (
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
{/* Tab bar */}
{terminals.length > 0 && (
<TerminalTabs
terminals={terminals}
activeTerminalId={activeTerminalId}
onSelect={setActiveTerminalId}
onCreate={handleCreateTerminal}
onRename={handleRenameTerminal}
onClose={handleCloseTerminal}
/>
)}
{/* Terminal content area */}
<div className="flex-1 min-h-0 relative">
{isLoadingTerminals ? (
<div className="h-full flex items-center justify-center text-muted-foreground font-mono text-sm">
Loading terminals...
</div>
) : terminals.length === 0 ? (
<div className="h-full flex items-center justify-center text-muted-foreground font-mono text-sm">
No terminal available
</div>
) : (
/*
* Render all terminals stacked on top of each other.
* The active terminal is visible and receives input.
* Inactive terminals are moved off-screen with `transform` so
* xterm.js IntersectionObserver pauses rendering while preserving
* the terminal buffer contents.
*/
terminals.map(terminal => {
const isActive = terminal.id === activeTerminalId
return (
<div
key={terminal.id}
className="absolute inset-0"
style={{
zIndex: isActive ? 10 : 1,
transform: isActive ? 'none' : 'translateX(-200%)',
pointerEvents: isActive ? 'auto' : 'none',
}}
>
<Terminal
projectName={projectName}
terminalId={terminal.id}
isActive={isActive}
/>
</div>
)
})
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,578 @@
/**
* AppContext - Central state provider for the AutoForge UI.
*
* Extracts all application state from the monolithic App.tsx into a shared
* React context so that deeply nested components can access state without
* prop-drilling. Provides the `useAppContext()` hook for consumption.
*/
import {
createContext,
useContext,
useState,
useEffect,
useCallback,
useMemo,
type ReactNode,
} from 'react'
import { useQueryClient, useQuery, type QueryClient } from '@tanstack/react-query'
import { useProjects, useFeatures, useAgentStatus, useSettings } from '../hooks/useProjects'
import { useProjectWebSocket } from '../hooks/useWebSocket'
import { useFeatureSound } from '../hooks/useFeatureSound'
import { useCelebration } from '../hooks/useCelebration'
import { useTheme, type ThemeId, type ThemeOption } from '../hooks/useTheme'
import { getDependencyGraph } from '../lib/api'
import type {
Feature,
FeatureListResponse,
ProjectSummary,
Settings,
DependencyGraph,
} from '../lib/types'
import { TooltipProvider } from '@/components/ui/tooltip'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type ViewId = 'dashboard' | 'kanban' | 'graph' | 'browsers' | 'terminal' | 'logs' | 'assistant' | 'settings'
type InitializerStatus = 'idle' | 'starting' | 'error'
/** Progress summary derived from WebSocket state and feature data. */
interface Progress {
passing: number
total: number
percentage: number
}
/**
* The full return type of `useProjectWebSocket`. We reference it structurally
* rather than importing a non-exported interface to keep the coupling minimal.
*/
type WebSocketState = ReturnType<typeof useProjectWebSocket>
/** Shape of the value exposed by AppContext. */
interface AppContextValue {
// -- Project selection --
selectedProject: string | null
setSelectedProject: (project: string | null) => void
// -- View navigation --
activeView: ViewId
setActiveView: (view: ViewId) => void
// -- Sidebar --
sidebarCollapsed: boolean
setSidebarCollapsed: (collapsed: boolean) => void
toggleSidebar: () => void
// -- Modals --
showAddFeature: boolean
setShowAddFeature: (open: boolean) => void
showExpandProject: boolean
setShowExpandProject: (open: boolean) => void
selectedFeature: Feature | null
setSelectedFeature: (feature: Feature | null) => void
showSettings: boolean
setShowSettings: (open: boolean) => void
showKeyboardHelp: boolean
setShowKeyboardHelp: (open: boolean) => void
showResetModal: boolean
setShowResetModal: (open: boolean) => void
showSpecChat: boolean
setShowSpecChat: (open: boolean) => void
isSpecCreating: boolean
setIsSpecCreating: (creating: boolean) => void
assistantOpen: boolean
setAssistantOpen: (open: boolean) => void
// -- Setup --
setupComplete: boolean
setSetupComplete: (complete: boolean) => void
// -- Spec initializer --
specInitializerStatus: InitializerStatus
setSpecInitializerStatus: (status: InitializerStatus) => void
specInitializerError: string | null
setSpecInitializerError: (error: string | null) => void
// -- Queries / data --
projects: ProjectSummary[] | undefined
projectsLoading: boolean
features: FeatureListResponse | undefined
settings: Settings | undefined
wsState: WebSocketState
theme: ThemeId
setTheme: (theme: ThemeId) => void
darkMode: boolean
toggleDarkMode: () => void
themes: ThemeOption[]
currentTheme: ThemeOption
queryClient: QueryClient
// -- Derived state --
selectedProjectData: ProjectSummary | undefined
hasSpec: boolean
progress: Progress
// -- Graph --
graphData: DependencyGraph | undefined
handleGraphNodeClick: (nodeId: number) => void
}
// ---------------------------------------------------------------------------
// LocalStorage helpers
// ---------------------------------------------------------------------------
const STORAGE_KEYS = {
selectedProject: 'autoforge-selected-project',
activeView: 'autoforge-active-view',
sidebarCollapsed: 'autoforge-sidebar-collapsed',
} as const
function readStorage<T extends string>(key: string, fallback: T): T {
try {
const stored = localStorage.getItem(key)
return (stored ?? fallback) as T
} catch {
return fallback
}
}
function writeStorage(key: string, value: string | null): void {
try {
if (value === null) {
localStorage.removeItem(key)
} else {
localStorage.setItem(key, value)
}
} catch {
// localStorage not available
}
}
// ---------------------------------------------------------------------------
// Context
// ---------------------------------------------------------------------------
const AppContext = createContext<AppContextValue | null>(null)
// ---------------------------------------------------------------------------
// Provider
// ---------------------------------------------------------------------------
export function AppProvider({ children }: { children: ReactNode }) {
// ---- Project ----
const [selectedProject, setSelectedProjectRaw] = useState<string | null>(() =>
readStorage(STORAGE_KEYS.selectedProject, '') || null,
)
const setSelectedProject = useCallback((project: string | null) => {
setSelectedProjectRaw(project)
writeStorage(STORAGE_KEYS.selectedProject, project)
}, [])
// ---- View navigation ----
const [activeView, setActiveViewRaw] = useState<ViewId>(() => {
const stored = readStorage(STORAGE_KEYS.activeView, 'dashboard')
const valid: ViewId[] = ['dashboard', 'kanban', 'graph', 'browsers', 'terminal', 'logs', 'assistant', 'settings']
return valid.includes(stored as ViewId) ? (stored as ViewId) : 'dashboard'
})
const setActiveView = useCallback((view: ViewId) => {
setActiveViewRaw(view)
writeStorage(STORAGE_KEYS.activeView, view)
}, [])
// ---- Sidebar ----
const [sidebarCollapsed, setSidebarCollapsedRaw] = useState<boolean>(() => {
try {
return localStorage.getItem(STORAGE_KEYS.sidebarCollapsed) === 'true'
} catch {
return false
}
})
const setSidebarCollapsed = useCallback((collapsed: boolean) => {
setSidebarCollapsedRaw(collapsed)
writeStorage(STORAGE_KEYS.sidebarCollapsed, String(collapsed))
}, [])
const toggleSidebar = useCallback(() => {
setSidebarCollapsedRaw(prev => {
const next = !prev
writeStorage(STORAGE_KEYS.sidebarCollapsed, String(next))
return next
})
}, [])
// ---- Modals ----
const [showAddFeature, setShowAddFeature] = useState(false)
const [showExpandProject, setShowExpandProject] = useState(false)
const [selectedFeature, setSelectedFeature] = useState<Feature | null>(null)
const [showSettings, setShowSettings] = useState(false)
const [showKeyboardHelp, setShowKeyboardHelp] = useState(false)
const [showResetModal, setShowResetModal] = useState(false)
const [showSpecChat, setShowSpecChat] = useState(false)
const [isSpecCreating, setIsSpecCreating] = useState(false)
const [assistantOpen, setAssistantOpen] = useState(false)
// ---- Setup ----
const [setupComplete, setSetupComplete] = useState(true) // optimistic default
// ---- Spec initializer ----
const [specInitializerStatus, setSpecInitializerStatus] = useState<InitializerStatus>('idle')
const [specInitializerError, setSpecInitializerError] = useState<string | null>(null)
// ---- Queries ----
const queryClient = useQueryClient()
const { data: projects, isLoading: projectsLoading } = useProjects()
const { data: features } = useFeatures(selectedProject)
const { data: settings } = useSettings()
useAgentStatus(selectedProject) // keep polling for status updates
const wsState = useProjectWebSocket(selectedProject)
const { theme, setTheme, darkMode, toggleDarkMode, themes, currentTheme } = useTheme()
// ---- Derived state ----
const selectedProjectData = projects?.find(p => p.name === selectedProject)
const hasSpec = selectedProjectData?.has_spec ?? true
const progress = useMemo<Progress>(() => {
// Prefer WebSocket progress when available; fall back to feature counts
if (wsState.progress.total > 0) {
return {
passing: wsState.progress.passing,
total: wsState.progress.total,
percentage: wsState.progress.percentage,
}
}
const total =
(features?.pending.length ?? 0) +
(features?.in_progress.length ?? 0) +
(features?.done.length ?? 0) +
(features?.needs_human_input?.length ?? 0)
const passing = features?.done.length ?? 0
const percentage = total > 0 ? Math.round((passing / total) * 100 * 10) / 10 : 0
return { passing, total, percentage }
}, [wsState.progress, features])
// ---- Graph data query ----
const { data: graphData } = useQuery({
queryKey: ['dependencyGraph', selectedProject],
queryFn: () => getDependencyGraph(selectedProject!),
enabled: !!selectedProject && activeView === 'graph',
refetchInterval: 5000,
})
// ---- Graph node click handler ----
const handleGraphNodeClick = useCallback((nodeId: number) => {
const allFeatures = [
...(features?.pending ?? []),
...(features?.in_progress ?? []),
...(features?.done ?? []),
...(features?.needs_human_input ?? []),
]
const feature = allFeatures.find(f => f.id === nodeId)
if (feature) setSelectedFeature(feature)
}, [features])
// ---- Side-effects ----
// Play sounds when features move between columns
useFeatureSound(features)
// Celebrate when all features are complete
useCelebration(features, selectedProject)
// Validate stored project exists (clear if project was deleted)
useEffect(() => {
if (selectedProject && projects && !projects.some(p => p.name === selectedProject)) {
setSelectedProject(null)
}
}, [selectedProject, projects, setSelectedProject])
// ---- Keyboard shortcuts ----
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Skip if the user is typing in an input or textarea
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
return
}
// -- View navigation shortcuts --
if (e.key === 'h' || e.key === 'H') {
e.preventDefault()
setActiveView('dashboard')
return
}
if (e.key === 'k' || e.key === 'K') {
e.preventDefault()
setActiveView('kanban')
return
}
if (e.key === 'g' || e.key === 'G') {
e.preventDefault()
setActiveView('graph')
return
}
if (e.key === 'b' || e.key === 'B') {
e.preventDefault()
setActiveView('browsers')
return
}
if (e.key === 't' || e.key === 'T') {
e.preventDefault()
setActiveView('terminal')
return
}
if (e.key === 'd' || e.key === 'D') {
e.preventDefault()
setActiveView('logs')
return
}
// A : Toggle assistant panel (overlay, not view navigation)
if ((e.key === 'a' || e.key === 'A') && selectedProject && !isSpecCreating) {
e.preventDefault()
setAssistantOpen(prev => !prev)
return
}
// [ : Toggle sidebar
if (e.key === '[') {
e.preventDefault()
toggleSidebar()
return
}
// -- Modal shortcuts --
// N : Add new feature (when project selected)
if ((e.key === 'n' || e.key === 'N') && selectedProject) {
e.preventDefault()
setShowAddFeature(true)
return
}
// E : Expand project with AI (when project selected, has spec, and has features)
if (
(e.key === 'e' || e.key === 'E') &&
selectedProject &&
hasSpec &&
features &&
(features.pending.length +
features.in_progress.length +
features.done.length +
(features.needs_human_input?.length || 0)) > 0
) {
e.preventDefault()
setShowExpandProject(true)
return
}
// , : Navigate to settings view
if (e.key === ',') {
e.preventDefault()
setActiveView('settings')
return
}
// ? : Show keyboard shortcuts help
if (e.key === '?') {
e.preventDefault()
setShowKeyboardHelp(true)
return
}
// R : Open reset modal (when project selected and agent not running/draining)
if (
(e.key === 'r' || e.key === 'R') &&
selectedProject &&
!['running', 'pausing', 'paused_graceful'].includes(wsState.agentStatus)
) {
e.preventDefault()
setShowResetModal(true)
return
}
// Escape : Close modals in priority order
if (e.key === 'Escape') {
if (showKeyboardHelp) {
setShowKeyboardHelp(false)
} else if (showResetModal) {
setShowResetModal(false)
} else if (showExpandProject) {
setShowExpandProject(false)
} else if (showSettings) {
setShowSettings(false)
} else if (assistantOpen) {
setAssistantOpen(false)
} else if (showAddFeature) {
setShowAddFeature(false)
} else if (selectedFeature) {
setSelectedFeature(null)
}
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [
selectedProject,
showAddFeature,
showExpandProject,
selectedFeature,
assistantOpen,
features,
showSettings,
showKeyboardHelp,
isSpecCreating,
showResetModal,
wsState.agentStatus,
hasSpec,
setActiveView,
toggleSidebar,
])
// ---- Assemble context value (memoised to avoid unnecessary re-renders) ----
const value = useMemo<AppContextValue>(
() => ({
// Project
selectedProject,
setSelectedProject,
// View navigation
activeView,
setActiveView,
// Sidebar
sidebarCollapsed,
setSidebarCollapsed,
toggleSidebar,
// Modals
showAddFeature,
setShowAddFeature,
showExpandProject,
setShowExpandProject,
selectedFeature,
setSelectedFeature,
showSettings,
setShowSettings,
showKeyboardHelp,
setShowKeyboardHelp,
showResetModal,
setShowResetModal,
showSpecChat,
setShowSpecChat,
isSpecCreating,
setIsSpecCreating,
assistantOpen,
setAssistantOpen,
// Setup
setupComplete,
setSetupComplete,
// Spec initializer
specInitializerStatus,
setSpecInitializerStatus,
specInitializerError,
setSpecInitializerError,
// Queries / data
projects,
projectsLoading,
features,
settings,
wsState,
theme,
setTheme,
darkMode,
toggleDarkMode,
themes,
currentTheme,
queryClient,
// Derived
selectedProjectData,
hasSpec,
progress,
// Graph
graphData,
handleGraphNodeClick,
}),
[
selectedProject,
setSelectedProject,
activeView,
setActiveView,
sidebarCollapsed,
setSidebarCollapsed,
toggleSidebar,
showAddFeature,
showExpandProject,
selectedFeature,
showSettings,
showKeyboardHelp,
showResetModal,
showSpecChat,
isSpecCreating,
assistantOpen,
setupComplete,
specInitializerStatus,
specInitializerError,
projects,
projectsLoading,
features,
settings,
wsState,
theme,
setTheme,
darkMode,
toggleDarkMode,
themes,
currentTheme,
queryClient,
selectedProjectData,
hasSpec,
progress,
graphData,
handleGraphNodeClick,
],
)
return (
<AppContext.Provider value={value}>
<TooltipProvider>
{children}
</TooltipProvider>
</AppContext.Provider>
)
}
// ---------------------------------------------------------------------------
// Consumer hook
// ---------------------------------------------------------------------------
/**
* Access the global application context.
* Must be called inside `<AppProvider>`.
*/
export function useAppContext(): AppContextValue {
const ctx = useContext(AppContext)
if (!ctx) {
throw new Error('useAppContext must be used within an <AppProvider>')
}
return ctx
}

View File

@@ -137,6 +137,7 @@ function isAllComplete(features: FeatureListResponse | undefined): boolean {
return ( return (
features.pending.length === 0 && features.pending.length === 0 &&
features.in_progress.length === 0 && features.in_progress.length === 0 &&
(features.needs_human_input?.length || 0) === 0 &&
features.done.length > 0 features.done.length > 0
) )
} }

View File

@@ -3,7 +3,7 @@
*/ */
import { useState, useCallback, useRef, useEffect } from 'react' import { useState, useCallback, useRef, useEffect } from 'react'
import type { ChatMessage, ImageAttachment, ExpandChatServerMessage } from '../lib/types' import type { ChatMessage, FileAttachment, ExpandChatServerMessage } from '../lib/types'
type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error' type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error'
@@ -27,7 +27,7 @@ interface UseExpandChatReturn {
featuresCreated: number featuresCreated: number
recentFeatures: CreatedFeature[] recentFeatures: CreatedFeature[]
start: () => void start: () => void
sendMessage: (content: string, attachments?: ImageAttachment[]) => void sendMessage: (content: string, attachments?: FileAttachment[]) => void
disconnect: () => void disconnect: () => void
} }
@@ -278,7 +278,7 @@ export function useExpandChat({
setTimeout(checkAndSend, 100) setTimeout(checkAndSend, 100)
}, [connect, onError]) }, [connect, onError])
const sendMessage = useCallback((content: string, attachments?: ImageAttachment[]) => { const sendMessage = useCallback((content: string, attachments?: FileAttachment[]) => {
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) { if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
onError?.('Not connected') onError?.('Not connected')
return return

View File

@@ -133,6 +133,18 @@ export function useUpdateFeature(projectName: string) {
}) })
} }
export function useResolveHumanInput(projectName: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ featureId, fields }: { featureId: number; fields: Record<string, string | boolean | string[]> }) =>
api.resolveHumanInput(projectName, featureId, { fields }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['features', projectName] })
},
})
}
// ============================================================================ // ============================================================================
// Agent // Agent
// ============================================================================ // ============================================================================
@@ -197,6 +209,28 @@ export function useResumeAgent(projectName: string) {
}) })
} }
export function useGracefulPauseAgent(projectName: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: () => api.gracefulPauseAgent(projectName),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['agent-status', projectName] })
},
})
}
export function useGracefulResumeAgent(projectName: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: () => api.gracefulResumeAgent(projectName),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['agent-status', projectName] })
},
})
}
// ============================================================================ // ============================================================================
// Setup // Setup
// ============================================================================ // ============================================================================
@@ -268,6 +302,7 @@ const DEFAULT_SETTINGS: Settings = {
testing_agent_ratio: 1, testing_agent_ratio: 1,
playwright_headless: true, playwright_headless: true,
batch_size: 3, batch_size: 3,
testing_batch_size: 3,
api_provider: 'claude', api_provider: 'claude',
api_base_url: null, api_base_url: null,
api_has_auth_token: false, api_has_auth_token: false,

View File

@@ -3,7 +3,7 @@
*/ */
import { useState, useCallback, useRef, useEffect } from 'react' import { useState, useCallback, useRef, useEffect } from 'react'
import type { ChatMessage, ImageAttachment, SpecChatServerMessage, SpecQuestion } from '../lib/types' import type { ChatMessage, FileAttachment, SpecChatServerMessage, SpecQuestion } from '../lib/types'
import { getSpecStatus } from '../lib/api' import { getSpecStatus } from '../lib/api'
type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error' type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error'
@@ -22,7 +22,7 @@ interface UseSpecChatReturn {
currentQuestions: SpecQuestion[] | null currentQuestions: SpecQuestion[] | null
currentToolId: string | null currentToolId: string | null
start: () => void start: () => void
sendMessage: (content: string, attachments?: ImageAttachment[]) => void sendMessage: (content: string, attachments?: FileAttachment[]) => void
sendAnswer: (answers: Record<string, string | string[]>) => void sendAnswer: (answers: Record<string, string | string[]>) => void
disconnect: () => void disconnect: () => void
} }
@@ -367,7 +367,7 @@ export function useSpecChat({
setTimeout(checkAndSend, 100) setTimeout(checkAndSend, 100)
}, [connect]) }, [connect])
const sendMessage = useCallback((content: string, attachments?: ImageAttachment[]) => { const sendMessage = useCallback((content: string, attachments?: FileAttachment[]) => {
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) { if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
onError?.('Not connected') onError?.('Not connected')
return return

View File

@@ -10,6 +10,7 @@ import type {
ActiveAgent, ActiveAgent,
AgentMascot, AgentMascot,
AgentLogEntry, AgentLogEntry,
BrowserScreenshot,
OrchestratorStatus, OrchestratorStatus,
OrchestratorEvent, OrchestratorEvent,
} from '../lib/types' } from '../lib/types'
@@ -33,6 +34,7 @@ interface WebSocketState {
progress: { progress: {
passing: number passing: number
in_progress: number in_progress: number
needs_human_input: number
total: number total: number
percentage: number percentage: number
} }
@@ -52,6 +54,9 @@ interface WebSocketState {
celebration: CelebrationTrigger | null celebration: CelebrationTrigger | null
// Orchestrator state for Mission Control // Orchestrator state for Mission Control
orchestratorStatus: OrchestratorStatus | null orchestratorStatus: OrchestratorStatus | null
// Browser view screenshots (sessionName -> latest screenshot)
browserScreenshots: Map<string, BrowserScreenshot>
browserViewSubscribed: boolean
} }
const MAX_LOGS = 100 // Keep last 100 log lines const MAX_LOGS = 100 // Keep last 100 log lines
@@ -60,7 +65,7 @@ const MAX_AGENT_LOGS = 500 // Keep last 500 log lines per agent
export function useProjectWebSocket(projectName: string | null) { export function useProjectWebSocket(projectName: string | null) {
const [state, setState] = useState<WebSocketState>({ const [state, setState] = useState<WebSocketState>({
progress: { passing: 0, in_progress: 0, total: 0, percentage: 0 }, progress: { passing: 0, in_progress: 0, needs_human_input: 0, total: 0, percentage: 0 },
agentStatus: 'loading', agentStatus: 'loading',
logs: [], logs: [],
isConnected: false, isConnected: false,
@@ -73,6 +78,8 @@ export function useProjectWebSocket(projectName: string | null) {
celebrationQueue: [], celebrationQueue: [],
celebration: null, celebration: null,
orchestratorStatus: null, orchestratorStatus: null,
browserScreenshots: new Map(),
browserViewSubscribed: false,
}) })
const wsRef = useRef<WebSocket | null>(null) const wsRef = useRef<WebSocket | null>(null)
@@ -107,6 +114,7 @@ export function useProjectWebSocket(projectName: string | null) {
progress: { progress: {
passing: message.passing, passing: message.passing,
in_progress: message.in_progress, in_progress: message.in_progress,
needs_human_input: message.needs_human_input ?? 0,
total: message.total, total: message.total,
percentage: message.percentage, percentage: message.percentage,
}, },
@@ -117,11 +125,12 @@ export function useProjectWebSocket(projectName: string | null) {
setState(prev => ({ setState(prev => ({
...prev, ...prev,
agentStatus: message.status, agentStatus: message.status,
// Clear active agents and orchestrator status when process stops OR crashes to prevent stale UI // Clear active agents, orchestrator status, and browser screenshots when process stops OR crashes
...((message.status === 'stopped' || message.status === 'crashed') && { ...((message.status === 'stopped' || message.status === 'crashed') && {
activeAgents: [], activeAgents: [],
recentActivity: [], recentActivity: [],
orchestratorStatus: null, orchestratorStatus: null,
browserScreenshots: new Map(),
}), }),
})) }))
break break
@@ -326,6 +335,22 @@ export function useProjectWebSocket(projectName: string | null) {
})) }))
break break
case 'browser_screenshot':
setState(prev => {
const newScreenshots = new Map(prev.browserScreenshots)
newScreenshots.set(message.sessionName, {
sessionName: message.sessionName,
agentIndex: message.agentIndex,
agentType: message.agentType,
featureId: message.featureId,
featureName: message.featureName,
imageDataUrl: `data:image/png;base64,${message.imageData}`,
timestamp: message.timestamp,
})
return { ...prev, browserScreenshots: newScreenshots }
})
break
case 'pong': case 'pong':
// Heartbeat response // Heartbeat response
break break
@@ -385,7 +410,7 @@ export function useProjectWebSocket(projectName: string | null) {
// Reset state when project changes to clear stale data // Reset state when project changes to clear stale data
// Use 'loading' for agentStatus to show loading indicator until WebSocket provides actual status // Use 'loading' for agentStatus to show loading indicator until WebSocket provides actual status
setState({ setState({
progress: { passing: 0, in_progress: 0, total: 0, percentage: 0 }, progress: { passing: 0, in_progress: 0, needs_human_input: 0, total: 0, percentage: 0 },
agentStatus: 'loading', agentStatus: 'loading',
logs: [], logs: [],
isConnected: false, isConnected: false,
@@ -398,6 +423,8 @@ export function useProjectWebSocket(projectName: string | null) {
celebrationQueue: [], celebrationQueue: [],
celebration: null, celebration: null,
orchestratorStatus: null, orchestratorStatus: null,
browserScreenshots: new Map(),
browserViewSubscribed: false,
}) })
if (!projectName) { if (!projectName) {
@@ -471,6 +498,22 @@ export function useProjectWebSocket(projectName: string | null) {
}) })
}, []) }, [])
// Subscribe to browser view screenshots
const subscribeBrowserView = useCallback(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'browser_view_subscribe' }))
setState(prev => ({ ...prev, browserViewSubscribed: true }))
}
}, [])
// Unsubscribe from browser view screenshots
const unsubscribeBrowserView = useCallback(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'browser_view_unsubscribe' }))
setState(prev => ({ ...prev, browserViewSubscribed: false }))
}
}, [])
return { return {
...state, ...state,
clearLogs, clearLogs,
@@ -478,5 +521,7 @@ export function useProjectWebSocket(projectName: string | null) {
clearCelebration, clearCelebration,
getAgentLogs, getAgentLogs,
clearAgentLogs, clearAgentLogs,
subscribeBrowserView,
unsubscribeBrowserView,
} }
} }

View File

@@ -181,6 +181,17 @@ export async function createFeaturesBulk(
}) })
} }
export async function resolveHumanInput(
projectName: string,
featureId: number,
response: { fields: Record<string, string | boolean | string[]> }
): Promise<Feature> {
return fetchJSON(`/projects/${encodeURIComponent(projectName)}/features/${featureId}/resolve-human-input`, {
method: 'POST',
body: JSON.stringify(response),
})
}
// ============================================================================ // ============================================================================
// Dependency Graph API // Dependency Graph API
// ============================================================================ // ============================================================================
@@ -271,6 +282,18 @@ export async function resumeAgent(projectName: string): Promise<AgentActionRespo
}) })
} }
export async function gracefulPauseAgent(projectName: string): Promise<AgentActionResponse> {
return fetchJSON(`/projects/${encodeURIComponent(projectName)}/agent/graceful-pause`, {
method: 'POST',
})
}
export async function gracefulResumeAgent(projectName: string): Promise<AgentActionResponse> {
return fetchJSON(`/projects/${encodeURIComponent(projectName)}/agent/graceful-resume`, {
method: 'POST',
})
}
// ============================================================================ // ============================================================================
// Spec Creation API // Spec Creation API
// ============================================================================ // ============================================================================

View File

@@ -57,6 +57,26 @@ export interface ProjectPrompts {
coding_prompt: string coding_prompt: string
} }
// Human input types
export interface HumanInputField {
id: string
label: string
type: 'text' | 'textarea' | 'select' | 'boolean'
required: boolean
placeholder?: string
options?: { value: string; label: string }[]
}
export interface HumanInputRequest {
prompt: string
fields: HumanInputField[]
}
export interface HumanInputResponseData {
fields: Record<string, string | boolean | string[]>
responded_at?: string
}
// Feature types // Feature types
export interface Feature { export interface Feature {
id: number id: number
@@ -70,10 +90,13 @@ export interface Feature {
dependencies?: number[] // Optional for backwards compat dependencies?: number[] // Optional for backwards compat
blocked?: boolean // Computed by API blocked?: boolean // Computed by API
blocking_dependencies?: number[] // Computed by API blocking_dependencies?: number[] // Computed by API
needs_human_input?: boolean
human_input_request?: HumanInputRequest | null
human_input_response?: HumanInputResponseData | null
} }
// Status type for graph nodes // Status type for graph nodes
export type FeatureStatus = 'pending' | 'in_progress' | 'done' | 'blocked' export type FeatureStatus = 'pending' | 'in_progress' | 'done' | 'blocked' | 'needs_human_input'
// Graph visualization types // Graph visualization types
export interface GraphNode { export interface GraphNode {
@@ -99,6 +122,7 @@ export interface FeatureListResponse {
pending: Feature[] pending: Feature[]
in_progress: Feature[] in_progress: Feature[]
done: Feature[] done: Feature[]
needs_human_input: Feature[]
} }
export interface FeatureCreate { export interface FeatureCreate {
@@ -120,7 +144,7 @@ export interface FeatureUpdate {
} }
// Agent types // Agent types
export type AgentStatus = 'stopped' | 'running' | 'paused' | 'crashed' | 'loading' export type AgentStatus = 'stopped' | 'running' | 'paused' | 'crashed' | 'loading' | 'pausing' | 'paused_graceful'
export interface AgentStatusResponse { export interface AgentStatusResponse {
status: AgentStatus status: AgentStatus
@@ -216,6 +240,8 @@ export type OrchestratorState =
| 'spawning' | 'spawning'
| 'monitoring' | 'monitoring'
| 'complete' | 'complete'
| 'draining'
| 'paused'
// Orchestrator event for recent activity // Orchestrator event for recent activity
export interface OrchestratorEvent { export interface OrchestratorEvent {
@@ -240,7 +266,7 @@ export interface OrchestratorStatus {
} }
// WebSocket message types // WebSocket message types
export type WSMessageType = 'progress' | 'feature_update' | 'log' | 'agent_status' | 'pong' | 'dev_log' | 'dev_server_status' | 'agent_update' | 'orchestrator_update' export type WSMessageType = 'progress' | 'feature_update' | 'log' | 'agent_status' | 'pong' | 'dev_log' | 'dev_server_status' | 'agent_update' | 'orchestrator_update' | 'browser_screenshot'
export interface WSProgressMessage { export interface WSProgressMessage {
type: 'progress' type: 'progress'
@@ -248,6 +274,7 @@ export interface WSProgressMessage {
in_progress: number in_progress: number
total: number total: number
percentage: number percentage: number
needs_human_input?: number
} }
export interface WSFeatureUpdateMessage { export interface WSFeatureUpdateMessage {
@@ -315,6 +342,28 @@ export interface WSOrchestratorUpdateMessage {
featureName?: string featureName?: string
} }
export interface WSBrowserScreenshotMessage {
type: 'browser_screenshot'
sessionName: string
agentIndex: number
agentType: AgentType
featureId: number
featureName: string
imageData: string // base64 PNG
timestamp: string
}
// Browser screenshot stored in UI state
export interface BrowserScreenshot {
sessionName: string
agentIndex: number
agentType: AgentType
featureId: number
featureName: string
imageDataUrl: string // "data:image/png;base64,..."
timestamp: string
}
export type WSMessage = export type WSMessage =
| WSProgressMessage | WSProgressMessage
| WSFeatureUpdateMessage | WSFeatureUpdateMessage
@@ -325,6 +374,7 @@ export type WSMessage =
| WSDevLogMessage | WSDevLogMessage
| WSDevServerStatusMessage | WSDevServerStatusMessage
| WSOrchestratorUpdateMessage | WSOrchestratorUpdateMessage
| WSBrowserScreenshotMessage
// ============================================================================ // ============================================================================
// Spec Chat Types // Spec Chat Types
@@ -390,22 +440,67 @@ export type SpecChatServerMessage =
| SpecChatPongMessage | SpecChatPongMessage
| SpecChatResponseDoneMessage | SpecChatResponseDoneMessage
// Image attachment for chat messages // File attachment for chat messages (images and documents)
export interface ImageAttachment { export interface FileAttachment {
id: string id: string
filename: string filename: string
mimeType: 'image/jpeg' | 'image/png' mimeType:
| 'image/jpeg'
| 'image/png'
| 'text/plain'
| 'text/markdown'
| 'text/csv'
| 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
| 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
| 'application/pdf'
| 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
base64Data: string // Raw base64 (without data: prefix) base64Data: string // Raw base64 (without data: prefix)
previewUrl: string // data: URL for display previewUrl: string // data: URL for images, empty string for documents
size: number // File size in bytes size: number // File size in bytes
} }
/** @deprecated Use FileAttachment instead */
export type ImageAttachment = FileAttachment
export const IMAGE_MIME_TYPES = ['image/jpeg', 'image/png'] as const
export const DOCUMENT_MIME_TYPES = [
'text/plain',
'text/markdown',
'text/csv',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/pdf',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
] as const
export const ALL_ALLOWED_MIME_TYPES: string[] = [...IMAGE_MIME_TYPES, ...DOCUMENT_MIME_TYPES]
export function isImageAttachment(att: FileAttachment): boolean {
return (IMAGE_MIME_TYPES as readonly string[]).includes(att.mimeType)
}
export function resolveMimeType(filename: string): string {
const ext = filename.split('.').pop()?.toLowerCase()
const map: Record<string, string> = {
md: 'text/markdown',
txt: 'text/plain',
csv: 'text/csv',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
pdf: 'application/pdf',
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
}
return map[ext || ''] || 'application/octet-stream'
}
// UI chat message for display // UI chat message for display
export interface ChatMessage { export interface ChatMessage {
id: string id: string
role: 'user' | 'assistant' | 'system' role: 'user' | 'assistant' | 'system'
content: string content: string
attachments?: ImageAttachment[] attachments?: FileAttachment[]
timestamp: Date timestamp: Date
questions?: SpecQuestion[] questions?: SpecQuestion[]
isStreaming?: boolean isStreaming?: boolean
@@ -552,7 +647,8 @@ export interface Settings {
ollama_mode: boolean ollama_mode: boolean
testing_agent_ratio: number // Regression testing agents (0-3) testing_agent_ratio: number // Regression testing agents (0-3)
playwright_headless: boolean playwright_headless: boolean
batch_size: number // Features per coding agent batch (1-3) batch_size: number // Features per coding agent batch (1-15)
testing_batch_size: number // Features per testing agent batch (1-15)
api_provider: string api_provider: string
api_base_url: string | null api_base_url: string | null
api_has_auth_token: boolean api_has_auth_token: boolean
@@ -563,8 +659,8 @@ export interface SettingsUpdate {
yolo_mode?: boolean yolo_mode?: boolean
model?: string model?: string
testing_agent_ratio?: number testing_agent_ratio?: number
playwright_headless?: boolean
batch_size?: number batch_size?: number
testing_batch_size?: number
api_provider?: string api_provider?: string
api_base_url?: string api_base_url?: string
api_auth_token?: string api_auth_token?: string

View File

@@ -1472,3 +1472,53 @@
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: var(--muted-foreground); background: var(--muted-foreground);
} }
/* ============================================================================
Slider (range input) styling
============================================================================ */
.slider-input::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--primary);
border: 2px solid var(--primary-foreground);
box-shadow: var(--shadow-sm);
cursor: pointer;
transition: transform 150ms, box-shadow 150ms;
}
.slider-input::-webkit-slider-thumb:hover {
transform: scale(1.15);
box-shadow: var(--shadow);
}
.slider-input::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--primary);
border: 2px solid var(--primary-foreground);
box-shadow: var(--shadow-sm);
cursor: pointer;
transition: transform 150ms, box-shadow 150ms;
}
.slider-input::-moz-range-thumb:hover {
transform: scale(1.15);
box-shadow: var(--shadow);
}
.slider-input::-webkit-slider-runnable-track {
height: 8px;
border-radius: 9999px;
background: var(--input);
}
.slider-input::-moz-range-track {
height: 8px;
border-radius: 9999px;
background: var(--input);
}