Archon Web UI: React frontend, web adapter, API routes, workflow events

* Archon UI PRD and phase 1 plan

* Initial Archon UI

* Workflow tracking

* UI improvements (with a couple schema changes)

* Message history for web adapter and UI adjustments

* Chat/Project/Tasks in Archon UI

* UI fixes

* UI fixes

* UI improvements

* Fix PR review issues: type safety, error handling, cleanup, docs

- Convert MessageChunk to discriminated union with workflow_dispatch variant
- Add IWebPlatformAdapter interface and isWebAdapter() type guard
- Replace unsafe 'in' type guards with proper type narrowing in orchestrator
- Add logging to silent catch blocks in api.ts, web.ts
- Add WebAdapter buffer cleanup with 60s delayed cleanup on disconnect
- Wrap SQLite migration ALTER TABLE groups in try/catch
- Log SSE parse errors in useSSE.ts
- Truncate API error bodies to 200 chars, include URL path
- Surface background workflow errors to parent conversation
- Fix getWorkflowRunByWorker to only swallow 404s, re-throw 500s
- Add documentation comments to ChatMessage and SSEEvent types
- Update table counts from 3/5/6 to 8 across all docs

* Remove agent plan files from PR, gitignore .agents/plans/

These are local development artifacts that shouldn't be in the repo.

* Gitignore .claude/PRPs/

* Fix PR review issues: error handling, type safety, silent failures

- Fix acquireLock error swallowing: wrap handler in try/catch/finally to
  always release UI lock and surface errors to SSE stream
- Restrict CORS to configurable origin (WEB_UI_ORIGIN env var)
- Validate status query param instead of unsafe `as` cast
- Add per-codebase JSON.parse error isolation for corrupted commands
- Type request body in POST /api/conversations
- Return 404 for missing conversation instead of empty array
- Add warning log when flushAssistantMessage has no DB ID mapping
- Add debug logging to 6 SSE catch blocks (was silently swallowing)
- Replace 17x .catch(() => undefined) with void on fire-and-forget calls
- Make workflow status='running' update a blocking error
- Split MessageChunk into proper per-type discriminated union
- Add hidden field to Conversation interface
- Add guard in addMessage for undefined row
- Extract WorkflowRunStatus/WorkflowStepStatus type aliases
- Fix WorkflowArtifact.type to use literal union
- Separate SSE parse errors from handler errors in useSSE hook
- Gitignore e2e testing session artifact

* Wire up dead endpoints: workflow run UI, health display, worker→run link

- Extract dispatchToOrchestrator() helper to deduplicate lock/dispatch
  logic between message send and workflow run endpoints
- Fix workflow run endpoint to construct /workflow run <name> <msg>
  from URL param + body, with name validation
- Add run panel to WorkflowList with message input, error display,
  and navigation to chat on success
- Add System Health section to SettingsPage with independent query
  and actionable error messages
- Add worker→run navigation link in WorkflowExecution header
- Regenerate 000_combined.sql to include migrations 001-015
- Fix silent .catch() patterns: log errors instead of swallowing
- Wrap emitSSE in catch block, log full error objects

* Fix routing for conversation IDs with special characters

GitHub issue conversation IDs like "user/repo#42" contain / and #
which break React Router's :param matching. Fix by:
- Use /chat/* splat route instead of /chat/:conversationId
- encodeURIComponent() all platform_conversation_id values in URLs
- decodeURIComponent() in ChatPage when reading the splat param

* Restructure sidebar: project selector nav, remove dead components

Replace multi-view sidebar (ChatView, ProjectView, TaskView, ViewSwitcher)
with a simpler project-scoped navigation. Add ProjectSelector component
for switching between codebases. Remove unused ConversationsList,
useNotifications hook, and related dead code.

* Add project CRUD: clone/register from UI, delete with cleanup

Extract clone logic from command-handler into standalone clone.ts module
with cloneRepository() and registerRepository() entry points. Add REST
endpoints POST /api/codebases (clone URL or register local path) and
DELETE /api/codebases/:id (destroy worktrees, unlink DB, remove workspace).

Update sidebar with "+" button for inline project add input that auto-detects
URL vs local path, and hover-to-delete with AlertDialog confirmation on each
project in ProjectSelector.

* Harden backend guard rails, polish UX, remove dead project pages

Backend:
- Guard output callback and cleanup timer with try-catch
- Add zombie stream reaper (5min interval) with proper cleanup
- Force-flush assistant buffer at 50 segments to prevent unbounded growth
- Log warning on message buffer overflow
- Emit SSE warning when user message persistence fails

Frontend:
- Show spinner on send button while processing
- Add native tooltip on truncated conversation titles
- Make sidebar resize handle visible with subtle background

Dead code:
- Delete orphaned ProjectDetail, ProjectsList, ProjectsPage
- Remove /projects/:id route from App.tsx

* Dashboard landing page, chat empty state, API error quality, SQLite indexes

- Replace DashboardPage redirect with landing page showing recent
  conversations and workflow runs in a two-column grid
- Fix /chat empty state to render ChatInterface in new-chat mode
  instead of a static placeholder
- Add proper HTTP status codes to API: 404 for missing conversations,
  400 for invalid codebase IDs, empty array for failed workflow discovery
- Add 3 missing indexes to SQLite schema from PG migrations 009/010
  (workflow run staleness, session parent, session conversation lookup)

* Scope workflow runs to selected project

Pass the selected project's codebaseId when creating a conversation
for workflow execution. Show which project the workflow will target,
and disable the run controls when no project is selected.

* Inline project selector in workflow invocation panel

Replace static "Running on {project}" text with a <select> dropdown
so users can pick a project without leaving the workflow view.

* Fix chat header showing "No project" and new chat missing project

Header now resolves project name from codebase_id when cwd is absent.
Dashboard "New Chat" creates conversation with selected project instead
of navigating to an orphaned /chat route. Buttons disabled when no
project is selected.

* Sidebar nav, workflow builder route, workflow invoker in project detail

Add Workflows and Workflow Builder nav links to sidebar, extract
navLinkClass helper. Wire WorkflowInvoker into ProjectDetail. Add
WorkflowBuilderPage route. Remove dead redirect routes and unused
Header from pages that don't need it.

* Fix review issues: guard rm-rf, error handling, Error Boundary, dedup utility

- Guard DELETE /api/codebases/:id to only rm-rf paths under ~/.archon/workspaces/
  (externally registered repos only get DB record deleted)
- Remove accidental e2e-testing-findings-session2.md
- Replace 3x .catch(() => undefined) with proper error logging
- Add React Error Boundary at app root to prevent white-screen crashes
- Wrap JSON.parse(metadata) in try-catch in ChatInterface and WorkflowLogs
- Upgrade SSE write failure logging from debug to warn, add missing logging
- Fix bare catch in SSE heartbeat to only swallow disconnect errors
- Remove unused _createErrorHandler parameter from registerApiRoutes
- Extract shared findMarkdownFilesRecursive to packages/core/src/utils/commands.ts
- Convert boolean to integer explicitly for SQLite hidden column
- Surface workflow discovery errors as warning in API response

* Address PR review: error handling, SSE resilience, test coverage

- Fix path traversal in codebase deletion (use normalizedCwd)
- Buffer SSE write failures for reconnect delivery (3 catch blocks)
- Extract flushBufferedMessages helper with partial-failure recovery
- Add defensive outer catch to background workflow dispatch
- Surface API/load errors to users (history, metadata, React Query)
- Add SSE parse validation and handler error surfacing
- Show stale indicator when workflow polling loses connection
- Add delete error handling to ConversationItem dialog
- Import shared types from @archon/core instead of duplicating
- Lower activity update failure threshold from 5 to 3
- Add test coverage for messages.ts and workflow-events.ts (100%)

* docs: Update documentation for Web UI, workflow events, and message persistence

- Update database schema description (workflow_events detail, messages metadata)
- Add Web UI platform adapter to architecture docs
- Document REST API endpoints for Web UI
- Add SSE streaming pattern documentation
- Update conversation schema (title, deleted_at columns)
- Expand Web UI feature list (workflow invocation, message persistence)

* Address PR review: error handling, type safety, SSE resilience

- Add null check on createWorkflowRun INSERT return
- Narrow catch in registerRepoAtPath to not swallow command loading errors
- Add SSE onerror logging and user notification on permanent close
- Add WarningEvent to SSE union and surface warnings in ChatInterface
- Fix WorkflowEventRow.data SQLite type mismatch (parse JSON string)
- Separate conversation lookup from auto-titling error handling
- Fix log levels for data loss scenarios (warn/debug → error)
- Emit SSE warning to user on flushAssistantMessage failure
- Set workflow run parent link regardless of success/failure
- Cap total buffered conversations at 200
- Define ArtifactType once in core, use everywhere
- Use imported status types in SSE event interfaces
- Add exhaustiveness checks in switch statements
- Preserve original git error in registerRepository
- Remove unused recoverable field from ErrorEvent

---------

Co-authored-by: Rasmus Widing <rasmus.widing@gmail.com>
This commit is contained in:
Cole Medin 2026-02-16 00:03:44 -07:00 committed by GitHub
parent 76e71b98ae
commit 352171474b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
93 changed files with 10735 additions and 388 deletions

View file

@ -0,0 +1,11 @@
Study @ARGUMENTS and rebase if needed. Check if there are any mentioned issues in the review reports that should be addressed or have follow-up issues before we merge this PR.
The CI is failing due to a billing issue, please run validation locally.
issue #367 is a housekeeping issue list, suggest if we should add any of the low items from the PR to the list, address them now or just skip fully.
Documentation issues should be addressed before merging if related to the PR otherwise added to housekeeping, we dont skip documentation issues.
IF the user tells you to merge merge with:
gh pr merge <pr-number> --rebase OR squash

View file

@ -0,0 +1,251 @@
---
name: agent-browser
description: Automates browser interactions for web testing, form filling, screenshots, and data extraction. Use when the user needs to navigate websites, interact with web pages, fill forms, take screenshots, test web applications, or extract information from web pages.
---
# Browser Automation with agent-browser
## Quick start
```bash
agent-browser open <url> # Navigate to page
agent-browser snapshot -i # Get interactive elements with refs
agent-browser click @e1 # Click element by ref
agent-browser fill @e2 "text" # Fill input by ref
agent-browser close # Close browser
```
## Core workflow
1. Navigate: `agent-browser open <url>`
2. Snapshot: `agent-browser snapshot -i` (returns elements with refs like `@e1`, `@e2`)
3. Interact using refs from the snapshot
4. Re-snapshot after navigation or significant DOM changes
## Commands
### Navigation
```bash
agent-browser open <url> # Navigate to URL
agent-browser back # Go back
agent-browser forward # Go forward
agent-browser reload # Reload page
agent-browser close # Close browser
```
### Snapshot (page analysis)
```bash
agent-browser snapshot # Full accessibility tree
agent-browser snapshot -i # Interactive elements only (recommended)
agent-browser snapshot -c # Compact output
agent-browser snapshot -d 3 # Limit depth to 3
agent-browser snapshot -s "#main" # Scope to CSS selector
```
### Interactions (use @refs from snapshot)
```bash
agent-browser click @e1 # Click
agent-browser dblclick @e1 # Double-click
agent-browser focus @e1 # Focus element
agent-browser fill @e2 "text" # Clear and type
agent-browser type @e2 "text" # Type without clearing
agent-browser press Enter # Press key
agent-browser press Control+a # Key combination
agent-browser keydown Shift # Hold key down
agent-browser keyup Shift # Release key
agent-browser hover @e1 # Hover
agent-browser check @e1 # Check checkbox
agent-browser uncheck @e1 # Uncheck checkbox
agent-browser select @e1 "value" # Select dropdown
agent-browser scroll down 500 # Scroll page
agent-browser scrollintoview @e1 # Scroll element into view
agent-browser drag @e1 @e2 # Drag and drop
agent-browser upload @e1 file.pdf # Upload files
```
### Get information
```bash
agent-browser get text @e1 # Get element text
agent-browser get html @e1 # Get innerHTML
agent-browser get value @e1 # Get input value
agent-browser get attr @e1 href # Get attribute
agent-browser get title # Get page title
agent-browser get url # Get current URL
agent-browser get count ".item" # Count matching elements
agent-browser get box @e1 # Get bounding box
```
### Check state
```bash
agent-browser is visible @e1 # Check if visible
agent-browser is enabled @e1 # Check if enabled
agent-browser is checked @e1 # Check if checked
```
### Screenshots & PDF
```bash
agent-browser screenshot # Screenshot to stdout
agent-browser screenshot path.png # Save to file
agent-browser screenshot --full # Full page
agent-browser pdf output.pdf # Save as PDF
```
### Video recording
```bash
agent-browser record start ./demo.webm # Start recording (uses current URL + state)
agent-browser click @e1 # Perform actions
agent-browser record stop # Stop and save video
agent-browser record restart ./take2.webm # Stop current + start new recording
```
Recording creates a fresh context but preserves cookies/storage from your session. If no URL is provided, it automatically returns to your current page. For smooth demos, explore first, then start recording.
### Wait
```bash
agent-browser wait @e1 # Wait for element
agent-browser wait 2000 # Wait milliseconds
agent-browser wait --text "Success" # Wait for text
agent-browser wait --url "**/dashboard" # Wait for URL pattern
agent-browser wait --load networkidle # Wait for network idle
agent-browser wait --fn "window.ready" # Wait for JS condition
```
### Mouse control
```bash
agent-browser mouse move 100 200 # Move mouse
agent-browser mouse down left # Press button
agent-browser mouse up left # Release button
agent-browser mouse wheel 100 # Scroll wheel
```
### Semantic locators (alternative to refs)
```bash
agent-browser find role button click --name "Submit"
agent-browser find text "Sign In" click
agent-browser find label "Email" fill "user@test.com"
agent-browser find first ".item" click
agent-browser find nth 2 "a" text
```
### Browser settings
```bash
agent-browser set viewport 1920 1080 # Set viewport size
agent-browser set device "iPhone 14" # Emulate device
agent-browser set geo 37.7749 -122.4194 # Set geolocation
agent-browser set offline on # Toggle offline mode
agent-browser set headers '{"X-Key":"v"}' # Extra HTTP headers
agent-browser set credentials user pass # HTTP basic auth
agent-browser set media dark # Emulate color scheme
```
### Cookies & Storage
```bash
agent-browser cookies # Get all cookies
agent-browser cookies set name value # Set cookie
agent-browser cookies clear # Clear cookies
agent-browser storage local # Get all localStorage
agent-browser storage local key # Get specific key
agent-browser storage local set k v # Set value
agent-browser storage local clear # Clear all
```
### Network
```bash
agent-browser network route <url> # Intercept requests
agent-browser network route <url> --abort # Block requests
agent-browser network route <url> --body '{}' # Mock response
agent-browser network unroute [url] # Remove routes
agent-browser network requests # View tracked requests
agent-browser network requests --filter api # Filter requests
```
### Tabs & Windows
```bash
agent-browser tab # List tabs
agent-browser tab new [url] # New tab
agent-browser tab 2 # Switch to tab
agent-browser tab close # Close tab
agent-browser window new # New window
```
### Frames
```bash
agent-browser frame "#iframe" # Switch to iframe
agent-browser frame main # Back to main frame
```
### Dialogs
```bash
agent-browser dialog accept [text] # Accept dialog
agent-browser dialog dismiss # Dismiss dialog
```
### JavaScript
```bash
agent-browser eval "document.title" # Run JavaScript
```
## Example: Form submission
```bash
agent-browser open https://example.com/form
agent-browser snapshot -i
# Output shows: textbox "Email" [ref=e1], textbox "Password" [ref=e2], button "Submit" [ref=e3]
agent-browser fill @e1 "user@example.com"
agent-browser fill @e2 "password123"
agent-browser click @e3
agent-browser wait --load networkidle
agent-browser snapshot -i # Check result
```
## Example: Authentication with saved state
```bash
# Login once
agent-browser open https://app.example.com/login
agent-browser snapshot -i
agent-browser fill @e1 "username"
agent-browser fill @e2 "password"
agent-browser click @e3
agent-browser wait --url "**/dashboard"
agent-browser state save auth.json
# Later sessions: load saved state
agent-browser state load auth.json
agent-browser open https://app.example.com/dashboard
```
## Sessions (parallel browsers)
```bash
agent-browser --session test1 open site-a.com
agent-browser --session test2 open site-b.com
agent-browser session list
```
## JSON output (for parsing)
Add `--json` for machine-readable output:
```bash
agent-browser snapshot -i --json
agent-browser get text @e1 --json
```
## Debugging
```bash
agent-browser open example.com --headed # Show browser window
agent-browser console # View console messages
agent-browser errors # View page errors
agent-browser record start ./debug.webm # Record from current page
agent-browser record stop # Save recording
agent-browser open example.com --headed # Show browser window
agent-browser --cdp 9222 snapshot # Connect via CDP
agent-browser console # View console messages
agent-browser console --clear # Clear console
agent-browser errors # View page errors
agent-browser errors --clear # Clear errors
agent-browser highlight @e1 # Highlight element
agent-browser trace start # Start recording trace
agent-browser trace stop trace.zip # Stop and save trace
```

7
.gitignore vendored
View file

@ -32,14 +32,17 @@ Caddyfile
# Session files
sessions/
api_sessions/
e2e-screenshots/
# Archon logs (generated at runtime)
.archon/logs/
# RCA reports (generated, local only)
# Agent artifacts (generated, local only)
.agents/rca-reports/
.claude/PRPs/features
.agents/plans/
.agents/pr-reviews
.claude/PRPs/
e2e-testing-findings-session2.md
# Local workspace
workspace/

View file

@ -8,7 +8,7 @@
- No multi-tenant complexity
- Commands versioned with Git (not stored in database)
- All credentials in environment variables only
- 5-table database schema (see Database Schema section)
- 8-table database schema (see Database Schema section)
**User-Controlled Workflows**
- Manual phase transitions via slash commands
@ -41,19 +41,25 @@
### Development (Recommended)
Run postgres in Docker, app locally for hot reload:
Run app locally for hot reload (SQLite auto-detected if no `DATABASE_URL`):
```bash
# Terminal 1: Start postgres only
docker-compose --profile with-db up -d postgres
# Terminal 2: Run app with hot reload
# Start server + Web UI together (hot reload for both)
bun run dev
# Or start individually
bun run dev:server # Backend only (port 3090)
bun run dev:web # Frontend only (port 5173)
```
Requires `DATABASE_URL=postgresql://postgres:postgres@localhost:5432/remote_coding_agent` in `.env` (or omit for SQLite auto-detection).
Optional: Use PostgreSQL instead of SQLite by setting `DATABASE_URL` in `.env`:
Code changes auto-reload instantly. Telegram/Slack work from any device (polling-based, no port forwarding needed).
```bash
docker-compose --profile with-db up -d postgres
# Set DATABASE_URL=postgresql://postgres:postgres@localhost:5432/remote_coding_agent in .env
```
Code changes auto-reload instantly. Web UI available at `http://localhost:5173`. Telegram/Slack work from any device (polling-based, no port forwarding needed).
### Build Commands
@ -284,16 +290,26 @@ packages/
│ │ └── archon-paths.ts
│ ├── workflows/ # YAML workflow engine
│ └── index.ts # Package exports
└── server/ # @archon/server - HTTP server + adapters
├── server/ # @archon/server - HTTP server + adapters
│ └── src/
│ ├── adapters/ # Platform adapters (Slack, Telegram, GitHub, Discord, Web, Test)
│ │ ├── slack.ts
│ │ ├── telegram.ts
│ │ ├── github.ts
│ │ ├── discord.ts
│ │ ├── web.ts # Web UI adapter (SSE streaming)
│ │ └── test.ts
│ ├── routes/ # API routes
│ │ └── api.ts # REST + SSE endpoints for Web UI
│ ├── scripts/ # Setup utilities
│ └── index.ts # Hono server entry point
└── web/ # @archon/web - React frontend (Web UI)
└── src/
├── adapters/ # Platform adapters (Slack, Telegram, GitHub, Discord, Test)
│ ├── slack.ts
│ ├── telegram.ts
│ ├── github.ts
│ ├── discord.ts
│ └── test.ts
├── scripts/ # Setup utilities
└── index.ts # Hono server entry point
├── components/ # React components (chat, layout, projects, ui)
├── hooks/ # Custom hooks (useSSE, etc.)
├── lib/ # API client, types, utilities
├── pages/ # Route pages (ChatPage, ProjectsPage)
└── App.tsx # Router + layout
```
**Import Patterns:**
@ -323,12 +339,15 @@ import * as core from '@archon/core'; // Don't do this
### Database Schema
**5 Tables (all prefixed with `remote_agent_`):**
**8 Tables (all prefixed with `remote_agent_`):**
1. **`codebases`** - Repository metadata and commands (JSONB)
2. **`conversations`** - Track platform conversations (Slack thread, Telegram chat, GitHub issue)
2. **`conversations`** - Track platform conversations with titles and soft-delete support
3. **`sessions`** - Track AI SDK sessions with resume capability
4. **`command_templates`** - Global command templates (manually added via `/template-add`)
5. **`isolation_environments`** - Git worktree isolation tracking
6. **`workflow_runs`** - Workflow execution tracking and state
7. **`workflow_events`** - Step-level workflow event log (step transitions, artifacts, errors)
8. **`messages`** - Conversation message history with tool call metadata (JSONB)
**Key Patterns:**
- Conversation ID format: Platform-specific (`thread_ts`, `chat_id`, `user/repo#123`)
@ -347,11 +366,13 @@ import * as core from '@archon/core'; // Don't do this
**Package Split:**
- **@archon/cli**: Command-line interface for running workflows
- **@archon/core**: Business logic, database, orchestration, workflows
- **@archon/server**: Platform adapters, Hono server, HTTP endpoints
- **@archon/server**: Platform adapters, Hono server, HTTP endpoints, Web UI static serving
- **@archon/web**: React frontend (Vite + Tailwind v4 + shadcn/ui), SSE streaming to server
**1. Platform Adapters** (`packages/server/src/adapters/`)
- Implement `IPlatformAdapter` interface
- Handle platform-specific message formats
- **Web**: Server-Sent Events (SSE) streaming, conversation ID = user-provided string
- **Slack**: SDK with polling (not webhooks), conversation ID = `thread_ts`
- **Telegram**: Bot API with polling, conversation ID = `chat_id`
- **GitHub**: Webhooks + GitHub CLI, conversation ID = `owner/repo#number`
@ -677,6 +698,22 @@ try {
### API Endpoints
**Web UI REST API** (`packages/server/src/routes/api.ts`):
- `GET /api/conversations` - List all conversations
- `POST /api/conversations` - Create new conversation
- `GET /api/conversations/:id` - Get conversation details
- `DELETE /api/conversations/:id` - Soft-delete conversation
- `POST /api/conversations/:id/messages` - Send message to conversation
- `GET /api/conversations/:id/messages` - Get message history
- `GET /api/conversations/:id/stream` - SSE stream for real-time updates
- `POST /api/conversations/:id/workflow` - Invoke workflow
- `GET /api/codebases` - List registered codebases
- `POST /api/codebases` - Clone or register repository
- `DELETE /api/codebases/:id` - Remove codebase
- `GET /api/workflows` - List available workflows
- `GET /api/workflow-runs/:id` - Get workflow run details
- `GET /api/workflow-runs/:id/events` - Get workflow event log
**Webhooks:**
- `POST /webhooks/github` - GitHub webhook events
- Signature verification required (HMAC SHA-256)

View file

@ -1,12 +1,13 @@
# Dynamous Remote Coding Agent
Control AI coding assistants (Claude Code, Codex) remotely from Telegram, GitHub, and more. Built for developers who want to code from anywhere with persistent sessions and flexible workflows/systems.
Control AI coding assistants (Claude Code, Codex) remotely from a Web UI, Telegram, GitHub, and more. Built for developers who want to code from anywhere with persistent sessions and flexible workflows/systems.
**Quick Start:** [Getting Started](#getting-started) • [Server Setup](#server-quick-start) • [AI Assistant Setup](#2-ai-assistant-setup-choose-at-least-one) • [Platform Setup](#3-platform-adapter-setup-choose-at-least-one) • [Usage Guide](#usage)
## Features
- **Multi-Platform Support**: Interact via Telegram, Slack, Discord, GitHub issues/PRs, and more
- **Web UI**: Built-in React dashboard with real-time streaming, tool call visualization, and conversation management — no external platform required
- **Multi-Platform Support**: Interact via Web UI, Telegram, Slack, Discord, GitHub issues/PRs, and more
- **Multiple AI Assistants**: Choose between Claude Code or Codex (or both)
- **Persistent Sessions**: Sessions survive container restarts with full context preservation
- **Codebase Management**: Clone and work with any GitHub repository
@ -105,7 +106,7 @@ archon workflow run assist "What does this codebase do?"
**Accounts Required:**
- GitHub account (for repository cloning via `/clone` command)
- At least one of: Claude Pro/Max subscription OR Codex account
- At least one of: Telegram, Slack, Discord, or GitHub account (for server interaction)
- Optional: Telegram, Slack, Discord, or GitHub account (the built-in Web UI works without any external platform)
---
@ -139,19 +140,21 @@ bun install
# 2. Configure
cp .env.example .env
nano .env # Add your tokens
nano .env # Add your AI assistant tokens (Claude or Codex)
# 3. Start database
docker compose --profile with-db up -d postgres
# 4. Run migrations
psql $DATABASE_URL < migrations/000_combined.sql
# 5. Start with hot reload
# 3. Start server + Web UI (SQLite auto-detected, no database setup needed)
bun run dev
# 6. Validate setup
bun run validate
# 4. Open Web UI
# http://localhost:5173
```
Optional: Use PostgreSQL instead of SQLite:
```bash
docker compose --profile with-db up -d postgres
# Set DATABASE_URL=postgresql://postgres:postgres@localhost:5432/remote_coding_agent in .env
psql $DATABASE_URL < migrations/000_combined.sql
```
### Option 3: Self-Hosted Production
@ -264,12 +267,15 @@ DATABASE_URL=postgresql://user:password@host:5432/dbname
psql $DATABASE_URL < migrations/000_combined.sql
```
This creates 5 tables:
This creates 8 tables:
- `remote_agent_codebases` - Repository metadata
- `remote_agent_conversations` - Platform conversation tracking
- `remote_agent_sessions` - AI session management
- `remote_agent_command_templates` - Global command templates
- `remote_agent_isolation_environments` - Worktree isolation tracking
- `remote_agent_workflow_runs` - Workflow execution tracking
- `remote_agent_workflow_events` - Step-level workflow event log
- `remote_agent_messages` - Conversation message history
**For updates to existing installations**, run only the migrations you haven't applied yet:
@ -432,9 +438,39 @@ DEFAULT_AI_ASSISTANT=codex
---
### 3. Platform Adapter Setup (Choose At Least One)
### 3. Platform Adapter Setup (Optional)
You must configure **at least one** platform to interact with your AI assistant.
The built-in **Web UI** works out of the box with no additional configuration. Optionally, configure one or more external platforms for remote access:
<details>
<summary><b>🌐 Web UI (Built-in — No Setup Required)</b></summary>
The Web UI is available automatically when you start the server. No tokens or configuration needed.
**Development:**
```bash
bun run dev
# Web UI: http://localhost:5173
# API server: http://localhost:3090
```
**Production:**
```bash
bun run build # Build the frontend
bun run start # Server serves both API and Web UI on port 3090
```
**Features:**
- Real-time streaming of AI responses via Server-Sent Events (SSE)
- Tool call visualization with collapsible cards showing inputs/outputs
- Conversation management (create, switch, rename, delete, persist across sessions)
- Project/codebase browsing and management (clone, register, remove)
- Workflow invocation from UI with real-time progress tracking
- Lock indicator showing when the agent is working
- Connected/disconnected status indicator
- Message history persistence across page refreshes
</details>
<details>
<summary><b>💬 Telegram</b></summary>
@ -759,11 +795,13 @@ docker compose logs -f app-with-db
**Option C: Local Development (No Docker)**
Run directly with Bun (requires local PostgreSQL or remote `DATABASE_URL` in `.env`):
Run directly with Bun. Uses SQLite by default (no database setup needed), or set `DATABASE_URL` for PostgreSQL:
```bash
bun install # First time only
bun run dev
bun run dev # Starts server + Web UI with hot reload
# Web UI: http://localhost:5173
# API: http://localhost:3090
```
**Stop the application:**
@ -1168,7 +1206,8 @@ prompt: |
```
┌─────────────────────────────────────────────────────────┐
│ Platform Adapters (Telegram, Slack, Discord, GitHub) │
│ Platform Adapters (Web UI, Telegram, Slack, Discord, │
│ GitHub) │
└──────────────────────────┬──────────────────────────────┘
@ -1190,14 +1229,16 @@ prompt: |
┌─────────────────────────────────────────────────────────┐
PostgreSQL (6 Tables)
SQLite / PostgreSQL (8 Tables)
│ Codebases • Conversations • Sessions • Workflow Runs │
│ Command Templates • Isolation Environments │
│ Command Templates • Isolation Environments • Messages │
│ Workflow Events │
└─────────────────────────────────────────────────────────┘
```
### Key Design Patterns
- **Web UI**: React dashboard with SSE streaming, served by the backend in production
- **Adapter Pattern**: Platform-agnostic via `IPlatformAdapter` interface
- **Strategy Pattern**: Swappable AI assistants via `IAssistantClient` interface
- **Session Persistence**: AI context survives restarts via database storage
@ -1209,7 +1250,7 @@ prompt: |
### Database Schema
<details>
<summary><b>6 tables with `remote_agent_` prefix</b></summary>
<summary><b>8 tables with `remote_agent_` prefix</b></summary>
1. **`remote_agent_codebases`** - Repository metadata
- Commands stored as JSONB: `{command_name: {path, description}}`
@ -1237,7 +1278,17 @@ prompt: |
6. **`remote_agent_workflow_runs`** - Workflow execution tracking
- Tracks active workflows per conversation
- Prevents concurrent workflow execution
- Stores workflow state and step progress
- Stores workflow state, step progress, and parent conversation linkage
7. **`remote_agent_workflow_events`** - Step-level workflow event log
- Records step transitions, artifacts, and errors per workflow run
- Lean UI-relevant events (verbose logs stored in JSONL files)
- Enables workflow run detail views and debugging
8. **`remote_agent_messages`** - Conversation message history
- Persists user and assistant messages with timestamps
- Stores tool call metadata (name, input, duration) in JSONB
- Enables message history in Web UI across page refreshes
</details>

1248
bun.lock

File diff suppressed because it is too large Load diff

View file

@ -8,15 +8,17 @@ Comprehensive guide to understanding and extending the Remote Coding Agent platf
## System Overview
The Remote Coding Agent is a **platform-agnostic AI coding assistant orchestrator** that connects messaging platforms (Telegram, GitHub, Slack) to AI coding assistants (Claude Code, Codex) via a unified interface.
The Remote Coding Agent is a **platform-agnostic AI coding assistant orchestrator** that connects messaging platforms (Web UI, Telegram, GitHub, Slack, Discord) to AI coding assistants (Claude Code, Codex) via a unified interface. The built-in Web UI provides a complete standalone experience with real-time streaming, tool call visualization, and workflow management.
### Core Architecture
```
┌─────────────────────────────────────────────┐
│ Platform Adapters (Telegram, GitHub, CLI) │
│ Platform Adapters (Web UI, Telegram, │
│ GitHub, Slack, Discord, CLI) │
│ • IPlatformAdapter interface │
│ • Handle platform-specific messaging │
│ • Web: SSE streaming + REST API │
│ • Others: Platform-specific messaging │
└──────────────────┬──────────────────────────┘
@ -26,6 +28,7 @@ The Remote Coding Agent is a **platform-agnostic AI coding assistant orchestrato
│ • Route AI queries → Assistant Clients │
│ • Manage session lifecycle │
│ • Stream responses back to platforms │
│ • Emit workflow events to Web UI │
└──────────────┬──────────────────────────────┘
┌───────┼────────┐
@ -42,8 +45,10 @@ The Remote Coding Agent is a **platform-agnostic AI coding assistant orchestrato
└───────────────┼───────────────────┘
┌─────────────────────────────────────────────┐
│ PostgreSQL/SQLite (3 Tables) │
│ PostgreSQL/SQLite (8 Tables) │
│ • Codebases • Conversations • Sessions │
│ • Command Templates • Isolation Envs │
│ • Workflow Runs • Workflow Events • Messages│
└─────────────────────────────────────────────┘
```
@ -163,6 +168,7 @@ YOUR_PLATFORM_STREAMING_MODE=stream # stream | batch
Each platform must provide a unique, stable conversation ID:
- **Web UI**: User-provided string or auto-generated UUID
- **Telegram**: `chat_id` (e.g., `"123456789"`)
- **GitHub**: `owner/repo#issue_number` (e.g., `"user/repo#42"`)
- **Slack**: `thread_ts` or `channel_id+thread_ts`
@ -190,6 +196,40 @@ async sendMessage(conversationId: string, message: string): Promise<void> {
**Reference:** `packages/server/src/adapters/telegram.ts`
#### Server-Sent Events (SSE)
**SSE** (Web UI pattern):
```typescript
// Web adapter maintains SSE connections per conversation
registerStream(conversationId: string, stream: SSEWriter): void {
this.streams.set(conversationId, stream);
}
async sendMessage(conversationId: string, message: string): Promise<void> {
const stream = this.streams.get(conversationId);
if (stream && !stream.closed) {
await stream.writeSSE({ data: JSON.stringify({ type: 'text', content: message }) });
} else {
// Buffer messages if client disconnected (reconnection recovery)
this.messageBuffer.set(conversationId, [...(this.messageBuffer.get(conversationId) ?? []), message]);
}
}
// Structured events for tool calls, workflow progress, errors
async sendStructuredEvent(conversationId: string, event: MessageChunk): Promise<void> {
await this.emitSSE(conversationId, JSON.stringify(event));
}
```
**Benefits:**
- Real-time streaming without polling overhead
- Automatic reconnection handling in browser
- Message buffering during disconnections
- Structured events (tool calls, workflow progress, lock state)
**Reference:** `packages/server/src/adapters/web.ts`
#### Polling vs Webhooks
**Polling** (Telegram pattern):
@ -965,7 +1005,7 @@ export function formatToolCall(toolName: string, toolInput?: Record<string, unkn
## Database Schema
The platform uses a minimal 3-table schema with `remote_agent_` prefix.
The platform uses an 8-table schema with `remote_agent_` prefix.
### Schema Overview
@ -980,11 +1020,13 @@ remote_agent_codebases
remote_agent_conversations
├── id (UUID)
├── platform_type (VARCHAR) -- 'telegram' | 'github' | 'slack'
├── platform_type (VARCHAR) -- 'web' | 'telegram' | 'github' | 'slack'
├── platform_conversation_id (VARCHAR) -- Platform-specific ID
├── codebase_id (UUID → remote_agent_codebases.id)
├── cwd (VARCHAR) -- Current working directory
├── ai_assistant_type (VARCHAR) -- LOCKED at creation
├── title (VARCHAR) -- User-friendly conversation title (Web UI)
├── deleted_at (TIMESTAMP) -- Soft-delete support
└── UNIQUE(platform_type, platform_conversation_id)
remote_agent_sessions
@ -997,6 +1039,49 @@ remote_agent_sessions
├── parent_session_id (UUID → remote_agent_sessions.id) -- Previous session for audit trail
├── transition_reason (TEXT) -- Why this session was created (TransitionTrigger)
└── metadata (JSONB) -- {lastCommand: "plan-feature", ...}
remote_agent_command_templates
├── id (UUID)
├── name (VARCHAR, UNIQUE)
├── description (TEXT)
└── content (TEXT)
remote_agent_isolation_environments
├── id (UUID)
├── codebase_id (UUID → remote_agent_codebases.id)
├── workflow_type (VARCHAR)
├── workflow_id (VARCHAR)
├── working_path (VARCHAR)
├── branch_name (VARCHAR)
├── status (VARCHAR) -- 'active' | 'destroyed'
└── metadata (JSONB)
remote_agent_workflow_runs
├── id (UUID)
├── conversation_id (UUID → remote_agent_conversations.id)
├── codebase_id (UUID → remote_agent_codebases.id)
├── workflow_name (VARCHAR)
├── status (VARCHAR) -- 'pending' | 'running' | 'completed' | 'failed'
├── current_step_index (INTEGER)
├── parent_conversation_id (UUID) -- Parent chat that dispatched this run
└── metadata (JSONB)
remote_agent_workflow_events
├── id (UUID)
├── workflow_run_id (UUID → remote_agent_workflow_runs.id)
├── event_type (VARCHAR) -- 'step-start' | 'step-complete' | 'step-fail' | 'artifact' | 'error'
├── step_index (INTEGER)
├── step_name (VARCHAR)
├── data (JSONB) -- Event-specific data (artifacts, error messages, etc.)
└── created_at (TIMESTAMP)
remote_agent_messages
├── id (UUID)
├── conversation_id (UUID → remote_agent_conversations.id)
├── role (VARCHAR) -- 'user' | 'assistant'
├── content (TEXT)
├── metadata (JSONB) -- {toolCalls: [{name, input, duration}], workflowDispatch, ...}
└── created_at (TIMESTAMP)
```
### Database Operations

90
docs/e2e-testing-wsl.md Normal file
View file

@ -0,0 +1,90 @@
# E2E Testing with agent-browser on Windows (via WSL)
`agent-browser` (Vercel) has a [known Windows bug](https://github.com/vercel-labs/agent-browser/issues/56) where the daemon fails to start due to Unix domain socket incompatibility. The workaround is to run agent-browser inside WSL while the dev servers run on Windows.
## Prerequisites
- WSL2 with Ubuntu installed (`wsl --list --verbose`)
- agent-browser installed in WSL: `npm install -g agent-browser`
- Playwright chromium installed: `agent-browser install --with-deps` (needs sudo)
## Setup
### 1. Find the Windows host IP accessible from WSL
```bash
ipconfig | findstr "IPv4" | findstr "WSL"
# Example output: IPv4 Address. . . . . . . . . . . : 172.18.64.1
```
Or from inside WSL:
```bash
wsl -d Ubuntu -- bash -c "cat /etc/resolv.conf | grep nameserver"
```
The Windows host IP for this system is `172.18.64.1`.
### 2. Start dev servers on Windows (bound to all interfaces)
```bash
# Backend (Hono on port 3090) - already binds to 0.0.0.0 by default
bun run dev:server &
# Frontend (Vite on port 5173) - needs --host flag
cd packages/web && bun x vite --host 0.0.0.0 &
```
### 3. Verify WSL can reach the servers
```bash
wsl -d Ubuntu -- curl -s http://172.18.64.1:3090/api/health
wsl -d Ubuntu -- curl -s -o /dev/null -w "%{http_code}" http://172.18.64.1:5173
```
## Running agent-browser Commands
All commands are run from the Windows terminal, prefixed with `wsl -d Ubuntu --`:
```bash
# Open a page
wsl -d Ubuntu -- agent-browser open http://172.18.64.1:5173
# Take interactive snapshot (get element refs like @e1, @e2)
wsl -d Ubuntu -- agent-browser snapshot -i
# Click, fill, press
wsl -d Ubuntu -- agent-browser click @e1
wsl -d Ubuntu -- agent-browser fill @e2 "some text"
wsl -d Ubuntu -- agent-browser press Enter
# Wait for content to load
wsl -d Ubuntu -- agent-browser wait 3000
# Reload page (hard refresh)
wsl -d Ubuntu -- agent-browser reload
# Close browser
wsl -d Ubuntu -- agent-browser close
```
## Taking Screenshots
Screenshots must be saved to a WSL-native path first, then copied to the Windows filesystem via the `/mnt/c/` mount:
```bash
# Save to WSL home, then copy to project
wsl -d Ubuntu -- bash -c '
agent-browser screenshot /home/coleam/screenshot.png 2>&1 &&
cp /home/coleam/screenshot.png /mnt/c/Users/colem/dynamous-community/remote-coding-agent/e2e-screenshots/my-test.png
'
```
**Why not save directly to `/mnt/c/...`?** agent-browser resolves paths through its Node.js process, which on some setups mangles `/mnt/c/` paths (e.g., prepending `C:/Program Files/Git/`). Saving to a WSL-native path and copying avoids this.
## Gotchas
- **`localhost` doesn't work from WSL2** - must use the Windows host IP (`172.18.64.1`)
- **Vite must bind to `0.0.0.0`** - default `localhost` isn't reachable from WSL
- **Git Bash path expansion** - `/status` gets expanded to `C:/Program Files/Git/status` when passed through Git Bash. Not an agent-browser issue; it's the shell expanding `/` paths
- **SSE `Connected` indicator** - only shows for `web` platform conversations; Telegram/Slack conversations show `Disconnected` (expected)
- **Daemon startup** - if `agent-browser open` fails with "Daemon failed to start", kill stale daemons: `wsl -d Ubuntu -- pkill -f daemon.js` and retry

View file

@ -18,6 +18,10 @@ export default tseslint.config(
'*.mjs',
'**/*.test.ts',
'*.d.ts', // Root-level declaration files (not in tsconfig project scope)
'packages/web/vite.config.ts', // Vite config doesn't need type-checked linting
'packages/web/components.json',
'packages/web/src/components/ui/**', // shadcn/ui auto-generated components
'packages/web/src/lib/utils.ts', // shadcn/ui utility file
],
},
@ -32,7 +36,7 @@ export default tseslint.config(
// Project-specific settings
{
files: ['packages/*/src/**/*.ts'],
files: ['packages/*/src/**/*.{ts,tsx}'],
languageOptions: {
parserOptions: {
projectService: true,
@ -61,7 +65,7 @@ export default tseslint.config(
custom: { regex: '^I?[A-Z]', match: true },
},
{ selector: 'typeAlias', format: ['PascalCase'] },
{ selector: 'function', format: ['camelCase'] },
{ selector: 'function', format: ['camelCase', 'PascalCase'] },
{ selector: 'variable', format: ['camelCase', 'UPPER_CASE'] },
],
'@typescript-eslint/no-non-null-assertion': 'error',

View file

@ -1,5 +1,5 @@
-- Remote Coding Agent - Combined Schema
-- Version: Combined (includes migrations 001-008)
-- Version: Combined (includes migrations 001-015)
-- Description: Complete database schema (idempotent - safe to run multiple times)
-- ============================================================================
@ -26,6 +26,9 @@ CREATE TABLE IF NOT EXISTS remote_agent_conversations (
codebase_id UUID REFERENCES remote_agent_codebases(id),
cwd VARCHAR(500),
ai_assistant_type VARCHAR(20) DEFAULT 'claude',
title VARCHAR(255),
deleted_at TIMESTAMP WITH TIME ZONE,
hidden BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(platform_type, platform_conversation_id)
@ -42,6 +45,8 @@ CREATE TABLE IF NOT EXISTS remote_agent_sessions (
assistant_session_id VARCHAR(255),
active BOOLEAN DEFAULT true,
metadata JSONB DEFAULT '{}'::jsonb,
parent_session_id UUID REFERENCES remote_agent_sessions(id),
transition_reason TEXT,
started_at TIMESTAMP DEFAULT NOW(),
ended_at TIMESTAMP
);
@ -167,3 +172,113 @@ CREATE INDEX IF NOT EXISTS idx_workflow_runs_status
COMMENT ON TABLE remote_agent_workflow_runs IS
'Tracks workflow execution state for resumption and observability';
-- ============================================================================
-- Migration 009: Workflow Last Activity
-- ============================================================================
ALTER TABLE remote_agent_workflow_runs
ADD COLUMN IF NOT EXISTS last_activity_at TIMESTAMP WITH TIME ZONE DEFAULT NOW();
-- Partial index for efficient staleness queries on running workflows
CREATE INDEX IF NOT EXISTS idx_workflow_runs_last_activity
ON remote_agent_workflow_runs(last_activity_at)
WHERE status = 'running';
-- ============================================================================
-- Migration 010: Immutable Sessions (parent linkage + transition tracking)
-- ============================================================================
ALTER TABLE remote_agent_sessions
ADD COLUMN IF NOT EXISTS parent_session_id UUID REFERENCES remote_agent_sessions(id);
ALTER TABLE remote_agent_sessions
ADD COLUMN IF NOT EXISTS transition_reason TEXT;
CREATE INDEX IF NOT EXISTS idx_sessions_parent ON remote_agent_sessions(parent_session_id);
CREATE INDEX IF NOT EXISTS idx_sessions_conversation_started
ON remote_agent_sessions(conversation_id, started_at DESC);
COMMENT ON COLUMN remote_agent_sessions.parent_session_id IS
'Links to the previous session in this conversation (for audit trail)';
COMMENT ON COLUMN remote_agent_sessions.transition_reason IS
'Why this session was created: plan-to-execute, isolation-changed, reset-requested, etc.';
-- ============================================================================
-- Migration 011: Partial Unique Constraint Fix
-- ============================================================================
-- Drop the existing full constraint (if it exists from older migrations)
ALTER TABLE remote_agent_isolation_environments
DROP CONSTRAINT IF EXISTS unique_workflow;
-- Partial unique index already created in Migration 006 above (unique_active_workflow)
-- ============================================================================
-- Migration 012: Workflow Events
-- ============================================================================
CREATE TABLE IF NOT EXISTS remote_agent_workflow_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workflow_run_id UUID NOT NULL REFERENCES remote_agent_workflow_runs(id) ON DELETE CASCADE,
event_type VARCHAR(50) NOT NULL,
step_index INTEGER,
step_name VARCHAR(255),
data JSONB DEFAULT '{}',
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_workflow_events_run_id
ON remote_agent_workflow_events(workflow_run_id);
CREATE INDEX IF NOT EXISTS idx_workflow_events_type
ON remote_agent_workflow_events(event_type);
COMMENT ON TABLE remote_agent_workflow_events IS
'Lean UI-relevant workflow events for observability (step transitions, artifacts, errors)';
-- ============================================================================
-- Migration 013: Conversation Titles + Soft Delete
-- ============================================================================
-- title and deleted_at already included in conversations CREATE TABLE above.
-- ALTER statements kept for idempotent upgrades from older schemas:
ALTER TABLE remote_agent_conversations
ADD COLUMN IF NOT EXISTS title VARCHAR(255);
ALTER TABLE remote_agent_conversations
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMP WITH TIME ZONE;
-- ============================================================================
-- Migration 014: Message History
-- ============================================================================
CREATE TABLE IF NOT EXISTS remote_agent_messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
conversation_id UUID NOT NULL REFERENCES remote_agent_conversations(id) ON DELETE CASCADE,
role VARCHAR(20) NOT NULL,
content TEXT NOT NULL DEFAULT '',
metadata JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_messages_conversation_id
ON remote_agent_messages(conversation_id, created_at ASC);
-- ============================================================================
-- Migration 015: Background Dispatch
-- ============================================================================
ALTER TABLE remote_agent_workflow_runs
ADD COLUMN IF NOT EXISTS parent_conversation_id UUID
REFERENCES remote_agent_conversations(id) ON DELETE SET NULL;
ALTER TABLE remote_agent_conversations
ADD COLUMN IF NOT EXISTS hidden BOOLEAN DEFAULT FALSE;
CREATE INDEX IF NOT EXISTS idx_workflow_runs_parent_conv
ON remote_agent_workflow_runs(parent_conversation_id);
CREATE INDEX IF NOT EXISTS idx_conversations_hidden
ON remote_agent_conversations(hidden);
CREATE INDEX IF NOT EXISTS idx_conversations_codebase
ON remote_agent_conversations(codebase_id) WHERE deleted_at IS NULL;

View file

@ -0,0 +1,21 @@
-- Workflow events - lean UI-relevant events for observability
-- Stores step transitions, parallel agent status, artifacts, errors.
-- Verbose assistant/tool content stays in JSONL logs at {cwd}/.archon/logs/{runId}.jsonl
CREATE TABLE IF NOT EXISTS remote_agent_workflow_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workflow_run_id UUID NOT NULL REFERENCES remote_agent_workflow_runs(id) ON DELETE CASCADE,
event_type VARCHAR(50) NOT NULL,
step_index INTEGER,
step_name VARCHAR(255),
data JSONB DEFAULT '{}',
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_workflow_events_run_id
ON remote_agent_workflow_events(workflow_run_id);
CREATE INDEX IF NOT EXISTS idx_workflow_events_type
ON remote_agent_workflow_events(event_type);
COMMENT ON TABLE remote_agent_workflow_events IS
'Lean UI-relevant workflow events for observability (step transitions, artifacts, errors)';

View file

@ -0,0 +1,6 @@
-- Add title and soft-delete support to conversations
ALTER TABLE remote_agent_conversations
ADD COLUMN IF NOT EXISTS title VARCHAR(255);
ALTER TABLE remote_agent_conversations
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMP WITH TIME ZONE;

View file

@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS remote_agent_messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
conversation_id UUID NOT NULL REFERENCES remote_agent_conversations(id) ON DELETE CASCADE,
role VARCHAR(20) NOT NULL,
content TEXT NOT NULL DEFAULT '',
metadata JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_messages_conversation_id
ON remote_agent_messages(conversation_id, created_at ASC);

View file

@ -0,0 +1,16 @@
-- Parent conversation link for background workflow runs
ALTER TABLE remote_agent_workflow_runs
ADD COLUMN IF NOT EXISTS parent_conversation_id UUID
REFERENCES remote_agent_conversations(id) ON DELETE SET NULL;
-- Hide worker conversations from sidebar
ALTER TABLE remote_agent_conversations
ADD COLUMN IF NOT EXISTS hidden BOOLEAN DEFAULT FALSE;
-- Index for filtering
CREATE INDEX IF NOT EXISTS idx_workflow_runs_parent_conv
ON remote_agent_workflow_runs(parent_conversation_id);
CREATE INDEX IF NOT EXISTS idx_conversations_hidden
ON remote_agent_conversations(hidden);
CREATE INDEX IF NOT EXISTS idx_conversations_codebase
ON remote_agent_conversations(codebase_id) WHERE deleted_at IS NULL;

View file

@ -8,7 +8,8 @@
"type": "module",
"scripts": {
"cli": "bun --env-file=.env --cwd packages/cli src/cli.ts",
"dev": "bun --filter @archon/server dev",
"dev": "bun --filter '*' dev",
"dev:server": "bun --filter @archon/server dev",
"start": "bun --filter @archon/server start",
"build": "bun --filter '*' build",
"build:binaries": "bash scripts/build-binaries.sh",
@ -20,6 +21,8 @@
"lint:fix": "bun x eslint . --cache --fix",
"format": "bun x prettier --write .",
"format:check": "bun x prettier --check .",
"dev:web": "bun --filter @archon/web dev",
"build:web": "bun --filter @archon/web build",
"validate": "bun run type-check && bun run lint --max-warnings 0 && bun run format:check && bun run test",
"prepare": "husky",
"setup-auth": "bun --filter @archon/server setup-auth"

View file

@ -99,18 +99,56 @@ export class SqliteAdapter implements IDatabase {
}
/**
* Initialize database schema
* Initialize database schema.
* Always runs createSchema() since all statements use IF NOT EXISTS,
* ensuring new tables from migrations are created in existing databases.
*/
private initSchema(): void {
const schemaExists = this.db
.prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND name='remote_agent_codebases'"
)
.get();
this.createSchema();
this.migrateColumns();
}
if (!schemaExists) {
console.log('[SQLite] Initializing database schema...');
this.createSchema();
/**
* Add columns to existing tables that predate newer schema additions.
* SQLite's CREATE TABLE IF NOT EXISTS skips entirely for existing tables,
* so new columns must be added via ALTER TABLE for databases created before
* the columns were added to createSchema().
*/
private migrateColumns(): void {
// Conversations columns
try {
const cols = this.db.prepare("PRAGMA table_info('remote_agent_conversations')").all() as {
name: string;
}[];
const colNames = new Set(cols.map(c => c.name));
if (!colNames.has('title')) {
this.db.run('ALTER TABLE remote_agent_conversations ADD COLUMN title TEXT');
}
if (!colNames.has('deleted_at')) {
this.db.run('ALTER TABLE remote_agent_conversations ADD COLUMN deleted_at TEXT');
}
if (!colNames.has('hidden')) {
this.db.run('ALTER TABLE remote_agent_conversations ADD COLUMN hidden INTEGER DEFAULT 0');
}
} catch (e: unknown) {
console.warn('[SQLite] Migration for conversations columns failed:', (e as Error).message);
}
// Workflow runs columns
try {
const wfCols = this.db.prepare("PRAGMA table_info('remote_agent_workflow_runs')").all() as {
name: string;
}[];
const wfColNames = new Set(wfCols.map(c => c.name));
if (!wfColNames.has('parent_conversation_id')) {
this.db.run(
'ALTER TABLE remote_agent_workflow_runs ADD COLUMN parent_conversation_id TEXT'
);
}
} catch (e: unknown) {
console.warn('[SQLite] Migration for workflow_runs columns failed:', (e as Error).message);
}
}
@ -141,6 +179,9 @@ export class SqliteAdapter implements IDatabase {
codebase_id TEXT REFERENCES remote_agent_codebases(id) ON DELETE SET NULL,
cwd TEXT,
isolation_env_id TEXT,
title TEXT,
deleted_at TEXT,
hidden INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
last_activity_at TEXT DEFAULT (datetime('now')),
@ -204,11 +245,33 @@ export class SqliteAdapter implements IDatabase {
status TEXT NOT NULL DEFAULT 'pending',
current_step_index INTEGER,
metadata TEXT DEFAULT '{}',
parent_conversation_id TEXT REFERENCES remote_agent_conversations(id) ON DELETE SET NULL,
started_at TEXT DEFAULT (datetime('now')),
completed_at TEXT,
last_activity_at TEXT DEFAULT (datetime('now'))
);
-- Workflow events table
CREATE TABLE IF NOT EXISTS remote_agent_workflow_events (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
workflow_run_id TEXT NOT NULL REFERENCES remote_agent_workflow_runs(id) ON DELETE CASCADE,
event_type TEXT NOT NULL,
step_index INTEGER,
step_name TEXT,
data TEXT DEFAULT '{}',
created_at TEXT DEFAULT (datetime('now'))
);
-- Messages table (conversation history for Web UI)
CREATE TABLE IF NOT EXISTS remote_agent_messages (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
conversation_id TEXT NOT NULL REFERENCES remote_agent_conversations(id) ON DELETE CASCADE,
role TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
metadata TEXT DEFAULT '{}',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_conversations_platform ON remote_agent_conversations(platform_type, platform_conversation_id);
CREATE INDEX IF NOT EXISTS idx_sessions_conversation ON remote_agent_sessions(conversation_id);
@ -217,6 +280,22 @@ export class SqliteAdapter implements IDatabase {
CREATE INDEX IF NOT EXISTS idx_isolation_workflow ON remote_agent_isolation_environments(workflow_type, workflow_id);
CREATE INDEX IF NOT EXISTS idx_workflow_runs_conversation ON remote_agent_workflow_runs(conversation_id);
CREATE INDEX IF NOT EXISTS idx_workflow_runs_status ON remote_agent_workflow_runs(status);
CREATE INDEX IF NOT EXISTS idx_workflow_events_run_id ON remote_agent_workflow_events(workflow_run_id);
CREATE INDEX IF NOT EXISTS idx_workflow_events_type ON remote_agent_workflow_events(event_type);
CREATE INDEX IF NOT EXISTS idx_messages_conversation_id ON remote_agent_messages(conversation_id, created_at ASC);
CREATE INDEX IF NOT EXISTS idx_workflow_runs_parent_conv ON remote_agent_workflow_runs(parent_conversation_id);
CREATE INDEX IF NOT EXISTS idx_conversations_hidden ON remote_agent_conversations(hidden);
CREATE INDEX IF NOT EXISTS idx_conversations_codebase ON remote_agent_conversations(codebase_id);
-- From PG migration 009: staleness detection for running workflows
CREATE INDEX IF NOT EXISTS idx_workflow_runs_last_activity
ON remote_agent_workflow_runs(last_activity_at) WHERE status = 'running';
-- From PG migration 010: session audit trail
CREATE INDEX IF NOT EXISTS idx_sessions_parent
ON remote_agent_sessions(parent_session_id);
CREATE INDEX IF NOT EXISTS idx_sessions_conversation_started
ON remote_agent_sessions(conversation_id, started_at DESC);
`);
console.log('[SQLite] Schema initialized successfully');
}

View file

@ -96,6 +96,13 @@ export async function updateCodebase(id: string, data: { default_cwd?: string })
);
}
export async function listCodebases(): Promise<readonly Codebase[]> {
const result = await pool.query<Codebase>(
'SELECT * FROM remote_agent_codebases ORDER BY name ASC'
);
return result.rows;
}
export async function deleteCodebase(id: string): Promise<void> {
// First, unlink any sessions referencing this codebase (FK has no cascade)
await pool.query('UPDATE remote_agent_sessions SET codebase_id = NULL WHERE codebase_id = $1', [

View file

@ -5,6 +5,17 @@ import { pool, getDialect } from './connection';
import type { Conversation } from '../types';
import { ConversationNotFoundError } from '../types';
/**
* Get a conversation by its database ID
*/
export async function getConversationById(id: string): Promise<Conversation | null> {
const result = await pool.query<Conversation>(
'SELECT * FROM remote_agent_conversations WHERE id = $1',
[id]
);
return result.rows[0] ?? null;
}
/**
* Get a conversation by platform type and platform ID
* Returns null if not found (unlike getOrCreate which creates)
@ -79,10 +90,12 @@ export async function getOrCreateConversation(
export async function updateConversation(
id: string,
updates: Partial<Pick<Conversation, 'codebase_id' | 'cwd' | 'isolation_env_id'>>
updates: Partial<Pick<Conversation, 'codebase_id' | 'cwd' | 'isolation_env_id'>> & {
hidden?: boolean;
}
): Promise<void> {
const fields: string[] = [];
const values: (string | null)[] = [];
const values: (string | number | null)[] = [];
let i = 1;
if (updates.codebase_id !== undefined) {
@ -97,6 +110,10 @@ export async function updateConversation(
fields.push(`isolation_env_id = $${String(i++)}`);
values.push(updates.isolation_env_id);
}
if (updates.hidden !== undefined) {
fields.push(`hidden = $${String(i++)}`);
values.push(updates.hidden ? 1 : 0);
}
if (fields.length === 0) {
return; // No updates
@ -145,6 +162,36 @@ export async function getConversationsByIsolationEnvId(
return result.rows;
}
/**
* List all conversations ordered by recent activity
*/
export async function listConversations(
limit = 50,
platformType?: string,
codebaseId?: string
): Promise<readonly Conversation[]> {
const params: unknown[] = [];
let sql =
'SELECT * FROM remote_agent_conversations WHERE deleted_at IS NULL AND (hidden IS NULL OR hidden = false)';
if (platformType) {
params.push(platformType);
sql += ` AND platform_type = $${String(params.length)}`;
}
if (codebaseId) {
params.push(codebaseId);
sql += ` AND codebase_id = $${String(params.length)}`;
}
sql += ' ORDER BY last_activity_at DESC NULLS LAST';
params.push(limit);
sql += ` LIMIT $${String(params.length)}`;
const result = await pool.query<Conversation>(sql, params);
return result.rows;
}
/**
* Update last_activity_at for staleness tracking
*/
@ -155,3 +202,31 @@ export async function touchConversation(id: string): Promise<void> {
[id]
);
}
/**
* Update conversation title
*/
export async function updateConversationTitle(id: string, title: string): Promise<void> {
const dialect = getDialect();
const result = await pool.query(
`UPDATE remote_agent_conversations SET title = $1, updated_at = ${dialect.now()} WHERE id = $2`,
[title, id]
);
if (result.rowCount === 0) {
throw new ConversationNotFoundError(id);
}
}
/**
* Soft delete a conversation (sets deleted_at timestamp)
*/
export async function softDeleteConversation(id: string): Promise<void> {
const dialect = getDialect();
const result = await pool.query(
`UPDATE remote_agent_conversations SET deleted_at = ${dialect.now()}, updated_at = ${dialect.now()} WHERE id = $1`,
[id]
);
if (result.rowCount === 0) {
throw new ConversationNotFoundError(id);
}
}

View file

@ -0,0 +1,124 @@
import { mock, describe, test, expect, beforeEach } from 'bun:test';
import { createQueryResult, mockPostgresDialect } from '../test/mocks/database';
import type { MessageRow } from './messages';
const mockQuery = mock(() => Promise.resolve(createQueryResult([])));
// Mock the connection module before importing the module under test
mock.module('./connection', () => ({
pool: {
query: mockQuery,
},
getDialect: () => mockPostgresDialect,
}));
import { addMessage, listMessages } from './messages';
describe('messages', () => {
beforeEach(() => {
mockQuery.mockClear();
});
const mockMessage: MessageRow = {
id: 'msg-123',
conversation_id: 'conv-456',
role: 'user',
content: 'Hello, world!',
metadata: '{}',
created_at: '2025-01-01T00:00:00.000Z',
};
describe('addMessage', () => {
test('calls pool.query with correct SQL and parameters', async () => {
mockQuery.mockResolvedValueOnce(createQueryResult([mockMessage]));
const result = await addMessage('conv-456', 'user', 'Hello, world!');
expect(result).toEqual(mockMessage);
expect(mockQuery).toHaveBeenCalledWith(
`INSERT INTO remote_agent_messages (conversation_id, role, content, metadata, created_at)
VALUES ($1, $2, $3, $4, NOW())
RETURNING *`,
['conv-456', 'user', 'Hello, world!', '{}']
);
});
test('includes metadata as JSON string when provided', async () => {
const messageWithMetadata: MessageRow = {
...mockMessage,
metadata: '{"toolCalls":[{"name":"read"}],"error":null}',
};
mockQuery.mockResolvedValueOnce(createQueryResult([messageWithMetadata]));
const metadata = { toolCalls: [{ name: 'read' }], error: null };
const result = await addMessage('conv-456', 'assistant', 'Done.', metadata);
expect(result).toEqual(messageWithMetadata);
expect(mockQuery).toHaveBeenCalledWith(expect.any(String), [
'conv-456',
'assistant',
'Done.',
JSON.stringify(metadata),
]);
});
test('defaults metadata to empty object when not provided', async () => {
mockQuery.mockResolvedValueOnce(createQueryResult([mockMessage]));
await addMessage('conv-456', 'user', 'Hello, world!');
expect(mockQuery).toHaveBeenCalledWith(expect.any(String), expect.arrayContaining(['{}']));
});
test('throws wrapped error when INSERT returns no rows', async () => {
mockQuery.mockResolvedValueOnce(createQueryResult([]));
await expect(addMessage('conv-456', 'user', 'Hello')).rejects.toThrow(
'Failed to persist message: INSERT returned no rows (conversation: conv-456)'
);
});
test('propagates query errors', async () => {
mockQuery.mockRejectedValueOnce(new Error('connection refused'));
await expect(addMessage('conv-456', 'user', 'Hello')).rejects.toThrow('connection refused');
});
});
describe('listMessages', () => {
test('returns rows from query result', async () => {
const messages: MessageRow[] = [
mockMessage,
{ ...mockMessage, id: 'msg-124', role: 'assistant', content: 'Hi!' },
];
mockQuery.mockResolvedValueOnce(createQueryResult(messages));
const result = await listMessages('conv-456');
expect(result).toEqual(messages);
expect(mockQuery).toHaveBeenCalledWith(
`SELECT * FROM remote_agent_messages
WHERE conversation_id = $1
ORDER BY created_at ASC
LIMIT $2`,
['conv-456', 200]
);
});
test('returns empty array for no results', async () => {
mockQuery.mockResolvedValueOnce(createQueryResult([]));
const result = await listMessages('conv-456');
expect(result).toEqual([]);
});
test('respects custom limit parameter', async () => {
mockQuery.mockResolvedValueOnce(createQueryResult([]));
await listMessages('conv-456', 50);
expect(mockQuery).toHaveBeenCalledWith(expect.any(String), ['conv-456', 50]);
});
});
});

View file

@ -0,0 +1,57 @@
/**
* Database operations for conversation messages (Web UI history)
*/
import { pool, getDialect } from './connection';
export interface MessageRow {
id: string;
conversation_id: string;
role: 'user' | 'assistant';
content: string;
metadata: string; // JSON string - parsed by frontend
created_at: string;
}
/**
* Add a message to conversation history.
* metadata should contain toolCalls array and/or error object if applicable.
*/
export async function addMessage(
conversationId: string,
role: 'user' | 'assistant',
content: string,
metadata?: Record<string, unknown>
): Promise<MessageRow> {
const dialect = getDialect();
const result = await pool.query<MessageRow>(
`INSERT INTO remote_agent_messages (conversation_id, role, content, metadata, created_at)
VALUES ($1, $2, $3, $4, ${dialect.now()})
RETURNING *`,
[conversationId, role, content, JSON.stringify(metadata ?? {})]
);
const row = result.rows[0];
if (!row) {
throw new Error(
`Failed to persist message: INSERT returned no rows (conversation: ${conversationId})`
);
}
return row;
}
/**
* List messages for a conversation, oldest first.
* conversationId is the database UUID (not platform_conversation_id).
*/
export async function listMessages(
conversationId: string,
limit = 200
): Promise<readonly MessageRow[]> {
const result = await pool.query<MessageRow>(
`SELECT * FROM remote_agent_messages
WHERE conversation_id = $1
ORDER BY created_at ASC
LIMIT $2`,
[conversationId, limit]
);
return result.rows;
}

View file

@ -0,0 +1,191 @@
import { mock, describe, test, expect, beforeEach, spyOn } from 'bun:test';
import { createQueryResult, mockPostgresDialect } from '../test/mocks/database';
import type { WorkflowEventRow } from './workflow-events';
const mockQuery = mock(() => Promise.resolve(createQueryResult([])));
// Mock the connection module before importing the module under test
mock.module('./connection', () => ({
pool: {
query: mockQuery,
},
getDialect: () => mockPostgresDialect,
}));
import { createWorkflowEvent, listWorkflowEvents, listRecentEvents } from './workflow-events';
describe('workflow-events', () => {
beforeEach(() => {
mockQuery.mockClear();
});
const mockEvent: WorkflowEventRow = {
id: 'evt-123',
workflow_run_id: 'run-456',
event_type: 'step_started',
step_index: 0,
step_name: 'plan',
data: {},
created_at: '2025-01-01T00:00:00.000Z',
};
describe('createWorkflowEvent', () => {
test('calls pool.query with correct SQL and parameters', async () => {
mockQuery.mockResolvedValueOnce(createQueryResult([]));
await createWorkflowEvent({
workflow_run_id: 'run-456',
event_type: 'step_started',
step_index: 0,
step_name: 'plan',
data: { duration: 100 },
});
expect(mockQuery).toHaveBeenCalledWith(
`INSERT INTO remote_agent_workflow_events (id, workflow_run_id, event_type, step_index, step_name, data)
VALUES ($1, $2, $3, $4, $5, $6)`,
[
expect.any(String), // generated UUID
'run-456',
'step_started',
0,
'plan',
JSON.stringify({ duration: 100 }),
]
);
});
test('defaults optional fields to null and empty data', async () => {
mockQuery.mockResolvedValueOnce(createQueryResult([]));
await createWorkflowEvent({
workflow_run_id: 'run-456',
event_type: 'workflow_started',
});
expect(mockQuery).toHaveBeenCalledWith(expect.any(String), [
expect.any(String),
'run-456',
'workflow_started',
null,
null,
'{}',
]);
});
test('does NOT throw when query fails (fire-and-forget)', async () => {
const consoleSpy = spyOn(console, 'error').mockImplementation(() => {});
mockQuery.mockRejectedValueOnce(new Error('connection refused'));
// Should NOT throw
await createWorkflowEvent({
workflow_run_id: 'run-456',
event_type: 'step_started',
});
expect(consoleSpy).toHaveBeenCalledWith(
'[DB:WorkflowEvents] Failed to create event (non-critical):',
expect.objectContaining({
error: 'connection refused',
eventType: 'step_started',
runId: 'run-456',
})
);
consoleSpy.mockRestore();
});
});
describe('listWorkflowEvents', () => {
test('returns rows from query result', async () => {
const events: WorkflowEventRow[] = [
mockEvent,
{ ...mockEvent, id: 'evt-124', event_type: 'step_completed', step_index: 1 },
];
mockQuery.mockResolvedValueOnce(createQueryResult(events));
const result = await listWorkflowEvents('run-456');
expect(result).toEqual(events);
expect(mockQuery).toHaveBeenCalledWith(
`SELECT * FROM remote_agent_workflow_events
WHERE workflow_run_id = $1
ORDER BY created_at ASC`,
['run-456']
);
});
test('returns empty array for no results', async () => {
mockQuery.mockResolvedValueOnce(createQueryResult([]));
const result = await listWorkflowEvents('run-456');
expect(result).toEqual([]);
});
test('throws wrapped error when query fails', async () => {
const consoleSpy = spyOn(console, 'error').mockImplementation(() => {});
mockQuery.mockRejectedValueOnce(new Error('timeout'));
await expect(listWorkflowEvents('run-456')).rejects.toThrow(
'Failed to list workflow events: timeout'
);
consoleSpy.mockRestore();
});
});
describe('listRecentEvents', () => {
test('returns events filtered by since parameter', async () => {
const events: WorkflowEventRow[] = [mockEvent];
mockQuery.mockResolvedValueOnce(createQueryResult(events));
const since = new Date('2025-01-01T00:00:00.000Z');
const result = await listRecentEvents('run-456', since);
expect(result).toEqual(events);
expect(mockQuery).toHaveBeenCalledWith(
`SELECT * FROM remote_agent_workflow_events
WHERE workflow_run_id = $1 AND created_at > $2
ORDER BY created_at ASC`,
['run-456', since.toISOString()]
);
});
test('delegates to listWorkflowEvents without since parameter', async () => {
const events: WorkflowEventRow[] = [mockEvent];
mockQuery.mockResolvedValueOnce(createQueryResult(events));
const result = await listRecentEvents('run-456');
expect(result).toEqual(events);
// Should use the same query as listWorkflowEvents (no created_at filter)
expect(mockQuery).toHaveBeenCalledWith(
`SELECT * FROM remote_agent_workflow_events
WHERE workflow_run_id = $1
ORDER BY created_at ASC`,
['run-456']
);
});
test('returns empty array for no results', async () => {
mockQuery.mockResolvedValueOnce(createQueryResult([]));
const since = new Date('2025-06-01T00:00:00.000Z');
const result = await listRecentEvents('run-456', since);
expect(result).toEqual([]);
});
test('throws wrapped error on query failure', async () => {
const consoleSpy = spyOn(console, 'error').mockImplementation(() => {});
mockQuery.mockRejectedValueOnce(new Error('connection lost'));
await expect(listRecentEvents('run-456', new Date())).rejects.toThrow(
'Failed to list recent workflow events: connection lost'
);
consoleSpy.mockRestore();
});
});
});

View file

@ -0,0 +1,110 @@
/**
* Database operations for workflow events (lean UI-relevant events).
*
* Stores step transitions, parallel agent status, artifacts, and errors.
* Verbose assistant/tool content stays in JSONL logs only.
*
* All write operations use fire-and-forget pattern (catch + log, never throw)
* because workflow execution must not fail due to event logging.
*/
import { pool, getDialect } from './connection';
export interface WorkflowEventRow {
id: string;
workflow_run_id: string;
event_type: string;
step_index: number | null;
step_name: string | null;
/** Normalized to object — SQLite returns JSON as string, PG returns object. */
data: Record<string, unknown>;
created_at: string;
}
/**
* Create a workflow event. Fire-and-forget - never throws.
*/
export async function createWorkflowEvent(data: {
workflow_run_id: string;
event_type: string;
step_index?: number;
step_name?: string;
data?: Record<string, unknown>;
}): Promise<void> {
try {
const dialect = getDialect();
const id = dialect.generateUuid();
await pool.query(
`INSERT INTO remote_agent_workflow_events (id, workflow_run_id, event_type, step_index, step_name, data)
VALUES ($1, $2, $3, $4, $5, $6)`,
[
id,
data.workflow_run_id,
data.event_type,
data.step_index ?? null,
data.step_name ?? null,
JSON.stringify(data.data ?? {}),
]
);
} catch (error) {
console.error('[DB:WorkflowEvents] Failed to create event (non-critical):', {
error: (error as Error).message,
eventType: data.event_type,
runId: data.workflow_run_id,
});
// Fire-and-forget: never throw
}
}
/**
* List all events for a workflow run, ordered by creation time.
*/
export async function listWorkflowEvents(workflowRunId: string): Promise<WorkflowEventRow[]> {
try {
const result = await pool.query<WorkflowEventRow>(
`SELECT * FROM remote_agent_workflow_events
WHERE workflow_run_id = $1
ORDER BY created_at ASC`,
[workflowRunId]
);
return [...result.rows].map(row => ({
...row,
data: typeof row.data === 'string' ? JSON.parse(row.data) : row.data,
}));
} catch (error) {
console.error('[DB:WorkflowEvents] Failed to list events:', {
error: (error as Error).message,
runId: workflowRunId,
});
throw new Error(`Failed to list workflow events: ${(error as Error).message}`);
}
}
/**
* List recent events for a workflow run since a given timestamp.
*/
export async function listRecentEvents(
workflowRunId: string,
since?: Date
): Promise<WorkflowEventRow[]> {
try {
if (since) {
const result = await pool.query<WorkflowEventRow>(
`SELECT * FROM remote_agent_workflow_events
WHERE workflow_run_id = $1 AND created_at > $2
ORDER BY created_at ASC`,
[workflowRunId, since.toISOString()]
);
return [...result.rows].map(row => ({
...row,
data: typeof row.data === 'string' ? JSON.parse(row.data) : row.data,
}));
}
return await listWorkflowEvents(workflowRunId);
} catch (error) {
console.error('[DB:WorkflowEvents] Failed to list recent events:', {
error: (error as Error).message,
runId: workflowRunId,
});
throw new Error(`Failed to list recent workflow events: ${(error as Error).message}`);
}
}

View file

@ -58,7 +58,13 @@ export async function createWorkflowRun(data: {
metadataJson,
]
);
return result.rows[0];
const row = result.rows[0];
if (!row) {
throw new Error(
`Failed to create workflow run: INSERT returned no rows (workflow: ${data.workflow_name})`
);
}
return row;
} catch (error) {
const err = error as Error;
console.error('[DB:Workflows] Failed to create workflow run:', err.message);
@ -96,6 +102,29 @@ export async function getActiveWorkflowRun(conversationId: string): Promise<Work
}
}
/**
* Find the most recent workflow run for a worker platform conversation ID.
* Joins with conversations table to resolve platform_conversation_id DB id.
*/
export async function getWorkflowRunByWorkerPlatformId(
platformConversationId: string
): Promise<WorkflowRun | null> {
try {
const result = await pool.query<WorkflowRun>(
`SELECT r.* FROM remote_agent_workflow_runs r
JOIN remote_agent_conversations c ON r.conversation_id = c.id
WHERE c.platform_conversation_id = $1
ORDER BY r.started_at DESC LIMIT 1`,
[platformConversationId]
);
return result.rows[0] || null;
} catch (error) {
const err = error as Error;
console.error('[DB:Workflows] Failed to get workflow run by worker platform ID:', err.message);
throw new Error(`Failed to get workflow run by worker platform ID: ${err.message}`);
}
}
/**
* Partially update a workflow run.
* - Dynamically builds SQL from provided fields
@ -182,6 +211,72 @@ export async function failWorkflowRun(id: string, error: string): Promise<void>
}
}
/**
* List workflow runs with optional filters.
*/
export async function listWorkflowRuns(options?: {
conversationId?: string;
status?: 'pending' | 'running' | 'completed' | 'failed';
limit?: number;
codebaseId?: string;
}): Promise<WorkflowRun[]> {
const whereClauses: string[] = [];
const values: unknown[] = [];
if (options?.conversationId) {
values.push(options.conversationId);
whereClauses.push(`conversation_id = $${String(values.length)}`);
}
if (options?.status) {
values.push(options.status);
whereClauses.push(`status = $${String(values.length)}`);
}
if (options?.codebaseId) {
values.push(options.codebaseId);
whereClauses.push(
`conversation_id IN (SELECT id FROM remote_agent_conversations WHERE codebase_id = $${String(values.length)})`
);
}
const limit = options?.limit ?? 50;
values.push(limit);
const limitParam = `$${String(values.length)}`;
const whereStr = whereClauses.length > 0 ? `WHERE ${whereClauses.join(' AND ')}` : '';
try {
const result = await pool.query<WorkflowRun>(
`SELECT * FROM remote_agent_workflow_runs ${whereStr} ORDER BY started_at DESC LIMIT ${limitParam}`,
values
);
return [...result.rows];
} catch (error) {
const err = error as Error;
console.error('[DB:Workflows] Failed to list workflow runs:', err.message);
throw new Error(`Failed to list workflow runs: ${err.message}`);
}
}
/**
* Update parent_conversation_id on a workflow run.
* Non-critical logs error but does not throw.
*/
export async function updateWorkflowRunParent(
runId: string,
parentConversationId: string
): Promise<void> {
try {
await pool.query(
'UPDATE remote_agent_workflow_runs SET parent_conversation_id = $1 WHERE id = $2',
[parentConversationId, runId]
);
} catch (error) {
const err = error as Error;
console.error('[DB:Workflows] Failed to update parent conversation:', err.message);
// Non-critical — don't throw
}
}
/**
* Update last_activity_at timestamp for a workflow run.
* Used for activity-based staleness detection.

View file

@ -0,0 +1,248 @@
/**
* Standalone repository clone/register logic.
* Extracted from command-handler.ts for reuse by REST endpoints.
*/
import { access } from 'fs/promises';
import { join, basename } from 'path';
import * as codebaseDb from '../db/codebases';
import { sanitizeError } from '../utils/credential-sanitizer';
import { execFileAsync } from '../utils/git';
import { getArchonWorkspacesPath, getCommandFolderSearchPaths } from '../utils/archon-paths';
import { findMarkdownFilesRecursive } from '../utils/commands';
export interface RegisterResult {
codebaseId: string;
name: string;
repositoryUrl: string | null;
defaultCwd: string;
commandCount: number;
alreadyExisted: boolean;
}
/**
* Shared logic: register a repo at a given path in the DB and load commands.
*/
async function registerRepoAtPath(
targetPath: string,
name: string,
repositoryUrl: string | null
): Promise<RegisterResult> {
// Auto-detect assistant type based on folder structure
let suggestedAssistant = 'claude';
const codexFolder = join(targetPath, '.codex');
const claudeFolder = join(targetPath, '.claude');
try {
await access(codexFolder);
suggestedAssistant = 'codex';
console.log('[Clone] Detected .codex folder - using Codex assistant');
} catch {
try {
await access(claudeFolder);
suggestedAssistant = 'claude';
console.log('[Clone] Detected .claude folder - using Claude assistant');
} catch {
console.log('[Clone] No assistant folder detected - defaulting to Claude');
}
}
const codebase = await codebaseDb.createCodebase({
name,
repository_url: repositoryUrl ?? undefined,
default_cwd: targetPath,
ai_assistant_type: suggestedAssistant,
});
// Auto-load commands if found
let commandsLoaded = 0;
for (const folder of getCommandFolderSearchPaths()) {
const commandPath = join(targetPath, folder);
try {
await access(commandPath);
} catch {
continue; // Folder doesn't exist, try next
}
// Command loading errors should NOT be swallowed
const markdownFiles = await findMarkdownFilesRecursive(commandPath);
if (markdownFiles.length > 0) {
const commands = await codebaseDb.getCodebaseCommands(codebase.id);
markdownFiles.forEach(({ commandName, relativePath }) => {
commands[commandName] = {
path: join(folder, relativePath),
description: `From ${folder}`,
};
});
await codebaseDb.updateCodebaseCommands(codebase.id, commands);
commandsLoaded = markdownFiles.length;
break;
}
}
return {
codebaseId: codebase.id,
name: codebase.name,
repositoryUrl: repositoryUrl,
defaultCwd: targetPath,
commandCount: commandsLoaded,
alreadyExisted: false,
};
}
/**
* Normalize a repo URL: strip trailing slashes and convert SSH to HTTPS.
*/
function normalizeRepoUrl(rawUrl: string): {
workingUrl: string;
ownerName: string;
repoName: string;
targetPath: string;
} {
const normalizedUrl = rawUrl.replace(/\/+$/, '');
let workingUrl = normalizedUrl;
if (normalizedUrl.startsWith('git@github.com:')) {
workingUrl = normalizedUrl.replace('git@github.com:', 'https://github.com/');
}
const urlParts = workingUrl.replace(/\.git$/, '').split('/');
const repoName = urlParts.pop() ?? 'unknown';
const ownerName = urlParts.pop() ?? 'unknown';
const workspacePath = getArchonWorkspacesPath();
const targetPath = join(workspacePath, ownerName, repoName);
return { workingUrl, ownerName, repoName, targetPath };
}
/**
* Clone a repository from a URL and register it in the database.
*/
export async function cloneRepository(repoUrl: string): Promise<RegisterResult> {
const { workingUrl, ownerName, repoName, targetPath } = normalizeRepoUrl(repoUrl);
// Check if target directory already exists
let directoryExists = false;
try {
await access(targetPath);
directoryExists = true;
} catch {
// Directory doesn't exist, proceed with clone
}
if (directoryExists) {
// Directory exists - try to find existing codebase by repo URL
const urlNoGit = workingUrl.replace(/\.git$/, '');
const urlWithGit = urlNoGit + '.git';
const existingCodebase =
(await codebaseDb.findCodebaseByRepoUrl(urlNoGit)) ??
(await codebaseDb.findCodebaseByRepoUrl(urlWithGit));
if (existingCodebase) {
return {
codebaseId: existingCodebase.id,
name: existingCodebase.name,
repositoryUrl: existingCodebase.repository_url,
defaultCwd: existingCodebase.default_cwd,
commandCount: 0,
alreadyExisted: true,
};
}
// Directory exists but no codebase found
throw new Error(
`Directory already exists: ${targetPath}\n\nNo matching codebase found in database. Remove the directory and re-clone.`
);
}
console.log(`[Clone] Cloning ${workingUrl} to ${targetPath}`);
// Build clone command with authentication if GitHub token is available
let cloneUrl = workingUrl;
const ghToken = process.env.GH_TOKEN;
if (ghToken && workingUrl.includes('github.com')) {
if (workingUrl.startsWith('https://github.com')) {
cloneUrl = workingUrl.replace('https://github.com', `https://${ghToken}@github.com`);
} else if (workingUrl.startsWith('http://github.com')) {
cloneUrl = workingUrl.replace('http://github.com', `https://${ghToken}@github.com`);
} else if (!workingUrl.startsWith('http')) {
cloneUrl = `https://${ghToken}@${workingUrl}`;
}
console.log('[Clone] Using authenticated GitHub clone');
}
try {
await execFileAsync('git', ['clone', cloneUrl, targetPath]);
} catch (error) {
const safeErr = sanitizeError(error as Error);
throw new Error(`Failed to clone repository: ${safeErr.message}`);
}
// Add to git safe.directory
await execFileAsync('git', ['config', '--global', '--add', 'safe.directory', targetPath]);
console.log(`[Clone] Added ${targetPath} to git safe.directory`);
return registerRepoAtPath(targetPath, `${ownerName}/${repoName}`, workingUrl);
}
/**
* Register an existing local repository in the database (no git clone).
*/
export async function registerRepository(localPath: string): Promise<RegisterResult> {
// Validate path exists and is a git repo
try {
await execFileAsync('git', ['-C', localPath, 'rev-parse', '--git-dir']);
} catch (error) {
throw new Error(`Path is not a git repository: ${localPath} (${(error as Error).message})`);
}
// Check if already registered by path
const existing = await codebaseDb.findCodebaseByDefaultCwd(localPath);
if (existing) {
return {
codebaseId: existing.id,
name: existing.name,
repositoryUrl: existing.repository_url,
defaultCwd: existing.default_cwd,
commandCount: 0,
alreadyExisted: true,
};
}
// Get remote URL (optional — local-only repos may not have one)
let remoteUrl: string | null = null;
try {
const { stdout } = await execFileAsync('git', ['-C', localPath, 'remote', 'get-url', 'origin']);
remoteUrl = stdout.trim() || null;
} catch (error) {
const msg = (error as Error).message ?? '';
if (!msg.includes('No such remote')) {
console.warn('[Clone] Unexpected error fetching remote URL', {
path: localPath,
error: msg,
});
}
}
// Extract repo name from directory name
const repoName = basename(localPath);
// Try to build owner/repo name from remote URL
let name = repoName;
if (remoteUrl) {
const cleaned = remoteUrl.replace(/\.git$/, '').replace(/\/+$/, '');
let workingRemote = cleaned;
if (cleaned.startsWith('git@github.com:')) {
workingRemote = cleaned.replace('git@github.com:', 'https://github.com/');
}
const parts = workingRemote.split('/');
const r = parts.pop();
const o = parts.pop();
if (o && r) {
name = `${o}/${r}`;
}
}
return registerRepoAtPath(localPath, name, remoteUrl);
}

View file

@ -25,6 +25,8 @@ import { discoverWorkflows } from '../workflows';
import { isSingleStep, type WorkflowDefinition } from '../workflows/types';
import * as workflowDb from '../db/workflows';
import { getTriggerForCommand } from '../state/session-transitions';
import { cloneRepository } from './clone';
import { findMarkdownFilesRecursive } from '../utils/commands';
// Workflow staleness thresholds (in milliseconds)
const WORKFLOW_SLOW_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
@ -168,40 +170,6 @@ async function formatRepoContext(
return `${codebase.name} @ ${branchName}`;
}
/**
* Recursively find all .md files in a directory and its subdirectories
*/
async function findMarkdownFilesRecursive(
rootPath: string,
relativePath = ''
): Promise<{ commandName: string; relativePath: string }[]> {
const results: { commandName: string; relativePath: string }[] = [];
const fullPath = join(rootPath, relativePath);
const entries = await readdir(fullPath, { withFileTypes: true });
for (const entry of entries) {
// Skip hidden directories and common exclusions
if (entry.name.startsWith('.') || entry.name === 'node_modules') {
continue;
}
if (entry.isDirectory()) {
// Recurse into subdirectory
const subResults = await findMarkdownFilesRecursive(rootPath, join(relativePath, entry.name));
results.push(...subResults);
} else if (entry.isFile() && entry.name.endsWith('.md')) {
// Found a markdown file - use filename as command name
results.push({
commandName: basename(entry.name, '.md'),
relativePath: join(relativePath, entry.name),
});
}
}
return results;
}
/**
* Represents a repository with nested owner/repo structure
*/
@ -292,52 +260,52 @@ export async function handleCommand(
case 'help':
return {
success: true,
message: `Available Commands:
message: `## Available Commands
Command Templates (global):
/<name> [args] - Invoke a template directly
/templates - List all templates
/template-add <name> <path> - Add template from file
/template-delete <name> - Remove a template
### Command Templates (global)
- \`/<name> [args]\` - Invoke a template directly
- \`/templates\` - List all templates
- \`/template-add <name> <path>\` - Add template from file
- \`/template-delete <name>\` - Remove a template
Codebase Commands (per-project):
/command-set <name> <path> [text] - Register command
/load-commands <folder> - Bulk load (recursive)
/command-invoke <name> [args] - Execute
/commands - List registered
Note: Commands use relative paths (e.g., .archon/commands)
### Codebase Commands (per-project)
- \`/command-set <name> <path> [text]\` - Register command
- \`/load-commands <folder>\` - Bulk load (recursive)
- \`/command-invoke <name> [args]\` - Execute
- \`/commands\` - List registered
- *Commands use relative paths (e.g., .archon/commands)*
Codebase:
/clone <repo-url> - Clone repository
/repos - List repositories (numbered)
/repo <#|name> [pull] - Switch repo (auto-loads commands)
/repo-remove <#|name> - Remove repo and codebase record
/getcwd - Show working directory
/setcwd <path> - Set directory
Note: Use /repo for quick switching, /setcwd for manual paths
### Codebase
- \`/clone <repo-url>\` - Clone repository
- \`/repos\` - List repositories (numbered)
- \`/repo <#|name> [pull]\` - Switch repo (auto-loads commands)
- \`/repo-remove <#|name>\` - Remove repo and codebase record
- \`/getcwd\` - Show working directory
- \`/setcwd <path>\` - Set directory
- *Use /repo for quick switching, /setcwd for manual paths*
Worktrees:
/worktree create <branch> - Create isolated worktree
/worktree list - Show worktrees for this repo
/worktree remove [--force] - Remove current worktree
/worktree cleanup merged|stale - Clean up worktrees
/worktree orphans - Show all worktrees from git
### Worktrees
- \`/worktree create <branch>\` - Create isolated worktree
- \`/worktree list\` - Show worktrees for this repo
- \`/worktree remove [--force]\` - Remove current worktree
- \`/worktree cleanup merged|stale\` - Clean up worktrees
- \`/worktree orphans\` - Show all worktrees from git
Workflows:
/workflow list - Show available workflows
/workflow reload - Reload workflow definitions
/workflow status - Show running workflow details
/workflow cancel - Cancel running workflow
Note: Workflows are YAML files in .archon/workflows/
### Workflows
- \`/workflow list\` - Show available workflows
- \`/workflow reload\` - Reload workflow definitions
- \`/workflow status\` - Show running workflow details
- \`/workflow cancel\` - Cancel running workflow
- *Workflows are YAML files in .archon/workflows/*
Session:
/status - Show state
/reset - Clear session
/reset-context - Reset AI context, keep worktree
/help - Show help
### Session
- \`/status\` - Show state
- \`/reset\` - Clear session
- \`/reset-context\` - Reset AI context, keep worktree
- \`/help\` - Show help
Setup:
/init - Create .archon structure in current repo`,
### Setup
- \`/init\` - Create .archon structure in current repo`,
};
case 'status': {
@ -506,180 +474,26 @@ Setup:
return { success: false, message: 'Usage: /clone <repo-url>' };
}
// Normalize URL: strip trailing slashes
const normalizedUrl: string = args[0].replace(/\/+$/, '');
// Convert SSH URL to HTTPS format if needed
// git@github.com:user/repo.git -> https://github.com/user/repo.git
let workingUrl = normalizedUrl;
if (normalizedUrl.startsWith('git@github.com:')) {
workingUrl = normalizedUrl.replace('git@github.com:', 'https://github.com/');
}
// Extract owner and repo from URL
// https://github.com/owner/repo.git -> owner, repo
const urlParts = workingUrl.replace(/\.git$/, '').split('/');
const repoName = urlParts.pop() ?? 'unknown';
const ownerName = urlParts.pop() ?? 'unknown';
// Use Archon workspaces path (ARCHON_HOME/workspaces or ~/.archon/workspaces)
// Include owner in path to prevent collisions (e.g., alice/utils vs bob/utils)
const workspacePath = getArchonWorkspacesPath();
const targetPath = join(workspacePath, ownerName, repoName);
try {
// Check if target directory already exists
try {
await access(targetPath);
const result = await cloneRepository(args[0]);
// Directory exists - try to find existing codebase by repo URL
// Check both with and without .git suffix (per github.ts pattern)
const urlNoGit = workingUrl.replace(/\.git$/, '');
const urlWithGit = urlNoGit + '.git';
const existingCodebase =
(await codebaseDb.findCodebaseByRepoUrl(urlNoGit)) ??
(await codebaseDb.findCodebaseByRepoUrl(urlWithGit));
if (existingCodebase) {
// Link conversation to existing codebase
try {
await db.updateConversation(conversation.id, {
codebase_id: existingCodebase.id,
cwd: targetPath,
});
} catch (updateError) {
if (updateError instanceof ConversationNotFoundError) {
return {
success: false,
message:
'Failed to link existing codebase: conversation state changed. Please try again.',
};
}
throw updateError;
}
// Reset session when switching codebases
const session = await sessionDb.getActiveSession(conversation.id);
if (session) {
await sessionDb.deactivateSession(session.id);
console.log(
`[Command] Deactivated session: ${getTriggerForCommand('clone') ?? 'codebase-cloned'}`
);
}
// Check for command folders (same logic as successful clone)
let commandFolder: string | null = null;
for (const folder of getCommandFolderSearchPaths()) {
try {
await access(join(targetPath, folder));
commandFolder = folder;
break;
} catch {
/* ignore */
}
}
let responseMessage = `Repository already cloned.\n\nLinked to existing codebase: ${existingCodebase.name}\nPath: ${targetPath}\n\nSession reset - starting fresh on next message.`;
if (commandFolder) {
responseMessage += `\n\n📁 Found: ${commandFolder}/\nUse /load-commands ${commandFolder} to register commands.`;
}
return {
success: true,
message: responseMessage,
modified: true,
};
}
// Directory exists but no codebase found
return {
success: false,
message: `Directory already exists: ${targetPath}\n\nNo matching codebase found in database. Options:\n- Remove the directory and re-clone\n- Use /setcwd ${targetPath} (limited functionality)`,
};
} catch {
// Directory doesn't exist, proceed with clone
}
console.log(`[Clone] Cloning ${workingUrl} to ${targetPath}`);
// Build clone command with authentication if GitHub token is available
let cloneUrl = workingUrl;
const ghToken = process.env.GH_TOKEN;
if (ghToken && workingUrl.includes('github.com')) {
// Inject token into GitHub URL for private repo access
// Convert: https://github.com/user/repo.git -> https://token@github.com/user/repo.git
if (workingUrl.startsWith('https://github.com')) {
cloneUrl = workingUrl.replace('https://github.com', `https://${ghToken}@github.com`);
} else if (workingUrl.startsWith('http://github.com')) {
cloneUrl = workingUrl.replace('http://github.com', `https://${ghToken}@github.com`);
} else if (!workingUrl.startsWith('http')) {
// Handle github.com/user/repo format (bare domain)
cloneUrl = `https://${ghToken}@${workingUrl}`;
}
console.log('[Clone] Using authenticated GitHub clone');
}
await execFileAsync('git', ['clone', cloneUrl, targetPath]);
// Add the cloned repository to git safe.directory to prevent ownership errors
// This is needed because we run as non-root user but git might see different ownership
await execFileAsync('git', ['config', '--global', '--add', 'safe.directory', targetPath]);
console.log(`[Clone] Added ${targetPath} to git safe.directory`);
// Auto-detect assistant type based on folder structure
let suggestedAssistant = 'claude';
const codexFolder = join(targetPath, '.codex');
const claudeFolder = join(targetPath, '.claude');
try {
await access(codexFolder);
suggestedAssistant = 'codex';
console.log('[Clone] Detected .codex folder - using Codex assistant');
} catch {
try {
await access(claudeFolder);
suggestedAssistant = 'claude';
console.log('[Clone] Detected .claude folder - using Claude assistant');
} catch {
// Default to claude
console.log('[Clone] No assistant folder detected - defaulting to Claude');
}
}
const codebase = await codebaseDb.createCodebase({
name: `${ownerName}/${repoName}`,
repository_url: workingUrl,
default_cwd: targetPath,
ai_assistant_type: suggestedAssistant,
});
console.log(
`[Clone] Updating conversation ${conversation.id} with codebase ${codebase.id}`
);
// Link conversation to the codebase
try {
await db.updateConversation(conversation.id, {
codebase_id: codebase.id,
cwd: targetPath,
codebase_id: result.codebaseId,
cwd: result.defaultCwd,
});
} catch (updateError) {
if (updateError instanceof ConversationNotFoundError) {
console.error('[Clone] Failed to link conversation - state changed unexpectedly', {
conversationId: conversation.id,
codebaseId: codebase.id,
});
return {
success: false,
message:
'Failed to complete clone: conversation state changed unexpectedly. Please try again.',
message: 'Failed to link codebase: conversation state changed. Please try again.',
};
}
throw updateError;
}
// Reset session when cloning a new repository
// Reset session when cloning/switching codebases
const session = await sessionDb.getActiveSession(conversation.id);
if (session) {
await sessionDb.deactivateSession(session.id);
@ -688,44 +502,36 @@ Setup:
);
}
// Auto-load commands if found (defaults loaded at runtime, not copied)
let commandsLoaded = 0;
for (const folder of getCommandFolderSearchPaths()) {
try {
const commandPath = join(targetPath, folder);
await access(commandPath);
const markdownFiles = await findMarkdownFilesRecursive(commandPath);
if (markdownFiles.length > 0) {
const commands = await codebaseDb.getCodebaseCommands(codebase.id);
markdownFiles.forEach(({ commandName, relativePath }) => {
commands[commandName] = {
path: join(folder, relativePath),
description: `From ${folder}`,
};
});
await codebaseDb.updateCodebaseCommands(codebase.id, commands);
commandsLoaded = markdownFiles.length;
if (result.alreadyExisted) {
// Check for command folders
let commandFolder: string | null = null;
for (const folder of getCommandFolderSearchPaths()) {
try {
await access(join(result.defaultCwd, folder));
commandFolder = folder;
break;
} catch {
/* ignore */
}
} catch {
// Folder doesn't exist, try next
}
let responseMessage = `Repository already cloned.\n\nLinked to existing codebase: ${result.name}\nPath: ${result.defaultCwd}\n\nSession reset - starting fresh on next message.`;
if (commandFolder) {
responseMessage += `\n\n📁 Found: ${commandFolder}/\nUse /load-commands ${commandFolder} to register commands.`;
}
return { success: true, message: responseMessage, modified: true };
}
let responseMessage = `Repository cloned successfully!\n\nRepository: ${repoName}`;
if (commandsLoaded > 0) {
responseMessage += `\n✓ Loaded ${String(commandsLoaded)} repo commands`;
let responseMessage = `Repository cloned successfully!\n\nRepository: ${result.name}`;
if (result.commandCount > 0) {
responseMessage += `\n✓ Loaded ${String(result.commandCount)} repo commands`;
}
responseMessage += '\n✓ App defaults available at runtime';
responseMessage +=
'\n\nSession reset - starting fresh on next message.\n\nYou can now start asking questions about the code.';
return {
success: true,
message: responseMessage,
modified: true,
};
return { success: true, message: responseMessage, modified: true };
} catch (error) {
const err = error as Error;
const safeErr = sanitizeError(err);

View file

@ -23,6 +23,8 @@ export {
type CommandTemplate,
type CommandResult,
type IPlatformAdapter,
type IWebPlatformAdapter,
isWebAdapter,
type MessageChunk,
type IAssistantClient,
} from './types';
@ -47,6 +49,7 @@ export * as sessionDb from './db/sessions';
export * as commandTemplateDb from './db/command-templates';
export * as isolationEnvDb from './db/isolation-environments';
export * as workflowDb from './db/workflows';
export * as messageDb from './db/messages';
// Re-export SessionNotFoundError for error handling
export { SessionNotFoundError } from './db/sessions';
@ -72,6 +75,9 @@ export {
type LoopConfig,
type WorkflowDefinition,
type WorkflowRun,
type WorkflowRunStatus,
type WorkflowStepStatus,
type ArtifactType,
type StepResult,
type LoadCommandResult,
type WorkflowExecutionResult,
@ -108,6 +114,16 @@ export {
logParallelBlockComplete,
} from './workflows/logger';
// Event Emitter
export {
type WorkflowEmitterEvent,
getWorkflowEventEmitter,
resetWorkflowEventEmitter,
} from './workflows/event-emitter';
// Workflow Events DB
export * as workflowEventDb from './db/workflow-events';
// =============================================================================
// Isolation
// =============================================================================
@ -128,6 +144,7 @@ export { handleMessage } from './orchestrator/orchestrator';
// Handlers
// =============================================================================
export { handleCommand, parseCommand } from './handlers/command-handler';
export { cloneRepository, registerRepository, type RegisterResult } from './handlers/clone';
// =============================================================================
// Config
@ -168,7 +185,7 @@ export {
// =============================================================================
// Conversation lock
export { ConversationLockManager } from './utils/conversation-lock';
export { ConversationLockManager, type LockAcquisitionResult } from './utils/conversation-lock';
// Error formatting
export { classifyAndFormatError } from './utils/error-formatter';

View file

@ -16,6 +16,7 @@ import {
Conversation,
Codebase,
ConversationNotFoundError,
isWebAdapter,
} from '../types';
import * as db from '../db/conversations';
import * as codebaseDb from '../db/codebases';
@ -38,6 +39,7 @@ import {
executeWorkflow,
} from '../workflows';
import type { WorkflowDefinition, RouterContext } from '../workflows';
import * as workflowDb from '../db/workflows';
import {
cleanupToMakeRoom,
getWorktreeStatusBreakdown,
@ -338,6 +340,8 @@ async function resolveIsolation(
error: err.message,
stack: err.stack,
codebaseId: codebase.id,
codebaseName: codebase.name,
defaultCwd: codebase.default_cwd,
});
await platform.sendMessage(
@ -477,7 +481,13 @@ async function tryWorkflowRouting(
? { branchName: ctx.isolationEnv.branch_name, isPrReview, prSha, prBranch }
: undefined;
// executeWorkflow handles its own errors and user messaging
// Background dispatch for web platform — workflow runs in a worker conversation
if (ctx.platform.getPlatformType() === 'web') {
await dispatchBackgroundWorkflow(ctx, workflow, isolationContext);
return true;
}
// Inline execution for all other platforms
await executeWorkflow(
ctx.platform,
ctx.conversationId,
@ -493,6 +503,115 @@ async function tryWorkflowRouting(
return true;
}
/**
* Dispatch a workflow to run in a background worker conversation (web platform only).
* Creates a hidden worker conversation, sets up event bridging from worker to parent,
* and fires-and-forgets the workflow execution.
*/
async function dispatchBackgroundWorkflow(
ctx: WorkflowRoutingContext,
workflow: WorkflowDefinition,
isolationContext?: {
branchName?: string;
isPrReview?: boolean;
prSha?: string;
prBranch?: string;
}
): Promise<void> {
// 1. Generate worker conversation ID
const workerPlatformId = `web-worker-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`;
// 2. Create worker conversation in DB (inherit context from parent)
const workerConv = await db.getOrCreateConversation('web', workerPlatformId);
await db.updateConversation(workerConv.id, {
cwd: ctx.cwd,
codebase_id: ctx.codebaseId ?? null,
hidden: true,
});
// 3. Notify parent chat that workflow is dispatching
await ctx.platform.sendMessage(
ctx.conversationId,
`\u{1F680} Dispatching workflow: **${workflow.name}** (background)`
);
// Narrow to web adapter for web-specific operations
const webAdapter = isWebAdapter(ctx.platform) ? ctx.platform : null;
// Send structured dispatch event for Web UI
if (webAdapter) {
await webAdapter.sendStructuredEvent(ctx.conversationId, {
type: 'workflow_dispatch',
workerConversationId: workerPlatformId,
workflowName: workflow.name,
});
}
// 4. Set up DB ID mapping for worker (needed for message persistence)
if (webAdapter) {
webAdapter.setConversationDbId(workerPlatformId, workerConv.id);
}
// 5. Set up event bridge (worker events → parent SSE stream)
let unsubscribeBridge: (() => void) | undefined;
if (webAdapter) {
unsubscribeBridge = webAdapter.setupEventBridge(workerPlatformId, ctx.conversationId);
}
// 6. Fire-and-forget: run workflow in background
void (async (): Promise<void> => {
try {
try {
const result = await executeWorkflow(
ctx.platform,
workerPlatformId,
ctx.cwd,
workflow,
ctx.originalMessage,
workerConv.id,
ctx.codebaseId,
ctx.issueContext,
isolationContext
);
// Store parent link on the workflow run (regardless of success/failure)
if (result.workflowRunId) {
await workflowDb.updateWorkflowRunParent(result.workflowRunId, ctx.conversationDbId);
}
} catch (error) {
console.error('[Orchestrator] Background workflow failed:', {
workflowName: workflow.name,
workerConversationId: workerPlatformId,
error: (error as Error).message,
});
// Surface error to parent conversation so the user knows
await ctx.platform
.sendMessage(
ctx.conversationId,
`Workflow **${workflow.name}** failed: ${(error as Error).message}`
)
.catch((sendErr: unknown) => {
console.error('[Orchestrator] Failed to notify parent of workflow error:', {
error: (sendErr as Error).message,
});
});
} finally {
// Clean up event bridge
if (unsubscribeBridge) {
unsubscribeBridge();
}
if (webAdapter) {
webAdapter.removeOutputCallback(workerPlatformId);
webAdapter.emitLockEvent(workerPlatformId, false);
}
}
} catch (outerError) {
console.error('[Orchestrator] Unhandled error in background workflow:', {
error: (outerError as Error).message,
});
}
})();
}
/**
* Wraps command content with execution context to signal the AI should execute immediately.
* @param commandName - The name of the command being invoked (e.g., 'create-pr')
@ -663,17 +782,32 @@ export async function handleMessage(
// Build the user message with workflow args
const userMessage = workflowArgs || message;
// Execute the workflow
await executeWorkflow(
platform,
conversationId,
cwd,
workflow,
userMessage,
conversation.id,
conversation.codebase_id,
issueContext
);
// Background dispatch for web platform
if (platform.getPlatformType() === 'web') {
const routingContext: WorkflowRoutingContext = {
platform,
conversationId,
cwd,
originalMessage: userMessage,
conversationDbId: conversation.id,
codebaseId: conversation.codebase_id ?? undefined,
availableWorkflows: workflows,
issueContext,
};
await dispatchBackgroundWorkflow(routingContext, workflow);
} else {
// Inline execution for all other platforms
await executeWorkflow(
platform,
conversationId,
cwd,
workflow,
userMessage,
conversation.id,
conversation.codebase_id,
issueContext
);
}
}
return;
}
@ -1018,8 +1152,18 @@ export async function handleMessage(
} else if (msg.type === 'tool' && msg.toolName) {
const toolMessage = formatToolCall(msg.toolName, msg.toolInput);
await platform.sendMessage(conversationId, toolMessage);
// Send structured event to adapters that support it (Web UI)
if (platform.sendStructuredEvent) {
await platform.sendStructuredEvent(conversationId, msg);
}
} else if (msg.type === 'result' && msg.sessionId) {
newSessionId = msg.sessionId;
// Send session info to adapters that support structured events
if (platform.sendStructuredEvent) {
await platform.sendStructuredEvent(conversationId, msg);
}
}
}

View file

@ -21,6 +21,9 @@ export interface Conversation {
cwd: string | null;
isolation_env_id: string | null; // UUID FK to isolation_environments
ai_assistant_type: string;
title: string | null;
hidden: boolean;
deleted_at: Date | null;
last_activity_at: Date | null; // For staleness detection
created_at: Date;
updated_at: Date;
@ -151,21 +154,48 @@ export interface IPlatformAdapter {
* Stop the platform adapter gracefully
*/
stop(): void;
/**
* Optional: Send a structured event (MessageChunk) to the platform.
* Only implemented by adapters that can display rich structured data (e.g., Web UI).
* Other adapters (Telegram, Slack) continue using sendMessage() for formatted text.
*/
sendStructuredEvent?(conversationId: string, event: MessageChunk): Promise<void>;
}
/**
* Message chunk from AI assistant
* Extended platform adapter for the Web UI.
* Adds methods for SSE event bridging, message persistence, and lock events
* that are only meaningful in the web context.
*/
export interface MessageChunk {
type: 'assistant' | 'result' | 'system' | 'tool' | 'thinking';
content?: string;
sessionId?: string;
// For tool calls
toolName?: string;
toolInput?: Record<string, unknown>;
export interface IWebPlatformAdapter extends IPlatformAdapter {
sendStructuredEvent(conversationId: string, event: MessageChunk): Promise<void>;
setConversationDbId(platformConversationId: string, dbId: string): void;
setupEventBridge(workerConversationId: string, parentConversationId: string): () => void;
emitLockEvent(conversationId: string, locked: boolean, queuePosition?: number): void;
registerOutputCallback(conversationId: string, callback: (text: string) => void): void;
removeOutputCallback(conversationId: string): void;
}
/**
* Type guard for web platform adapter.
*/
export function isWebAdapter(adapter: IPlatformAdapter): adapter is IWebPlatformAdapter {
return adapter.getPlatformType() === 'web';
}
/**
* Message chunk from AI assistant.
* Discriminated union with per-type required fields for type safety.
*/
export type MessageChunk =
| { type: 'assistant'; content: string }
| { type: 'system'; content: string }
| { type: 'thinking'; content: string }
| { type: 'result'; sessionId?: string }
| { type: 'tool'; toolName: string; toolInput?: Record<string, unknown> }
| { type: 'workflow_dispatch'; workerConversationId: string; workflowName: string };
/**
* Generic AI assistant client interface
* Allows supporting multiple AI assistants (Claude, Codex, etc.)

View file

@ -0,0 +1,37 @@
/**
* Shared command utilities for markdown file discovery.
*/
import { readdir } from 'fs/promises';
import { join, basename } from 'path';
/**
* Recursively find all .md files in a directory and its subdirectories.
* Skips hidden directories and node_modules.
*/
export async function findMarkdownFilesRecursive(
rootPath: string,
relativePath = ''
): Promise<{ commandName: string; relativePath: string }[]> {
const results: { commandName: string; relativePath: string }[] = [];
const fullPath = join(rootPath, relativePath);
const entries = await readdir(fullPath, { withFileTypes: true });
for (const entry of entries) {
if (entry.name.startsWith('.') || entry.name === 'node_modules') {
continue;
}
if (entry.isDirectory()) {
const subResults = await findMarkdownFilesRecursive(rootPath, join(relativePath, entry.name));
results.push(...subResults);
} else if (entry.isFile() && entry.name.endsWith('.md')) {
results.push({
commandName: basename(entry.name, '.md'),
relativePath: join(relativePath, entry.name),
});
}
}
return results;
}

View file

@ -15,6 +15,13 @@ interface QueuedMessage {
timestamp: number;
}
/**
* Result of acquiring a lock, indicating whether the message was started or queued
*/
export interface LockAcquisitionResult {
status: 'started' | 'queued-conversation' | 'queued-capacity';
}
/**
* Manages conversation locks for concurrent message processing
*/
@ -40,11 +47,14 @@ export class ConversationLockManager {
* @param conversationId - Unique conversation identifier
* @param handler - Async function to execute
*/
async acquireLock(conversationId: string, handler: () => Promise<void>): Promise<void> {
async acquireLock(
conversationId: string,
handler: () => Promise<void>
): Promise<LockAcquisitionResult> {
// Check if conversation already active - queue if yes
if (this.activeConversations.has(conversationId)) {
this.queueMessage(conversationId, handler);
return;
return { status: 'queued-conversation' };
}
// Check if at max capacity - queue if yes
@ -53,7 +63,7 @@ export class ConversationLockManager {
`[ConversationLock] At max capacity (${String(this.maxConcurrent)}), queuing ${conversationId}`
);
this.queueMessage(conversationId, handler);
return;
return { status: 'queued-capacity' };
}
// Execute immediately
@ -89,6 +99,7 @@ export class ConversationLockManager {
this.activeConversations.set(conversationId, promise);
// Fire-and-forget: don't await here, return immediately
return { status: 'started' };
}
/**

View file

@ -0,0 +1,237 @@
/**
* WorkflowEventEmitter - typed event emitter for workflow execution observability.
*
* Lives in @archon/core so the executor can emit events.
* The Web adapter in @archon/server subscribes to forward events to SSE streams.
*
* Design:
* - Singleton pattern via getWorkflowEventEmitter()
* - Fire-and-forget: listener errors never propagate to the executor
* - Conversation-scoped subscriptions via registerRun() mapping
*/
import { EventEmitter } from 'events';
import type { ArtifactType } from './types';
// ---------------------------------------------------------------------------
// Event types
// ---------------------------------------------------------------------------
interface WorkflowStartedEvent {
type: 'workflow_started';
runId: string;
workflowName: string;
conversationId: string;
totalSteps: number;
isLoop: boolean;
}
interface WorkflowCompletedEvent {
type: 'workflow_completed';
runId: string;
workflowName: string;
duration: number;
}
interface WorkflowFailedEvent {
type: 'workflow_failed';
runId: string;
workflowName: string;
error: string;
stepIndex?: number;
}
interface StepStartedEvent {
type: 'step_started';
runId: string;
stepIndex: number;
stepName: string;
totalSteps: number;
}
interface StepCompletedEvent {
type: 'step_completed';
runId: string;
stepIndex: number;
stepName: string;
duration: number;
}
interface StepFailedEvent {
type: 'step_failed';
runId: string;
stepIndex: number;
stepName: string;
error: string;
}
interface ParallelAgentStartedEvent {
type: 'parallel_agent_started';
runId: string;
stepIndex: number;
agentIndex: number;
totalAgents: number;
agentName: string;
}
interface ParallelAgentCompletedEvent {
type: 'parallel_agent_completed';
runId: string;
stepIndex: number;
agentIndex: number;
agentName: string;
duration: number;
}
interface ParallelAgentFailedEvent {
type: 'parallel_agent_failed';
runId: string;
stepIndex: number;
agentIndex: number;
agentName: string;
error: string;
}
interface LoopIterationStartedEvent {
type: 'loop_iteration_started';
runId: string;
iteration: number;
maxIterations: number;
}
interface LoopIterationCompletedEvent {
type: 'loop_iteration_completed';
runId: string;
iteration: number;
duration: number;
completionDetected: boolean;
}
interface WorkflowArtifactEvent {
type: 'workflow_artifact';
runId: string;
artifactType: ArtifactType;
label: string;
url?: string;
path?: string;
}
export type WorkflowEmitterEvent =
| WorkflowStartedEvent
| WorkflowCompletedEvent
| WorkflowFailedEvent
| StepStartedEvent
| StepCompletedEvent
| StepFailedEvent
| ParallelAgentStartedEvent
| ParallelAgentCompletedEvent
| ParallelAgentFailedEvent
| LoopIterationStartedEvent
| LoopIterationCompletedEvent
| WorkflowArtifactEvent;
// ---------------------------------------------------------------------------
// Emitter class
// ---------------------------------------------------------------------------
type Listener = (event: WorkflowEmitterEvent) => void;
const WORKFLOW_EVENT = 'workflow_event';
class WorkflowEventEmitter {
private emitter = new EventEmitter();
private conversationMap = new Map<string, string>(); // runId -> conversationId
constructor() {
// Allow many subscribers (adapters, DB persistence, tests, etc.)
this.emitter.setMaxListeners(50);
}
/**
* Register a run-to-conversation mapping so subscribers can filter by conversation.
*/
registerRun(runId: string, conversationId: string): void {
this.conversationMap.set(runId, conversationId);
}
/**
* Remove the run-to-conversation mapping (called at workflow end).
*/
unregisterRun(runId: string): void {
this.conversationMap.delete(runId);
}
/**
* Get the conversation ID for a given run.
*/
getConversationId(runId: string): string | undefined {
return this.conversationMap.get(runId);
}
/**
* Emit a workflow event. Fire-and-forget: listener errors are caught and logged.
*/
emit(event: WorkflowEmitterEvent): void {
try {
this.emitter.emit(WORKFLOW_EVENT, event);
} catch (error) {
console.error('[WorkflowEventEmitter] Error emitting event', {
eventType: event.type,
error: (error as Error).message,
});
}
}
/**
* Subscribe to all workflow events. Returns an unsubscribe function.
*/
subscribe(listener: Listener): () => void {
// Wrap listener to catch errors - listener failures must not propagate
const safeListener = (event: WorkflowEmitterEvent): void => {
try {
listener(event);
} catch (error) {
console.error('[WorkflowEventEmitter] Listener error', {
eventType: event.type,
error: (error as Error).message,
});
}
};
this.emitter.on(WORKFLOW_EVENT, safeListener);
return (): void => {
this.emitter.removeListener(WORKFLOW_EVENT, safeListener);
};
}
/**
* Subscribe to events for a specific conversation only. Returns unsubscribe function.
*/
subscribeForConversation(conversationId: string, listener: Listener): () => void {
return this.subscribe((event: WorkflowEmitterEvent) => {
const eventConversationId = this.conversationMap.get(event.runId);
if (eventConversationId === conversationId) {
listener(event);
}
});
}
}
// ---------------------------------------------------------------------------
// Singleton
// ---------------------------------------------------------------------------
let instance: WorkflowEventEmitter | null = null;
export function getWorkflowEventEmitter(): WorkflowEventEmitter {
if (!instance) {
instance = new WorkflowEventEmitter();
}
return instance;
}
/**
* Reset singleton for testing.
*/
export function resetWorkflowEventEmitter(): void {
instance = null;
}

View file

@ -185,7 +185,7 @@ describe('Workflow Executor', () => {
// First call should be the workflow start notification
expect(calls[0][1]).toContain('🚀 **Starting workflow**: `test-workflow`');
expect(calls[0][1]).toContain('A test workflow');
expect(calls[0][1]).toContain('`command-one` → `command-two`');
// Steps are now shown visually in WorkflowProgressCard, not in the text notification
});
it('should execute each step and send notifications', async () => {

View file

@ -19,7 +19,7 @@ import type {
SingleStep,
WorkflowExecutionResult,
} from './types';
import { isParallelBlock, isSingleStep } from './types';
import { isParallelBlock } from './types';
import {
logWorkflowStart,
logStepStart,
@ -31,6 +31,8 @@ import {
logParallelBlockStart,
logParallelBlockComplete,
} from './logger';
import { getWorkflowEventEmitter } from './event-emitter';
import * as workflowEventDb from '../db/workflow-events';
/** Context for platform message sending */
interface SendMessageContext {
@ -101,6 +103,11 @@ function detectCompletionSignal(output: string, signal: string): boolean {
return endPattern.test(output) || ownLinePattern.test(output);
}
/** Strip internal completion signal tags before sending to user-facing output. */
function stripCompletionTags(content: string): string {
return content.replace(/<promise>[\s\S]*?<\/promise>/gi, '').trim();
}
/**
* Check if error message matches any pattern in the list
*/
@ -150,7 +157,7 @@ function logSendError(
const UNKNOWN_ERROR_THRESHOLD = 3;
/** Threshold for consecutive activity update failures before warning user */
const ACTIVITY_WARNING_THRESHOLD = 5;
const ACTIVITY_WARNING_THRESHOLD = 3;
/** Mutable counter for tracking consecutive unknown errors across calls */
interface UnknownErrorTracker {
@ -520,6 +527,7 @@ async function executeStepInternal(
console.log(`[WorkflowExecutor] Executing step ${stepId}: ${commandName}`);
await logStepStart(cwd, workflowRun.id, commandName, Number(stepId.split('.')[0]));
const stepStartTime = Date.now();
// Load command prompt
const promptResult = await loadCommandPrompt(cwd, commandName, configuredCommandFolder);
@ -634,6 +642,11 @@ async function executeStepInternal(
unknownErrorTracker
);
if (!sent) droppedMessageCount++;
// Send structured event to adapters that support it (Web UI)
if (platform.sendStructuredEvent) {
await platform.sendStructuredEvent(conversationId, msg);
}
}
await logTool(cwd, workflowRun.id, msg.toolName, msg.toolInput ?? {});
} else if (msg.type === 'result' && msg.sessionId) {
@ -672,6 +685,24 @@ async function executeStepInternal(
await logStepComplete(cwd, workflowRun.id, commandName, Number(stepId.split('.')[0]));
// Emit step_completed event (fire-and-forget)
const emitter = getWorkflowEventEmitter();
const stepIdx = Number(stepId.split('.')[0]);
emitter.emit({
type: 'step_completed',
runId: workflowRun.id,
stepIndex: stepIdx,
stepName: commandName,
duration: Date.now() - stepStartTime,
});
void workflowEventDb.createWorkflowEvent({
workflow_run_id: workflowRun.id,
event_type: 'step_completed',
step_index: stepIdx,
step_name: commandName,
data: { duration_ms: Date.now() - stepStartTime },
});
return {
commandName,
success: true,
@ -685,6 +716,24 @@ async function executeStepInternal(
errorType,
});
// Emit step_failed event (fire-and-forget)
const emitter = getWorkflowEventEmitter();
const stepIdx = Number(stepId.split('.')[0]);
emitter.emit({
type: 'step_failed',
runId: workflowRun.id,
stepIndex: stepIdx,
stepName: commandName,
error: err.message,
});
void workflowEventDb.createWorkflowEvent({
workflow_run_id: workflowRun.id,
event_type: 'step_failed',
step_index: stepIdx,
step_name: commandName,
data: { error: err.message },
});
// Add user-friendly hints based on error classification
let userHint = '';
const lowerMessage = err.message.toLowerCase();
@ -758,6 +807,9 @@ async function executeParallelBlock(
`[WorkflowExecutor] Starting parallel block with ${String(parallelSteps.length)} agents on ${cwd}`
);
const emitter = getWorkflowEventEmitter();
const totalAgents = parallelSteps.length;
// Spawn all agents concurrently - each gets its own fresh session
const results = await Promise.all(
parallelSteps.map(async (step, i) => {
@ -765,6 +817,25 @@ async function executeParallelBlock(
`[WorkflowExecutor] Spawning agent ${String(blockIndex)}.${String(i)}: ${step.command}`
);
// Emit parallel_agent_started
emitter.emit({
type: 'parallel_agent_started',
runId: workflowRun.id,
stepIndex: blockIndex,
agentIndex: i,
totalAgents,
agentName: step.command,
});
void workflowEventDb.createWorkflowEvent({
workflow_run_id: workflowRun.id,
event_type: 'parallel_agent_started',
step_index: blockIndex,
step_name: step.command,
data: { agentIndex: i, totalAgents },
});
const agentStart = Date.now();
// Each parallel step is an independent agent
// clearContext is always effectively true (fresh session)
const result = await executeStepInternal(
@ -781,6 +852,41 @@ async function executeParallelBlock(
issueContext
);
// Emit parallel_agent_completed or parallel_agent_failed
if (result.success) {
emitter.emit({
type: 'parallel_agent_completed',
runId: workflowRun.id,
stepIndex: blockIndex,
agentIndex: i,
agentName: step.command,
duration: Date.now() - agentStart,
});
void workflowEventDb.createWorkflowEvent({
workflow_run_id: workflowRun.id,
event_type: 'parallel_agent_completed',
step_index: blockIndex,
step_name: step.command,
data: { agentIndex: i, duration_ms: Date.now() - agentStart },
});
} else {
emitter.emit({
type: 'parallel_agent_failed',
runId: workflowRun.id,
stepIndex: blockIndex,
agentIndex: i,
agentName: step.command,
error: result.error,
});
void workflowEventDb.createWorkflowEvent({
workflow_run_id: workflowRun.id,
event_type: 'parallel_agent_failed',
step_index: blockIndex,
step_name: step.command,
data: { agentIndex: i, error: result.error },
});
}
return { index: i, result };
})
);
@ -848,10 +954,27 @@ async function executeLoopWorkflow(
await safeSendMessage(
platform,
conversationId,
`⏳ **Iteration ${String(i)}/${String(loop.max_iterations)}**`,
`\n⏳ **Iteration ${String(i)}/${String(loop.max_iterations)}**\n`,
workflowContext
);
// Emit loop_iteration_started
const loopEmitter = getWorkflowEventEmitter();
const iterationStart = Date.now();
loopEmitter.emit({
type: 'loop_iteration_started',
runId: workflowRun.id,
iteration: i,
maxIterations: loop.max_iterations,
});
void workflowEventDb.createWorkflowEvent({
workflow_run_id: workflowRun.id,
event_type: 'loop_iteration_started',
step_index: i - 1,
step_name: `iteration-${String(i)}`,
data: { iteration: i, maxIterations: loop.max_iterations },
});
// Determine session handling
const needsFreshSession = loop.fresh_context === true || i === 1;
const resumeSessionId = needsFreshSession ? undefined : currentSessionId;
@ -925,20 +1048,21 @@ async function executeLoopWorkflow(
}
if (msg.type === 'assistant' && msg.content) {
fullOutput += msg.content;
if (streamingMode === 'stream') {
fullOutput += msg.content; // Keep raw content for signal detection
const cleanedContent = stripCompletionTags(msg.content);
if (streamingMode === 'stream' && cleanedContent) {
const sent = await safeSendMessage(
platform,
conversationId,
msg.content,
cleanedContent,
workflowContext,
unknownErrorTracker
);
if (!sent) droppedMessageCount++;
} else {
assistantMessages.push(msg.content);
} else if (streamingMode === 'batch' && cleanedContent) {
assistantMessages.push(cleanedContent);
}
await logAssistant(cwd, workflowRun.id, msg.content);
await logAssistant(cwd, workflowRun.id, msg.content); // Log raw for debugging
} else if (msg.type === 'tool' && msg.toolName) {
if (streamingMode === 'stream') {
const toolMessage = formatToolCall(msg.toolName, msg.toolInput);
@ -992,6 +1116,27 @@ async function executeLoopWorkflow(
// Check for completion signal
if (detectCompletionSignal(fullOutput, loop.until)) {
console.log(`[WorkflowExecutor] Completion signal detected at iteration ${String(i)}`);
// Emit loop_iteration_completed with completionDetected
loopEmitter.emit({
type: 'loop_iteration_completed',
runId: workflowRun.id,
iteration: i,
duration: Date.now() - iterationStart,
completionDetected: true,
});
void workflowEventDb.createWorkflowEvent({
workflow_run_id: workflowRun.id,
event_type: 'loop_iteration_completed',
step_index: i - 1,
step_name: `iteration-${String(i)}`,
data: {
iteration: i,
duration_ms: Date.now() - iterationStart,
completionDetected: true,
},
});
await workflowDb.completeWorkflowRun(workflowRun.id);
await logWorkflowComplete(cwd, workflowRun.id);
await sendCriticalMessage(
@ -1014,6 +1159,26 @@ async function executeLoopWorkflow(
}
await logStepComplete(cwd, workflowRun.id, `iteration-${String(i)}`, i - 1);
// Emit loop_iteration_completed
loopEmitter.emit({
type: 'loop_iteration_completed',
runId: workflowRun.id,
iteration: i,
duration: Date.now() - iterationStart,
completionDetected: false,
});
void workflowEventDb.createWorkflowEvent({
workflow_run_id: workflowRun.id,
event_type: 'loop_iteration_completed',
step_index: i - 1,
step_name: `iteration-${String(i)}`,
data: {
iteration: i,
duration_ms: Date.now() - iterationStart,
completionDetected: false,
},
});
} catch (error) {
const err = error as Error;
console.error(`[WorkflowExecutor] Loop iteration ${String(i)} failed:`, err.message);
@ -1202,6 +1367,43 @@ export async function executeWorkflow(
console.log(`[WorkflowExecutor] Starting workflow: ${workflow.name} (${workflowRun.id})`);
await logWorkflowStart(cwd, workflowRun.id, workflow.name, userMessage);
// Register run with emitter and emit workflow_started
const emitter = getWorkflowEventEmitter();
const workflowStartTime = Date.now();
emitter.registerRun(workflowRun.id, conversationId);
const totalSteps = workflow.steps ? workflow.steps.length : 0;
const isLoop = !!workflow.loop;
emitter.emit({
type: 'workflow_started',
runId: workflowRun.id,
workflowName: workflow.name,
conversationId: conversationDbId,
totalSteps,
isLoop,
});
void workflowEventDb.createWorkflowEvent({
workflow_run_id: workflowRun.id,
event_type: 'workflow_started',
data: { workflowName: workflow.name, totalSteps, isLoop },
});
// Set status to running now that execution has started
try {
await workflowDb.updateWorkflowRun(workflowRun.id, { status: 'running' });
} catch (dbError) {
console.error('[WorkflowExecutor] Failed to set workflow status to running', {
error: (dbError as Error).message,
workflowRunId: workflowRun.id,
});
await sendCriticalMessage(
platform,
conversationId,
'Workflow blocked: Unable to update status. Please try again.'
);
return { success: false, error: 'Database error setting workflow to running' };
}
// Context for error logging
const workflowContext: SendMessageContext = {
workflowId: workflowRun.id,
@ -1235,14 +1437,18 @@ export async function executeWorkflow(
}
}
// Add workflow start message
startupMessage += `🚀 **Starting workflow**: \`${workflow.name}\`\n\n> ${workflow.description}`;
// Add steps info - use type narrowing from discriminated union
const stepsInfo = workflow.steps
? `\n\n**Steps**: ${workflow.steps.map(s => (isSingleStep(s) ? `\`${s.command}\`` : `[${String(s.parallel.length)} parallel]`)).join(' → ')}`
: `\n\n**Loop**: until \`${workflow.loop.until}\` (max ${String(workflow.loop.max_iterations)} iterations)`;
startupMessage += stepsInfo;
// Add workflow start message (steps shown visually in WorkflowProgressCard)
// Strip routing metadata from description (Use when:, Handles:, NOT for:, Capability:, Triggers:)
const cleanDescription = (workflow.description ?? '')
.split('\n')
.filter(
line =>
!/^\s*(Use when|Handles|NOT for|Capability|Triggers)[:\s]/i.test(line) && line.trim()
)
.join('\n')
.trim();
const descriptionText = cleanDescription || workflow.name;
startupMessage += `🚀 **Starting workflow**: \`${workflow.name}\`\n\n> ${descriptionText}`;
// Send consolidated message - use critical send with limited retries (1 retry max)
// to avoid blocking workflow execution while still catching transient failures
@ -1309,6 +1515,22 @@ export async function executeWorkflow(
// Log parallel block start
await logParallelBlockStart(cwd, workflowRun.id, i, stepCommands);
// Emit step_started for the parallel block
emitter.emit({
type: 'step_started',
runId: workflowRun.id,
stepIndex: i,
stepName: `parallel(${stepCommands.join(', ')})`,
totalSteps: steps.length,
});
void workflowEventDb.createWorkflowEvent({
workflow_run_id: workflowRun.id,
event_type: 'step_started',
step_index: i,
step_name: `parallel(${stepCommands.join(', ')})`,
data: { totalSteps: steps.length, parallelAgents: stepCount },
});
// Notify user
const stepNames = parallelSteps.map(s => `\`${s.command}\``).join(', ');
await safeSendMessage(
@ -1347,6 +1569,22 @@ export async function executeWorkflow(
const errorMsg = `${String(failures.length)} parallel step(s) failed:\n${failureDetails.join('\n')}`;
await logWorkflowError(cwd, workflowRun.id, errorMsg);
// Emit workflow_failed for parallel block failure
emitter.emit({
type: 'workflow_failed',
runId: workflowRun.id,
workflowName: workflow.name,
error: errorMsg,
stepIndex: i,
});
void workflowEventDb.createWorkflowEvent({
workflow_run_id: workflowRun.id,
event_type: 'workflow_failed',
step_index: i,
data: { error: errorMsg },
});
emitter.unregisterRun(workflowRun.id);
// Record failure in database (non-critical - log but don't prevent user notification)
try {
await workflowDb.failWorkflowRun(workflowRun.id, errorMsg);
@ -1377,6 +1615,22 @@ export async function executeWorkflow(
}));
await logParallelBlockComplete(cwd, workflowRun.id, i, blockResults);
// Emit step_completed for the parallel block
emitter.emit({
type: 'step_completed',
runId: workflowRun.id,
stepIndex: i,
stepName: `parallel(${stepCommands.join(', ')})`,
duration: 0, // Duration tracked per-agent, not per-block
});
void workflowEventDb.createWorkflowEvent({
workflow_run_id: workflowRun.id,
event_type: 'step_completed',
step_index: i,
step_name: `parallel(${stepCommands.join(', ')})`,
data: {},
});
// All parallel steps succeeded - no session to carry forward
currentSessionId = undefined;
} else {
@ -1395,6 +1649,22 @@ export async function executeWorkflow(
);
}
// Emit step_started event
emitter.emit({
type: 'step_started',
runId: workflowRun.id,
stepIndex: i,
stepName: step.command,
totalSteps: steps.length,
});
void workflowEventDb.createWorkflowEvent({
workflow_run_id: workflowRun.id,
event_type: 'step_started',
step_index: i,
step_name: step.command,
data: { totalSteps: steps.length },
});
const result = await executeStepInternal(
platform,
conversationId,
@ -1412,6 +1682,23 @@ export async function executeWorkflow(
if (!result.success) {
await logWorkflowError(cwd, workflowRun.id, result.error);
// Emit workflow_failed for step failure
emitter.emit({
type: 'workflow_failed',
runId: workflowRun.id,
workflowName: workflow.name,
error: result.error,
stepIndex: i,
});
void workflowEventDb.createWorkflowEvent({
workflow_run_id: workflowRun.id,
event_type: 'workflow_failed',
step_index: i,
step_name: result.commandName,
data: { error: result.error },
});
emitter.unregisterRun(workflowRun.id);
// Record failure in database (non-critical - log but don't prevent user notification)
try {
await workflowDb.failWorkflowRun(workflowRun.id, result.error);
@ -1489,6 +1776,20 @@ export async function executeWorkflow(
console.log(`[WorkflowExecutor] Workflow completed: ${workflow.name}`);
// Emit workflow_completed
emitter.emit({
type: 'workflow_completed',
runId: workflowRun.id,
workflowName: workflow.name,
duration: Date.now() - workflowStartTime,
});
void workflowEventDb.createWorkflowEvent({
workflow_run_id: workflowRun.id,
event_type: 'workflow_completed',
data: { duration_ms: Date.now() - workflowStartTime },
});
emitter.unregisterRun(workflowRun.id);
// Safety net: Commit any artifacts created during workflow but not yet committed
await commitWorkflowArtifacts(
platform,
@ -1533,6 +1834,21 @@ export async function executeWorkflow(
});
}
// Emit workflow_failed event
const emitter = getWorkflowEventEmitter();
emitter.emit({
type: 'workflow_failed',
runId: workflowRun.id,
workflowName: workflow.name,
error: err.message,
});
void workflowEventDb.createWorkflowEvent({
workflow_run_id: workflowRun.id,
event_type: 'workflow_failed',
data: { error: err.message },
});
emitter.unregisterRun(workflowRun.id);
// Notify user about the failure
const delivered = await sendCriticalMessage(
platform,
@ -1572,6 +1888,23 @@ async function commitWorkflowArtifacts(
if (committed) {
console.log(`[WorkflowExecutor] Committed remaining artifacts for workflow: ${workflowName}`);
// Emit workflow_artifact event
const emitter = getWorkflowEventEmitter();
emitter.emit({
type: 'workflow_artifact',
runId: workflowId,
artifactType: 'commit',
label: `Auto-commit workflow artifacts (${workflowName})`,
});
void workflowEventDb.createWorkflowEvent({
workflow_run_id: workflowId,
event_type: 'workflow_artifact',
data: {
artifactType: 'commit',
label: `Auto-commit workflow artifacts (${workflowName})`,
},
});
// Push the committed artifacts
try {
await execFileAsync('git', ['-C', cwd, 'push', 'origin', 'HEAD'], { timeout: 30000 });

View file

@ -9,6 +9,10 @@
* types to enforce mutual exclusivity between steps and loop at compile time.
*/
export type WorkflowRunStatus = 'pending' | 'running' | 'completed' | 'failed';
export type WorkflowStepStatus = 'pending' | 'running' | 'completed' | 'failed';
export type ArtifactType = 'pr' | 'commit' | 'file_created' | 'file_modified' | 'branch';
/**
* A single step with a command
*/
@ -97,9 +101,10 @@ export interface WorkflowRun {
id: string;
workflow_name: string;
conversation_id: string;
parent_conversation_id: string | null;
codebase_id: string | null;
current_step_index: number;
status: 'running' | 'completed' | 'failed';
status: WorkflowRunStatus;
user_message: string; // Original user intent
metadata: Record<string, unknown>;
started_at: Date;

View file

@ -0,0 +1,655 @@
/**
* Web platform adapter implementing IPlatformAdapter with SSE stream management.
* Bridge between the orchestrator and the React frontend via Server-Sent Events.
*/
import type { IWebPlatformAdapter, MessageChunk, WorkflowEmitterEvent } from '@archon/core';
import { getWorkflowEventEmitter } from '@archon/core';
interface SSEWriter {
writeSSE(data: { data: string; event?: string; id?: string }): Promise<void>;
close(): Promise<void>;
readonly closed: boolean;
}
export class WebAdapter implements IWebPlatformAdapter {
private streams = new Map<string, SSEWriter>();
private messageBuffer = new Map<string, string[]>();
private assistantBuffer = new Map<
string,
{
segments: {
content: string;
toolCalls: {
name: string;
input: Record<string, unknown>;
startedAt: number;
duration?: number;
}[];
}[];
}
>();
private dbIdMap = new Map<string, string>(); // platform_conversation_id → DB UUID
private dispatchBuffer = new Map<
string,
{ workerConversationId: string; workflowName: string }
>();
private unsubscribeWorkflowEvents: (() => void) | null = null;
private outputCallbacks = new Map<string, (text: string) => void>();
private cleanupTimers = new Map<string, ReturnType<typeof setTimeout>>();
private zombieReaperHandle: ReturnType<typeof setInterval> | null = null;
/**
* Register an SSE stream for a conversation.
* Closes any existing stream (browser refresh / new tab replaces old).
*/
registerStream(conversationId: string, stream: SSEWriter): void {
const existing = this.streams.get(conversationId);
if (existing && !existing.closed) {
existing.close().catch((e: unknown) => {
console.warn('[Web] SSE write failed', { conversationId, error: (e as Error).message });
});
}
this.streams.set(conversationId, stream);
// Cancel pending cleanup — client reconnected
const pendingCleanup = this.cleanupTimers.get(conversationId);
if (pendingCleanup) {
clearTimeout(pendingCleanup);
this.cleanupTimers.delete(conversationId);
}
// Flush buffered events
const buffered = this.messageBuffer.get(conversationId);
if (buffered) {
this.messageBuffer.delete(conversationId);
void this.flushBufferedMessages(conversationId, stream, buffered);
}
}
removeStream(conversationId: string): void {
this.streams.delete(conversationId);
// Schedule buffer cleanup after delay (allows reconnection without data loss)
this.scheduleCleanup(conversationId, 60_000);
}
/**
* Map a platform conversation ID to its database UUID for message persistence.
*/
setConversationDbId(platformConversationId: string, dbId: string): void {
this.dbIdMap.set(platformConversationId, dbId);
}
async sendMessage(conversationId: string, message: string): Promise<void> {
// Skip formatted tool call text - Web adapter gets structured data via sendStructuredEvent()
if (message.startsWith('\u{1F527}')) {
return;
}
// Strip isolation context line (📍) - cwd is shown in the header already.
// The executor sends it combined with the 🚀 workflow start, so strip just
// the 📍 line and keep the rest (workflow name + description).
if (message.startsWith('\u{1F4CD}')) {
const stripped = message.replace(/^\u{1F4CD}[^\n]*\n\n?/u, '');
if (!stripped.trim()) return;
message = stripped;
}
// Buffer assistant text for persistence (segment-based to preserve message structure)
const buf = this.assistantBuffer.get(conversationId) ?? { segments: [] };
const lastSeg = buf.segments[buf.segments.length - 1];
const isWorkflowStatus = /^[\u{1F680}\u{2705}]/u.test(message);
// Start a new segment when:
// 1. No segments yet
// 2. Previous segment has tool calls (text after tool = new message in live view)
// 3. This is a workflow status message (🚀/✅ should be its own bubble)
// 4. Previous segment was a workflow status (next text should be separate)
const needsNewSegment =
!lastSeg ||
lastSeg.toolCalls.length > 0 ||
isWorkflowStatus ||
/^[\u{1F680}\u{2705}]/u.test(lastSeg.content);
if (needsNewSegment) {
buf.segments.push({ content: message, toolCalls: [] });
} else {
lastSeg.content += message;
}
this.assistantBuffer.set(conversationId, buf);
// Prevent unbounded buffer growth — force flush if too many segments
if (buf.segments.length > 50) {
console.warn('[Web] Assistant buffer overflow, force-flushing', {
conversationId,
segments: buf.segments.length,
});
void this.flushAssistantMessage(conversationId);
}
const event = JSON.stringify({
type: 'text',
content: message,
isComplete: true,
timestamp: Date.now(),
});
// Forward output to registered callback (for event bridge preview)
const callback = this.outputCallbacks.get(conversationId);
if (callback) {
try {
callback(message);
} catch (e: unknown) {
console.warn('[Web] Output callback failed', {
conversationId,
error: (e as Error).message,
});
}
}
await this.emitSSE(conversationId, event);
}
async sendStructuredEvent(conversationId: string, chunk: MessageChunk): Promise<void> {
let event: string;
if (chunk.type === 'tool' && chunk.toolName) {
// Buffer tool call for persistence (add to current segment)
const buf = this.assistantBuffer.get(conversationId) ?? { segments: [] };
if (buf.segments.length === 0) {
buf.segments.push({ content: '', toolCalls: [] });
}
const lastSeg = buf.segments[buf.segments.length - 1];
// Finalize duration on previous running tool (agent moved on to next tool)
const now = Date.now();
const prevTool = lastSeg.toolCalls[lastSeg.toolCalls.length - 1];
if (prevTool && prevTool.duration === undefined) {
prevTool.duration = now - prevTool.startedAt;
}
lastSeg.toolCalls.push({
name: chunk.toolName,
input: chunk.toolInput ?? {},
startedAt: now,
});
this.assistantBuffer.set(conversationId, buf);
event = JSON.stringify({
type: 'tool_call',
name: chunk.toolName,
input: chunk.toolInput ?? {},
timestamp: Date.now(),
});
} else if (chunk.type === 'result' && chunk.sessionId) {
event = JSON.stringify({
type: 'session_info',
sessionId: chunk.sessionId,
timestamp: Date.now(),
});
} else if (chunk.type === 'workflow_dispatch') {
event = JSON.stringify({
type: 'workflow_dispatch',
workerConversationId: chunk.workerConversationId,
workflowName: chunk.workflowName,
timestamp: Date.now(),
});
// Buffer dispatch for persistence
this.dispatchBuffer.set(conversationId, {
workerConversationId: chunk.workerConversationId,
workflowName: chunk.workflowName,
});
} else {
return;
}
await this.emitSSE(conversationId, event);
}
async ensureThread(originalConversationId: string): Promise<string> {
return originalConversationId;
}
getStreamingMode(): 'stream' | 'batch' {
return 'stream';
}
getPlatformType(): string {
return 'web';
}
async start(): Promise<void> {
this.subscribeToWorkflowEvents();
// Reap zombie streams every 5 minutes
this.zombieReaperHandle = setInterval(() => {
for (const [id, stream] of this.streams) {
if (stream.closed) {
this.removeStream(id);
}
}
}, 300_000);
console.log('[Web] Web adapter ready');
}
stop(): void {
// Stop zombie stream reaper
if (this.zombieReaperHandle) {
clearInterval(this.zombieReaperHandle);
this.zombieReaperHandle = null;
}
// Unsubscribe from workflow events
if (this.unsubscribeWorkflowEvents) {
this.unsubscribeWorkflowEvents();
this.unsubscribeWorkflowEvents = null;
}
for (const [id, stream] of this.streams) {
if (!stream.closed) {
stream.close().catch((e: unknown) => {
console.warn('[Web] SSE write failed', {
conversationId: id,
error: (e as Error).message,
});
});
}
console.log(`[Web] Closed stream for ${id}`);
}
this.streams.clear();
this.messageBuffer.clear();
for (const timer of this.cleanupTimers.values()) {
clearTimeout(timer);
}
this.cleanupTimers.clear();
console.log('[Web] Web adapter stopped');
}
/**
* Emit a lock event to the SSE stream for a conversation.
* Called by API routes based on acquireLock() return status.
*/
emitLockEvent(conversationId: string, locked: boolean, queuePosition?: number): void {
if (!locked) {
void this.flushAssistantMessage(conversationId);
}
const event = JSON.stringify({
type: 'conversation_lock',
conversationId,
locked,
queuePosition,
timestamp: Date.now(),
});
const stream = this.streams.get(conversationId);
if (stream && !stream.closed) {
stream.writeSSE({ data: event }).catch((e: unknown) => {
console.warn('[Web] Critical SSE write failed, buffering for reconnect', {
conversationId,
error: (e as Error).message,
});
this.bufferMessage(conversationId, event);
this.streams.delete(conversationId);
});
}
}
hasActiveStream(conversationId: string): boolean {
const stream = this.streams.get(conversationId);
return stream !== undefined && !stream.closed;
}
/**
* Subscribe to WorkflowEventEmitter and forward events to SSE streams.
*/
private subscribeToWorkflowEvents(): void {
const emitter = getWorkflowEventEmitter();
this.unsubscribeWorkflowEvents = emitter.subscribe((event: WorkflowEmitterEvent) => {
const conversationId = emitter.getConversationId(event.runId);
if (!conversationId) return;
const sseEvent = this.mapWorkflowEvent(event);
if (sseEvent) {
this.emitWorkflowEvent(conversationId, sseEvent);
}
});
}
/**
* Map a WorkflowEmitterEvent to an SSE event JSON string.
*/
private mapWorkflowEvent(event: WorkflowEmitterEvent): string | null {
switch (event.type) {
case 'workflow_started':
case 'workflow_completed':
case 'workflow_failed':
return JSON.stringify({
type: 'workflow_status',
runId: event.runId,
workflowName: event.workflowName,
status:
event.type === 'workflow_started'
? 'running'
: event.type === 'workflow_completed'
? 'completed'
: 'failed',
error: event.type === 'workflow_failed' ? event.error : undefined,
timestamp: Date.now(),
});
case 'step_started':
return JSON.stringify({
type: 'workflow_step',
runId: event.runId,
step: event.stepIndex,
total: event.totalSteps,
name: event.stepName,
status: 'running',
timestamp: Date.now(),
});
case 'step_completed':
return JSON.stringify({
type: 'workflow_step',
runId: event.runId,
step: event.stepIndex,
total: 0,
name: event.stepName,
status: 'completed',
duration: event.duration,
timestamp: Date.now(),
});
case 'step_failed':
return JSON.stringify({
type: 'workflow_step',
runId: event.runId,
step: event.stepIndex,
total: 0,
name: event.stepName,
status: 'failed',
timestamp: Date.now(),
});
case 'parallel_agent_started':
case 'parallel_agent_completed':
case 'parallel_agent_failed':
return JSON.stringify({
type: 'parallel_agent',
runId: event.runId,
step: event.stepIndex,
agentIndex: event.agentIndex,
totalAgents: event.type === 'parallel_agent_started' ? event.totalAgents : 0,
name: event.agentName,
status:
event.type === 'parallel_agent_started'
? 'running'
: event.type === 'parallel_agent_completed'
? 'completed'
: 'failed',
duration: event.type === 'parallel_agent_completed' ? event.duration : undefined,
error: event.type === 'parallel_agent_failed' ? event.error : undefined,
timestamp: Date.now(),
});
case 'loop_iteration_started':
return JSON.stringify({
type: 'workflow_step',
runId: event.runId,
step: event.iteration - 1,
total: event.maxIterations,
name: `iteration-${String(event.iteration)}`,
status: 'running',
iteration: event.iteration,
timestamp: Date.now(),
});
case 'loop_iteration_completed':
return JSON.stringify({
type: 'workflow_step',
runId: event.runId,
step: event.iteration - 1,
total: 0,
name: `iteration-${String(event.iteration)}`,
status: 'completed',
duration: event.duration,
iteration: event.iteration,
timestamp: Date.now(),
});
case 'workflow_artifact':
return JSON.stringify({
type: 'workflow_artifact',
runId: event.runId,
artifactType: event.artifactType,
label: event.label,
url: event.url,
path: event.path,
timestamp: Date.now(),
});
default: {
const exhaustiveCheck: never = event;
console.warn('[Web] Unhandled workflow event type', {
type: (exhaustiveCheck as { type: string }).type,
});
return null;
}
}
}
/**
* Emit a workflow event to the SSE stream for a conversation. Fire-and-forget.
*/
private emitWorkflowEvent(conversationId: string, event: string): void {
const stream = this.streams.get(conversationId);
if (stream && !stream.closed) {
stream.writeSSE({ data: event }).catch((e: unknown) => {
console.warn('[Web] SSE workflow event write failed, buffering for reconnect', {
conversationId,
error: (e as Error).message,
});
this.bufferMessage(conversationId, event);
this.streams.delete(conversationId);
});
}
}
/**
* Flush buffered assistant segments to the database as individual message rows.
* Each segment maps to one ChatMessage in the frontend, preserving the same
* structure as the live streaming view (text+tools interleaving).
*/
private async flushAssistantMessage(conversationId: string): Promise<void> {
const buf = this.assistantBuffer.get(conversationId);
this.assistantBuffer.delete(conversationId);
if (!buf || buf.segments.length === 0) return;
const dbId = this.dbIdMap.get(conversationId);
if (!dbId) {
console.warn('[Web] Cannot persist assistant message: no DB ID mapping', {
conversationId,
segmentCount: buf.segments.length,
});
return;
}
// Finalize any remaining tool durations (last tool in each segment)
const now = Date.now();
for (const seg of buf.segments) {
const lastTool = seg.toolCalls[seg.toolCalls.length - 1];
if (lastTool && lastTool.duration === undefined) {
lastTool.duration = now - lastTool.startedAt;
}
}
try {
const { addMessage } = await import('@archon/core/db/messages');
for (const seg of buf.segments) {
if (!seg.content && seg.toolCalls.length === 0) continue;
// Store tool calls with name, input, duration (strip startedAt - not needed)
const toolCalls = seg.toolCalls.map(tc => ({
name: tc.name,
input: tc.input,
duration: tc.duration,
}));
const isDispatchMsg = seg.content.startsWith('\u{1F680}');
const dispatch = isDispatchMsg ? this.dispatchBuffer.get(conversationId) : undefined;
const metadata = {
...(toolCalls.length > 0 ? { toolCalls } : {}),
...(dispatch ? { workflowDispatch: dispatch } : {}),
};
await addMessage(dbId, 'assistant', seg.content, metadata);
}
this.dispatchBuffer.delete(conversationId);
} catch (e: unknown) {
console.error('[Web] Message persistence failed (data loss)', {
conversationId,
error: (e as Error).message,
});
void this.emitSSE(
conversationId,
JSON.stringify({
type: 'warning',
message: 'Assistant response could not be saved to history',
timestamp: Date.now(),
})
);
}
}
/**
* Bridge workflow events from a worker conversation to a parent conversation's SSE stream.
* Forwards compact progress events (step progress, status) and output previews.
*/
setupEventBridge(workerConversationId: string, parentConversationId: string): () => void {
const emitter = getWorkflowEventEmitter();
const unsubscribe = emitter.subscribeForConversation(
workerConversationId,
(event: WorkflowEmitterEvent) => {
const sseEvent = this.mapWorkflowEvent(event);
if (sseEvent) {
// Send to parent's stream (not worker's)
const parentStream = this.streams.get(parentConversationId);
if (parentStream && !parentStream.closed) {
parentStream.writeSSE({ data: sseEvent }).catch((e: unknown) => {
console.warn('[Web] SSE bridge write failed, buffering for reconnect', {
conversationId: parentConversationId,
error: (e as Error).message,
});
this.bufferMessage(parentConversationId, sseEvent);
this.streams.delete(parentConversationId);
});
}
}
}
);
return unsubscribe;
}
registerOutputCallback(conversationId: string, callback: (text: string) => void): void {
this.outputCallbacks.set(conversationId, callback);
}
removeOutputCallback(conversationId: string): void {
this.outputCallbacks.delete(conversationId);
}
/**
* Flush buffered messages to a newly connected stream.
* Stops on first write failure and re-buffers the remaining messages.
*/
private async flushBufferedMessages(
conversationId: string,
stream: SSEWriter,
messages: string[]
): Promise<void> {
for (let i = 0; i < messages.length; i++) {
try {
await stream.writeSSE({ data: messages[i] });
} catch (e: unknown) {
console.warn('[Web] SSE flush failed, re-buffering remaining messages', {
conversationId,
error: (e as Error).message,
flushed: i,
remaining: messages.length - i,
});
const remaining = messages.slice(i);
for (const msg of remaining) {
this.bufferMessage(conversationId, msg);
}
this.streams.delete(conversationId);
return;
}
}
}
/**
* Schedule cleanup of all buffers for a conversation after a delay.
* If the client reconnects before the timer fires, the cleanup is cancelled.
*/
private scheduleCleanup(conversationId: string, delayMs: number): void {
// Cancel any existing timer for this conversation
const existing = this.cleanupTimers.get(conversationId);
if (existing) {
clearTimeout(existing);
}
const timer = setTimeout(() => {
try {
this.cleanupTimers.delete(conversationId);
// Only clean up if stream is still absent (client didn't reconnect)
if (!this.streams.has(conversationId)) {
this.messageBuffer.delete(conversationId);
this.assistantBuffer.delete(conversationId);
this.dispatchBuffer.delete(conversationId);
this.dbIdMap.delete(conversationId);
this.outputCallbacks.delete(conversationId);
}
} catch (e: unknown) {
console.warn('[Web] Cleanup timer failed', {
conversationId,
error: (e as Error).message,
});
}
}, delayMs);
this.cleanupTimers.set(conversationId, timer);
}
async emitSSE(conversationId: string, event: string): Promise<void> {
const stream = this.streams.get(conversationId);
if (stream && !stream.closed) {
try {
await stream.writeSSE({ data: event });
} catch (e: unknown) {
console.warn('[Web] SSE write failed, buffering event', {
conversationId,
error: (e as Error).message,
});
this.removeStream(conversationId);
this.bufferMessage(conversationId, event);
}
} else {
if (stream?.closed) {
this.removeStream(conversationId);
}
this.bufferMessage(conversationId, event);
}
}
private bufferMessage(conversationId: string, event: string): void {
if (!this.messageBuffer.has(conversationId) && this.messageBuffer.size > 200) {
console.warn('[Web] Too many buffered conversations, skipping buffer', { conversationId });
return;
}
const buffer = this.messageBuffer.get(conversationId) ?? [];
buffer.push(event);
this.messageBuffer.set(conversationId, buffer);
// Cap buffer size to prevent memory leaks
if (buffer.length > 100) {
console.warn('[Web] Message buffer overflow, dropping oldest event', { conversationId });
buffer.shift();
}
}
}

View file

@ -10,9 +10,11 @@ import 'dotenv/config';
import { Hono } from 'hono';
import { TelegramAdapter } from './adapters/telegram';
import { TestAdapter } from './adapters/test';
import { WebAdapter } from './adapters/web';
import { GitHubAdapter } from './adapters/github';
import { DiscordAdapter } from './adapters/discord';
import { SlackAdapter } from './adapters/slack';
import { registerApiRoutes } from './routes/api';
import {
handleMessage,
pool,
@ -107,18 +109,21 @@ async function main(): Promise<void> {
const testAdapter = new TestAdapter();
await testAdapter.start();
// Initialize web adapter (always enabled)
const webAdapter = new WebAdapter();
await webAdapter.start();
// Check that at least one platform is configured
const hasTelegram = Boolean(process.env.TELEGRAM_BOT_TOKEN);
const hasDiscord = Boolean(process.env.DISCORD_BOT_TOKEN);
const hasGitHub = Boolean(process.env.GITHUB_TOKEN && process.env.WEBHOOK_SECRET);
if (!hasTelegram && !hasDiscord && !hasGitHub) {
console.error('[App] No platform adapters configured.');
console.error('[App] You must configure at least one platform:');
console.error('[App] - Telegram: Set TELEGRAM_BOT_TOKEN');
console.error('[App] - Discord: Set DISCORD_BOT_TOKEN');
console.error('[App] - GitHub: Set GITHUB_TOKEN and WEBHOOK_SECRET');
process.exit(1);
console.warn('[App] No platform adapters configured.');
console.warn('[App] Web UI is available. To enable other platforms:');
console.warn('[App] - Telegram: Set TELEGRAM_BOT_TOKEN');
console.warn('[App] - Discord: Set DISCORD_BOT_TOKEN');
console.warn('[App] - GitHub: Set GITHUB_TOKEN and WEBHOOK_SECRET');
}
// Initialize GitHub adapter (conditional)
@ -274,6 +279,9 @@ async function main(): Promise<void> {
return c.json({ error: 'Internal server error' }, 500);
});
// Register Web UI API routes
registerApiRoutes(app, webAdapter, lockManager);
// GitHub webhook endpoint
if (github) {
app.post('/webhooks/github', async c => {
@ -396,9 +404,26 @@ async function main(): Promise<void> {
return c.json({ success: true, mode });
});
// Serve web UI static files in production
// Uses import.meta.dir for absolute path (CWD varies with bun --filter)
if (process.env.NODE_ENV === 'production' || !process.env.WEB_UI_DEV) {
const { serveStatic } = await import('hono/bun');
const pathModule = await import('path');
const webDistPath = pathModule.join(
pathModule.dirname(pathModule.dirname(import.meta.dir)),
'web',
'dist'
);
app.use('/assets/*', serveStatic({ root: webDistPath }));
// SPA fallback - serve index.html for unmatched routes (after all API routes)
app.get('*', serveStatic({ root: webDistPath, path: 'index.html' }));
}
const server = Bun.serve({
fetch: app.fetch,
port,
idleTimeout: 255, // Max value (seconds) - prevents SSE connections from being killed
});
console.log(`[Hono] Server listening on port ${String(server.port)}`);
@ -434,6 +459,7 @@ async function main(): Promise<void> {
telegram?.stop();
discord?.stop();
slack?.stop();
webAdapter.stop();
} catch (error) {
console.error('[App] Error stopping adapters:', error);
}
@ -454,7 +480,7 @@ async function main(): Promise<void> {
process.once('SIGTERM', shutdown);
// Show active platforms
const activePlatforms = [];
const activePlatforms = ['Web'];
if (telegram) activePlatforms.push('Telegram');
if (discord) activePlatforms.push('Discord');
if (slack) activePlatforms.push('Slack');

View file

@ -0,0 +1,612 @@
/**
* REST API routes for the Archon Web UI.
* Provides conversation, codebase, and SSE streaming endpoints.
*/
import type { Hono } from 'hono';
import { streamSSE } from 'hono/streaming';
import { cors } from 'hono/cors';
import type { WebAdapter } from '../adapters/web';
import { rm } from 'fs/promises';
import { normalize } from 'path';
import type { Context } from 'hono';
import type { ConversationLockManager } from '@archon/core';
import {
handleMessage,
getDatabaseType,
loadConfig,
discoverWorkflows,
cloneRepository,
registerRepository,
removeWorktree,
ConversationNotFoundError,
getArchonWorkspacesPath,
} from '@archon/core';
import * as conversationDb from '@archon/core/db/conversations';
import * as codebaseDb from '@archon/core/db/codebases';
import * as isolationEnvDb from '@archon/core/db/isolation-environments';
import * as workflowDb from '@archon/core/db/workflows';
import * as workflowEventDb from '@archon/core/db/workflow-events';
import * as messageDb from '@archon/core/db/messages';
/**
* Register all /api/* routes on the Hono app.
*/
export function registerApiRoutes(
app: Hono,
webAdapter: WebAdapter,
lockManager: ConversationLockManager
): void {
function apiError(
c: Context,
status: 400 | 404 | 500,
message: string,
detail?: string
): Response {
return c.json({ error: message, ...(detail ? { detail } : {}) }, status);
}
// CORS for Web UI — allow-all is fine for a single-developer tool.
// Override with WEB_UI_ORIGIN env var to restrict if exposing publicly.
app.use('/api/*', cors({ origin: process.env.WEB_UI_ORIGIN || '*' }));
// Shared lock/dispatch/error handling for message and workflow endpoints
async function dispatchToOrchestrator(
conversationId: string,
message: string
): Promise<{ accepted: boolean; status: string }> {
const result = await lockManager.acquireLock(conversationId, async () => {
try {
await handleMessage(webAdapter, conversationId, message);
} catch (error) {
console.error('[API] handleMessage failed', {
conversationId,
error,
});
try {
await webAdapter.emitSSE(
conversationId,
JSON.stringify({
type: 'error',
message: `Failed to process message: ${(error as Error).message ?? 'unknown error'}. Try /reset if the problem persists.`,
classification: 'transient',
timestamp: Date.now(),
})
);
} catch (sseError) {
console.error('[API] Failed to emit error SSE', {
conversationId,
sseError,
});
}
} finally {
webAdapter.emitLockEvent(conversationId, false);
}
});
if (result.status === 'queued-conversation' || result.status === 'queued-capacity') {
webAdapter.emitLockEvent(conversationId, true);
}
return { accepted: true, status: result.status };
}
// GET /api/conversations - List conversations
app.get('/api/conversations', async c => {
try {
const platformType = c.req.query('platform') ?? undefined;
const codebaseId = c.req.query('codebaseId') ?? undefined;
const conversations = await conversationDb.listConversations(50, platformType, codebaseId);
return c.json(conversations);
} catch (error) {
console.error('[API] Failed to list conversations', { error });
return c.json({ error: 'Failed to list conversations' }, 500);
}
});
// POST /api/conversations - Create new conversation
app.post('/api/conversations', async c => {
try {
const body: { codebaseId?: unknown } = await c.req.json();
const codebaseId = typeof body.codebaseId === 'string' ? body.codebaseId : undefined;
// Validate codebase exists if provided
if (codebaseId) {
const codebase = await codebaseDb.getCodebase(codebaseId);
if (!codebase) {
return apiError(c, 400, 'Codebase not found', `No codebase with id "${codebaseId}"`);
}
}
const conversationId = `web-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const conversation = await conversationDb.getOrCreateConversation(
'web',
conversationId,
codebaseId
);
return c.json({ conversationId: conversation.platform_conversation_id, id: conversation.id });
} catch (error) {
console.error('[API] Failed to create conversation', { error });
return apiError(c, 500, 'Failed to create conversation');
}
});
// PATCH /api/conversations/:id - Update conversation (title)
app.patch('/api/conversations/:id', async c => {
const id = c.req.param('id');
try {
const body: { title?: unknown } = await c.req.json();
if (typeof body.title === 'string') {
const title = body.title.slice(0, 255);
await conversationDb.updateConversationTitle(id, title);
}
return c.json({ success: true });
} catch (error) {
if (error instanceof ConversationNotFoundError) {
return apiError(c, 404, 'Conversation not found');
}
console.error('[API] Failed to update conversation', { error });
return apiError(c, 500, 'Failed to update conversation');
}
});
// DELETE /api/conversations/:id - Soft delete
app.delete('/api/conversations/:id', async c => {
const id = c.req.param('id');
try {
await conversationDb.softDeleteConversation(id);
return c.json({ success: true });
} catch (error) {
if (error instanceof ConversationNotFoundError) {
return apiError(c, 404, 'Conversation not found');
}
console.error('[API] Failed to delete conversation', { error });
return apiError(c, 500, 'Failed to delete conversation');
}
});
// GET /api/conversations/:id/messages - Message history
app.get('/api/conversations/:id/messages', async c => {
const platformConversationId = c.req.param('id');
const limit = Math.min(Number(c.req.query('limit') ?? '200'), 500);
try {
const conv = await conversationDb.getConversationByPlatformId('web', platformConversationId);
if (!conv) {
return c.json({ error: 'Conversation not found' }, 404);
}
const messages = await messageDb.listMessages(conv.id, limit);
return c.json(messages);
} catch (error) {
console.error('[API] Failed to list messages', { error });
return c.json({ error: 'Failed to list messages' }, 500);
}
});
// POST /api/conversations/:id/message - Send message
app.post('/api/conversations/:id/message', async c => {
const conversationId = c.req.param('id');
let body: { message?: unknown };
try {
body = await c.req.json();
} catch {
return c.json({ error: 'Invalid JSON in request body' }, 400);
}
if (typeof body.message !== 'string' || !body.message) {
return c.json({ error: 'message must be a non-empty string' }, 400);
}
const message = body.message;
// Look up conversation for persistence and auto-titling
let conv: Awaited<ReturnType<typeof conversationDb.getConversationByPlatformId>> = null;
try {
conv = await conversationDb.getConversationByPlatformId('web', conversationId);
} catch (e: unknown) {
console.error('[API] Failed to look up conversation', {
conversationId,
error: (e as Error).message,
});
}
// Auto-title from first non-command message (non-critical)
if (conv && !conv.title && !message.startsWith('/')) {
try {
const title = message.length > 80 ? message.slice(0, 77) + '...' : message;
await conversationDb.updateConversationTitle(conv.id, title);
} catch (e: unknown) {
console.warn('[API] Auto-title failed', {
conversationId,
error: (e as Error).message,
});
}
}
// Persist user message and pass DB ID to adapter for assistant message persistence
if (conv) {
try {
await messageDb.addMessage(conv.id, 'user', message);
} catch (e: unknown) {
console.error('[API] Message persistence failed', {
conversationId: conv.id,
error: (e as Error).message,
});
try {
await webAdapter.emitSSE(
conversationId,
JSON.stringify({
type: 'warning',
message: 'Message could not be saved to history',
timestamp: Date.now(),
})
);
} catch (sseErr: unknown) {
console.error('[API] SSE warning also failed (double failure)', {
conversationId: conv?.id,
error: (sseErr as Error).message,
});
}
}
webAdapter.setConversationDbId(conversationId, conv.id);
}
const result = await dispatchToOrchestrator(conversationId, message);
return c.json(result);
});
// GET /api/stream/:conversationId - SSE streaming
app.get('/api/stream/:conversationId', async c => {
const conversationId = c.req.param('conversationId');
return streamSSE(c, async stream => {
// Send initial heartbeat immediately to flush HTTP headers.
// Without this, EventSource stays in CONNECTING state until the first write.
await stream.writeSSE({
data: JSON.stringify({ type: 'heartbeat', timestamp: Date.now() }),
});
webAdapter.registerStream(conversationId, stream);
console.log(`[Web] SSE stream opened: ${conversationId}`);
stream.onAbort(() => {
console.log(`[Web] SSE client disconnected: ${conversationId}`);
webAdapter.removeStream(conversationId);
});
try {
while (true) {
await stream.sleep(30000);
if (!stream.closed) {
await stream.writeSSE({
data: JSON.stringify({ type: 'heartbeat', timestamp: Date.now() }),
});
}
}
} catch (e: unknown) {
// stream.sleep() throws when client disconnects — expected behavior.
// Log unexpected errors for debugging.
const msg = (e as Error).message ?? '';
if (!msg.includes('aborted') && !msg.includes('closed') && !msg.includes('cancel')) {
console.warn('[Web] Unexpected SSE heartbeat error', { error: msg });
}
} finally {
webAdapter.removeStream(conversationId);
console.log(`[Web] SSE stream closed: ${conversationId}`);
}
});
});
// GET /api/codebases - List codebases
app.get('/api/codebases', async c => {
try {
const codebases = await codebaseDb.listCodebases();
return c.json(
codebases.map(cb => {
let commands = cb.commands;
if (typeof commands === 'string') {
try {
commands = JSON.parse(commands);
} catch (parseErr) {
console.error('[API] Corrupted commands JSON for codebase', {
codebaseId: cb.id,
error: (parseErr as Error).message,
});
commands = {};
}
}
return { ...cb, commands };
})
);
} catch (error) {
console.error('[API] Failed to list codebases', { error });
return c.json({ error: 'Failed to list codebases' }, 500);
}
});
// GET /api/codebases/:id - Codebase detail
app.get('/api/codebases/:id', async c => {
try {
const codebase = await codebaseDb.getCodebase(c.req.param('id'));
if (!codebase) {
return c.json({ error: 'Codebase not found' }, 404);
}
let commands = codebase.commands;
if (typeof commands === 'string') {
try {
commands = JSON.parse(commands);
} catch (parseErr) {
console.error('[API] Corrupted commands JSON for codebase', {
codebaseId: codebase.id,
error: (parseErr as Error).message,
});
commands = {};
}
}
return c.json({ ...codebase, commands });
} catch (error) {
console.error('[API] Failed to get codebase', { error });
return c.json({ error: 'Failed to get codebase' }, 500);
}
});
// POST /api/codebases - Add a project (clone from URL or register local path)
app.post('/api/codebases', async c => {
let body: { url?: unknown; path?: unknown };
try {
body = await c.req.json();
} catch {
return c.json({ error: 'Invalid JSON in request body' }, 400);
}
const hasUrl = typeof body.url === 'string' && body.url.length > 0;
const hasPath = typeof body.path === 'string' && body.path.length > 0;
if ((!hasUrl && !hasPath) || (hasUrl && hasPath)) {
return c.json({ error: 'Provide either "url" or "path", not both' }, 400);
}
try {
const result = hasUrl
? await cloneRepository(body.url as string)
: await registerRepository(body.path as string);
// Fetch the full codebase record for a consistent response
const codebase = await codebaseDb.getCodebase(result.codebaseId);
if (!codebase) {
return c.json({ error: 'Codebase created but not found' }, 500);
}
return c.json(codebase, result.alreadyExisted ? 200 : 201);
} catch (error) {
console.error('[API] Failed to add codebase', { error });
return c.json(
{ error: `Failed to add codebase: ${(error as Error).message ?? 'unknown error'}` },
500
);
}
});
// DELETE /api/codebases/:id - Delete a project and clean up
app.delete('/api/codebases/:id', async c => {
const id = c.req.param('id');
try {
const codebase = await codebaseDb.getCodebase(id);
if (!codebase) {
return c.json({ error: 'Codebase not found' }, 404);
}
// Clean up isolation environments (worktrees)
const environments = await isolationEnvDb.listByCodebase(id);
for (const env of environments) {
try {
await removeWorktree(codebase.default_cwd, env.working_path);
console.log(`[API] Removed worktree: ${env.working_path}`);
} catch (wtErr) {
// Worktree may already be gone — log but continue
console.warn('[API] Failed to remove worktree', {
path: env.working_path,
error: (wtErr as Error).message,
});
}
await isolationEnvDb.updateStatus(env.id, 'destroyed');
}
// Delete from database (unlinks conversations and sessions)
await codebaseDb.deleteCodebase(id);
// Remove workspace directory from disk — only for Archon-managed repos
const workspacesRoot = normalize(getArchonWorkspacesPath());
const normalizedCwd = normalize(codebase.default_cwd);
if (
normalizedCwd.startsWith(workspacesRoot + '/') ||
normalizedCwd.startsWith(workspacesRoot + '\\')
) {
try {
await rm(normalizedCwd, { recursive: true, force: true });
console.log(`[API] Removed workspace: ${normalizedCwd}`);
} catch (rmErr) {
// Directory may not exist — log but don't fail
console.warn('[API] Failed to remove workspace directory', {
path: codebase.default_cwd,
error: (rmErr as Error).message,
});
}
} else {
console.log(
`[API] Skipping filesystem deletion for externally registered repo: ${codebase.default_cwd}`
);
}
return c.json({ success: true });
} catch (error) {
console.error('[API] Failed to delete codebase', { error });
return c.json({ error: 'Failed to delete codebase' }, 500);
}
});
// =========================================================================
// Workflow endpoints
// =========================================================================
// GET /api/workflows - Discover available workflows
app.get('/api/workflows', async c => {
try {
const cwd = c.req.query('cwd');
let workingDir = cwd;
// Fallback to first codebase's default_cwd
if (!workingDir) {
const codebases = await codebaseDb.listCodebases();
if (codebases.length > 0) {
workingDir = codebases[0].default_cwd;
}
}
if (!workingDir) {
return c.json({ workflows: [] });
}
const workflows = await discoverWorkflows(workingDir);
return c.json({ workflows });
} catch (error) {
// Workflow discovery can fail if cwd is stale or deleted — return empty with warning
console.warn('[API] Failed to discover workflows, returning empty', { error });
return c.json({
workflows: [],
warning: `Workflow discovery failed: ${(error as Error).message}`,
});
}
});
// POST /api/workflows/:name/run - Run a workflow via the orchestrator
app.post('/api/workflows/:name/run', async c => {
try {
let body: { conversationId?: unknown; message?: unknown };
try {
body = await c.req.json();
} catch {
return c.json({ error: 'Invalid JSON' }, 400);
}
const conversationId = typeof body.conversationId === 'string' ? body.conversationId : null;
const message = typeof body.message === 'string' ? body.message : null;
if (!conversationId || !message) {
return c.json({ error: 'conversationId and message are required' }, 400);
}
const workflowName = c.req.param('name');
if (!/^[\w-]+$/.test(workflowName)) {
return c.json({ error: 'Invalid workflow name' }, 400);
}
const fullMessage = `/workflow run ${workflowName} ${message}`;
const result = await dispatchToOrchestrator(conversationId, fullMessage);
return c.json(result);
} catch (error) {
console.error('[API] Failed to run workflow', { error });
return c.json({ error: 'Failed to run workflow' }, 500);
}
});
// GET /api/workflows/runs - List workflow runs
app.get('/api/workflows/runs', async c => {
try {
const conversationId = c.req.query('conversationId') ?? undefined;
const rawStatus = c.req.query('status');
const validStatuses = ['pending', 'running', 'completed', 'failed'] as const;
type WorkflowRunStatus = (typeof validStatuses)[number];
const status: WorkflowRunStatus | undefined =
rawStatus && (validStatuses as readonly string[]).includes(rawStatus)
? (rawStatus as WorkflowRunStatus)
: undefined;
const codebaseId = c.req.query('codebaseId') ?? undefined;
const limitStr = c.req.query('limit');
const limit = Math.min(Math.max(1, limitStr ? Number(limitStr) : 50), 200);
const runs = await workflowDb.listWorkflowRuns({
conversationId,
status,
limit,
codebaseId,
});
return c.json({ runs });
} catch (error) {
console.error('[API] Failed to list workflow runs', { error });
return c.json({ error: 'Failed to list workflow runs' }, 500);
}
});
// GET /api/workflows/runs/by-worker/:platformId - Look up run by worker conversation
// Must be registered before :runId to avoid "by-worker" matching as a runId
app.get('/api/workflows/runs/by-worker/:platformId', async c => {
try {
const platformId = c.req.param('platformId');
const run = await workflowDb.getWorkflowRunByWorkerPlatformId(platformId);
if (!run) {
return c.json({ error: 'No workflow run found for this worker' }, 404);
}
return c.json({ run });
} catch (error) {
console.error('[API] Failed to look up workflow run by worker', { error });
return c.json({ error: 'Failed to look up workflow run' }, 500);
}
});
// GET /api/workflows/runs/:runId - Get run details with events
app.get('/api/workflows/runs/:runId', async c => {
try {
const runId = c.req.param('runId');
const run = await workflowDb.getWorkflowRun(runId);
if (!run) {
return c.json({ error: 'Workflow run not found' }, 404);
}
const events = await workflowEventDb.listWorkflowEvents(runId);
// Look up worker conversation to get its platform_conversation_id for SSE/messages
let workerPlatformId: string | undefined;
if (run.conversation_id) {
const conv = await conversationDb.getConversationById(run.conversation_id);
workerPlatformId = conv?.platform_conversation_id;
}
// Look up parent conversation to get its platform_conversation_id for navigation
let parentPlatformId: string | undefined;
if (run.parent_conversation_id) {
const parentConv = await conversationDb.getConversationById(run.parent_conversation_id);
parentPlatformId = parentConv?.platform_conversation_id;
}
return c.json({
run: { ...run, worker_platform_id: workerPlatformId, parent_platform_id: parentPlatformId },
events,
});
} catch (error) {
console.error('[API] Failed to get workflow run', { error });
return c.json({ error: 'Failed to get workflow run' }, 500);
}
});
// GET /api/config - Read-only configuration
app.get('/api/config', async c => {
try {
const config = await loadConfig();
return c.json({
config,
database: getDatabaseType(),
});
} catch (error) {
console.error('[API] Failed to get config', { error });
return c.json({ error: 'Failed to get config' }, 500);
}
});
// GET /api/health - Health check with web adapter info
app.get('/api/health', c => {
return c.json({
status: 'ok',
adapter: 'web',
concurrency: lockManager.getStats(),
});
});
}

View file

@ -0,0 +1,23 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

23
packages/web/index.html Normal file
View file

@ -0,0 +1,23 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Archon</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
<style>
body {
background-color: #0f1117;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

40
packages/web/package.json Normal file
View file

@ -0,0 +1,40 @@
{
"name": "@archon/web",
"version": "0.2.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@radix-ui/react-alert-dialog": "^1.1.15",
"@tanstack/react-query": "^5.0.0",
"@tanstack/react-virtual": "^3.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.563.0",
"radix-ui": "^1.4.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-markdown": "^9.0.0",
"react-router": "^7.0.0",
"rehype-highlight": "^7.0.0",
"remark-breaks": "^4.0.0",
"tailwind-merge": "^3.4.0",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.0.0",
"shadcn": "^3.8.4",
"tailwindcss": "^4.0.0",
"typescript": "^5.3.0",
"vite": "^6.0.0"
}
}

90
packages/web/src/App.tsx Normal file
View file

@ -0,0 +1,90 @@
import { Component } from 'react';
import type { ReactNode, ErrorInfo } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Layout } from '@/components/layout/Layout';
import { DashboardPage } from '@/routes/DashboardPage';
import { ChatPage } from '@/routes/ChatPage';
import { WorkflowsPage } from '@/routes/WorkflowsPage';
import { WorkflowExecutionPage } from '@/routes/WorkflowExecutionPage';
import { WorkflowBuilderPage } from '@/routes/WorkflowBuilderPage';
import { SettingsPage } from '@/routes/SettingsPage';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 10_000,
refetchOnWindowFocus: true,
},
},
});
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
class ErrorBoundary extends Component<{ children: ReactNode }, ErrorBoundaryState> {
constructor(props: { children: ReactNode }) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: ErrorInfo): void {
console.error('[ErrorBoundary] Uncaught rendering error', {
error: error.message,
stack: error.stack,
componentStack: info.componentStack,
});
}
render(): ReactNode {
if (this.state.hasError) {
return (
<div className="flex h-screen items-center justify-center bg-zinc-950 p-8">
<div className="max-w-md text-center">
<h1 className="mb-2 text-xl font-semibold text-zinc-100">Something went wrong</h1>
<p className="mb-4 text-sm text-zinc-400">
{this.state.error?.message ?? 'An unexpected error occurred.'}
</p>
<button
onClick={(): void => {
window.location.reload();
}}
className="rounded-md bg-zinc-800 px-4 py-2 text-sm text-zinc-200 hover:bg-zinc-700"
>
Reload page
</button>
</div>
</div>
);
}
return this.props.children;
}
}
export function App(): React.ReactElement {
return (
<ErrorBoundary>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<Routes>
<Route element={<Layout />}>
<Route path="/" element={<DashboardPage />} />
<Route path="/chat" element={<ChatPage />} />
<Route path="/chat/*" element={<ChatPage />} />
<Route path="/workflows" element={<WorkflowsPage />} />
<Route path="/workflows/builder" element={<WorkflowBuilderPage />} />
<Route path="/workflows/runs/:runId" element={<WorkflowExecutionPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Route>
</Routes>
</BrowserRouter>
</QueryClientProvider>
</ErrorBoundary>
);
}

View file

@ -0,0 +1,418 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router';
import { Header } from '@/components/layout/Header';
import { MessageList } from './MessageList';
import { MessageInput } from './MessageInput';
import { LockIndicator } from './LockIndicator';
import { WorkflowProgressCard } from './WorkflowProgressCard';
import { useSSE } from '@/hooks/useSSE';
import { useWorkflowStatus } from '@/hooks/useWorkflowStatus';
import {
sendMessage as apiSendMessage,
listConversations,
listCodebases,
getMessages,
createConversation,
} from '@/lib/api';
import type { ConversationResponse, CodebaseResponse, MessageResponse } from '@/lib/api';
import type {
ChatMessage,
ToolCallDisplay,
ErrorDisplay,
WorkflowDispatchEvent,
} from '@/lib/types';
import { getCachedMessages, setCachedMessages } from '@/lib/message-cache';
interface ChatInterfaceProps {
conversationId: string;
}
export function ChatInterface({ conversationId }: ChatInterfaceProps): React.ReactElement {
const navigate = useNavigate();
const isNewChat = conversationId === 'new';
const [messages, setMessages] = useState<ChatMessage[]>(() =>
isNewChat ? [] : getCachedMessages(conversationId).map(m => ({ ...m, isStreaming: false }))
);
const [locked, setLocked] = useState(false);
const [queuePosition, setQueuePosition] = useState<number | undefined>();
const [sending, setSending] = useState(false);
const [hasSentMessage, setHasSentMessage] = useState(false);
const messageIdCounter = useRef(0);
const { activeWorkflow, handlers: workflowHandlers } = useWorkflowStatus();
// Sync messages to cache for persistence across navigation
useEffect(() => {
if (!isNewChat) {
setCachedMessages(conversationId, messages);
}
}, [conversationId, messages, isNewChat]);
// Load message history from server on mount (survives hard refresh)
useEffect(() => {
if (isNewChat) return;
void getMessages(conversationId)
.then((rows: MessageResponse[]) => {
if (rows.length === 0) return;
const hydrated: ChatMessage[] = rows.map(row => {
let meta: {
toolCalls?: {
name: string;
input: Record<string, unknown>;
duration?: number;
}[];
error?: ErrorDisplay;
workflowDispatch?: { workerConversationId: string; workflowName: string };
} = {};
try {
meta = JSON.parse(row.metadata) as typeof meta;
} catch (parseErr) {
console.warn('[Chat] Corrupted message metadata', {
messageId: row.id,
error: (parseErr as Error).message,
});
meta = {
error: {
message: 'Message data corrupted',
classification: 'fatal' as const,
suggestedActions: [],
},
};
}
return {
id: row.id,
role: row.role,
content: row.content,
toolCalls: meta.toolCalls?.map((tc, i) => ({
...tc,
id: `${row.id}-tool-${String(i)}`,
startedAt: 0,
isExpanded: false,
duration: tc.duration ?? 0, // Use stored duration, fallback to 0
})),
error: meta.error,
workflowDispatch: meta.workflowDispatch,
timestamp: new Date(row.created_at).getTime(),
isStreaming: false,
};
});
// Only set if no messages arrived via SSE while loading
setMessages(prev => (prev.length > 0 ? prev : hydrated));
setHasSentMessage(true);
})
.catch((e: unknown) => {
console.error('[Chat] Failed to load message history', {
conversationId,
error: e instanceof Error ? e.message : e,
});
setMessages(prev => [
...prev,
{
id: 'error-load-history',
role: 'assistant' as const,
content: '',
error: {
message: 'Failed to load message history. Try refreshing the page.',
classification: 'transient' as const,
suggestedActions: ['Refresh page'],
},
timestamp: Date.now(),
},
]);
});
}, [conversationId, isNewChat]);
// Share conversations cache with sidebar for title/context display
const { data: conversations, isError: conversationsError } = useQuery<ConversationResponse[]>({
queryKey: ['conversations'],
queryFn: () => listConversations(),
});
const { data: codebases, isError: codebasesError } = useQuery<CodebaseResponse[]>({
queryKey: ['codebases'],
queryFn: listCodebases,
});
const currentConv = conversations?.find(c => c.platform_conversation_id === conversationId);
const currentCodebase = codebases?.find(cb => cb.id === currentConv?.codebase_id);
const headerTitle = currentConv?.title ?? 'Chat';
const headerSubtitle = currentConv?.cwd ?? undefined;
const nextId = (): string => {
messageIdCounter.current += 1;
return `msg-${String(messageIdCounter.current)}`;
};
const onText = useCallback((content: string): void => {
setMessages(prev => {
const last = prev[prev.length - 1];
// Workflow status messages (🚀 start, ✅ complete) should always be their own message
const isWorkflowStatus = /^[\u{1F680}\u{2705}]/u.test(content);
if (last?.role === 'assistant' && last.isStreaming) {
const lastIsWorkflowStatus = /^[\u{1F680}\u{2705}]/u.test(last.content);
if ((isWorkflowStatus && last.content) || (lastIsWorkflowStatus && !isWorkflowStatus)) {
// Close the current streaming message and start a new one when:
// 1. Incoming is a workflow status and current has content
// 2. Current is a workflow status and incoming is regular text
return [
...prev.slice(0, -1),
{ ...last, isStreaming: false },
{
id: `msg-${String(Date.now())}`,
role: 'assistant' as const,
content,
timestamp: Date.now(),
isStreaming: true,
toolCalls: [],
},
];
}
// Append to existing streaming message (replace thinking placeholder if empty)
return [...prev.slice(0, -1), { ...last, content: last.content + content }];
}
// New assistant message
return [
...prev,
{
id: `msg-${String(Date.now())}`,
role: 'assistant' as const,
content,
timestamp: Date.now(),
isStreaming: true,
toolCalls: [],
},
];
});
}, []);
const onToolCall = useCallback((name: string, input: Record<string, unknown>): void => {
setMessages(prev => {
const last = prev[prev.length - 1];
if (last?.role === 'assistant') {
const now = Date.now();
// Mark any previous running tools as complete (agent moved on)
const updatedExistingTools = (last.toolCalls ?? []).map(tc =>
!tc.output && tc.duration === undefined ? { ...tc, duration: now - tc.startedAt } : tc
);
const newTool: ToolCallDisplay = {
id: `tool-${String(now)}`,
name,
input,
startedAt: now,
isExpanded: false,
};
return [
...prev.slice(0, -1),
{
...last,
isStreaming: false,
toolCalls: [...updatedExistingTools, newTool],
},
];
}
return prev;
});
}, []);
const onToolResult = useCallback((name: string, output: string, duration: number): void => {
setMessages(prev => {
const last = prev[prev.length - 1];
if (last?.role === 'assistant' && last.toolCalls) {
const updatedTools = last.toolCalls.map(tc =>
tc.name === name && !tc.output ? { ...tc, output, duration } : tc
);
return [...prev.slice(0, -1), { ...last, toolCalls: updatedTools }];
}
return prev;
});
}, []);
const onError = useCallback((error: ErrorDisplay): void => {
setMessages(prev => {
const last = prev[prev.length - 1];
if (last?.role === 'assistant') {
return [...prev.slice(0, -1), { ...last, isStreaming: false, error }];
}
return [
...prev,
{
id: `msg-${String(Date.now())}`,
role: 'assistant',
content: '',
error,
timestamp: Date.now(),
},
];
});
}, []);
const onLockChange = useCallback((isLocked: boolean, position?: number): void => {
setLocked(isLocked);
setQueuePosition(position);
if (!isLocked) {
const now = Date.now();
// Mark ALL streaming messages as complete and all running tools as finished
setMessages(prev =>
prev.map(msg => {
const needsToolFix = msg.toolCalls?.some(tc => !tc.output && tc.duration === undefined);
const needsStreamFix = msg.isStreaming;
if (!needsToolFix && !needsStreamFix) return msg;
return {
...msg,
isStreaming: false,
toolCalls: needsToolFix
? msg.toolCalls?.map(tc =>
!tc.output && tc.duration === undefined
? { ...tc, duration: now - tc.startedAt }
: tc
)
: msg.toolCalls,
};
})
);
}
}, []);
const onSessionInfo = useCallback((_sessionId: string, _cost?: number): void => {
// Session info can be stored for display later
}, []);
const onWorkflowDispatch = useCallback((event: WorkflowDispatchEvent): void => {
setMessages(prev => {
let lastAssistantIdx = -1;
for (let i = prev.length - 1; i >= 0; i--) {
if (prev[i].role === 'assistant') {
lastAssistantIdx = i;
break;
}
}
if (lastAssistantIdx < 0) return prev;
const msg = prev[lastAssistantIdx];
return [
...prev.slice(0, lastAssistantIdx),
{
...msg,
workflowDispatch: {
workerConversationId: event.workerConversationId,
workflowName: event.workflowName,
},
},
...prev.slice(lastAssistantIdx + 1),
];
});
}, []);
const onWarning = useCallback(
(message: string): void => {
console.warn('[SSE] Warning from server:', message);
onError({
message,
classification: 'transient',
suggestedActions: [],
});
},
[onError]
);
const { connected } = useSSE(isNewChat ? null : conversationId, {
onText,
onToolCall,
onToolResult,
onError,
onLockChange,
onSessionInfo,
onWorkflowDispatch,
onWarning,
...workflowHandlers,
});
const handleSend = useCallback(
async (message: string): Promise<void> => {
// Add user message + thinking indicator to UI immediately
const userMsg: ChatMessage = {
id: nextId(),
role: 'user',
content: message,
timestamp: Date.now(),
};
const thinkingMsg: ChatMessage = {
id: `thinking-${String(Date.now())}`,
role: 'assistant',
content: '',
timestamp: Date.now(),
isStreaming: true,
};
setMessages(prev => [...prev, userMsg, thinkingMsg]);
setSending(true);
setHasSentMessage(true);
let targetConversationId = conversationId;
// Create conversation on first message if this is a new chat
if (isNewChat) {
try {
const selectedProjectId = localStorage.getItem('archon-selected-project');
const { conversationId: newId } = await createConversation(
selectedProjectId ?? undefined
);
targetConversationId = newId;
navigate(`/chat/${newId}`, { replace: true });
} catch (error) {
console.error('[Chat] Failed to create conversation', { error });
onError({
message: 'Failed to create conversation. Please try again.',
classification: 'transient',
suggestedActions: ['Retry'],
});
setSending(false);
return;
}
}
try {
await apiSendMessage(targetConversationId, message);
} catch (error) {
console.error('[Chat] Failed to send message', { error });
onError({
message: 'Failed to send message. Please try again.',
classification: 'transient',
suggestedActions: ['Retry'],
});
} finally {
setSending(false);
}
},
[conversationId, isNewChat, navigate, onError]
);
const handleCancelWorkflow = useCallback((): void => {
void handleSend('/workflow cancel');
}, [handleSend]);
const isStreaming = messages.some(m => m.isStreaming);
return (
<div className="flex flex-1 flex-col overflow-hidden">
<Header
title={headerTitle}
subtitle={headerSubtitle}
projectName={currentCodebase?.name}
connected={connected}
/>
{(conversationsError || codebasesError) && (
<div className="flex gap-2 px-4 py-1">
{conversationsError && (
<span className="text-xs text-red-400">Failed to load conversations</span>
)}
{codebasesError && <span className="text-xs text-red-400">Failed to load projects</span>}
</div>
)}
<MessageList messages={messages} isStreaming={isStreaming} />
{activeWorkflow && (
<WorkflowProgressCard workflow={activeWorkflow} onCancel={handleCancelWorkflow} />
)}
<LockIndicator locked={locked && hasSentMessage} queuePosition={queuePosition} />
<MessageInput onSend={handleSend} disabled={sending} />
</div>
);
}

View file

@ -0,0 +1,54 @@
import { AlertCircle } from 'lucide-react';
import type { ErrorDisplay } from '@/lib/types';
import { cn } from '@/lib/utils';
interface ErrorCardProps {
error: ErrorDisplay;
onRetry?: () => void;
}
export function ErrorCard({ error, onRetry }: ErrorCardProps): React.ReactElement {
return (
<div className="rounded-lg border border-border border-l-[3px] border-l-error bg-surface p-4">
<div className="flex items-start gap-3">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-error" />
<div className="flex-1">
<div className="flex items-center gap-2">
<p className="text-sm text-text-primary">{error.message}</p>
<span
className={cn(
'shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium',
error.classification === 'transient'
? 'bg-warning/20 text-warning'
: 'bg-error/20 text-error'
)}
>
{error.classification === 'transient' ? 'Transient' : 'Fatal'}
</span>
</div>
{error.suggestedActions.length > 0 && (
<div className="mt-2 flex flex-wrap gap-2">
{error.suggestedActions.map((action, i) => (
<button
key={i}
onClick={action === 'Retry' ? onRetry : undefined}
className="text-xs text-text-secondary hover:text-text-primary"
>
{action}
</button>
))}
</div>
)}
{error.classification === 'transient' && onRetry && (
<button
onClick={onRetry}
className="mt-2 text-xs text-text-secondary hover:text-text-primary"
>
Retry
</button>
)}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,29 @@
import { cn } from '@/lib/utils';
interface LockIndicatorProps {
locked: boolean;
queuePosition?: number;
}
export function LockIndicator({ locked, queuePosition }: LockIndicatorProps): React.ReactElement {
return (
<div
className={cn(
'overflow-hidden transition-all duration-200',
locked ? 'h-8 opacity-100' : 'h-0 opacity-0'
)}
>
<div className="flex h-8 items-center gap-2 border-l-2 border-l-primary bg-surface px-4">
<div className="h-2 w-2 animate-pulse rounded-full bg-primary" />
<span className="text-sm text-text-secondary">
Agent is working...
{queuePosition !== undefined && queuePosition > 0 && (
<span className="ml-1 text-text-tertiary">
Position {String(queuePosition)} in queue
</span>
)}
</span>
</div>
</div>
);
}

View file

@ -0,0 +1,106 @@
import ReactMarkdown from 'react-markdown';
import rehypeHighlight from 'rehype-highlight';
import remarkBreaks from 'remark-breaks';
import type { ChatMessage } from '@/lib/types';
import { cn } from '@/lib/utils';
interface MessageBubbleProps {
message: ChatMessage;
}
export function MessageBubble({ message }: MessageBubbleProps): React.ReactElement {
const isUser = message.role === 'user';
return (
<div className={cn('group flex w-full', isUser ? 'justify-end' : 'justify-start')}>
<div
className={cn(
'relative',
isUser
? 'max-w-[70%] rounded-2xl rounded-br-sm bg-accent-muted px-4 py-2.5'
: 'max-w-full rounded-lg border-l-2 border-border pl-4'
)}
>
{isUser ? (
<p className="text-sm text-text-primary whitespace-pre-wrap">{message.content}</p>
) : (
<div className="chat-markdown max-w-none text-sm text-text-primary">
{message.isStreaming && !message.content && (
<div className="flex items-center gap-1.5 py-1">
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-text-tertiary" />
<span
className="h-1.5 w-1.5 animate-pulse rounded-full bg-text-tertiary"
style={{ animationDelay: '0.2s' }}
/>
<span
className="h-1.5 w-1.5 animate-pulse rounded-full bg-text-tertiary"
style={{ animationDelay: '0.4s' }}
/>
</div>
)}
<ReactMarkdown
remarkPlugins={[remarkBreaks]}
rehypePlugins={[rehypeHighlight]}
components={{
pre: ({ children, ...props }): React.ReactElement => (
<pre
className="overflow-x-auto rounded-lg border border-border bg-surface p-4 font-mono text-sm"
{...props}
>
{children}
</pre>
),
code: ({ children, className, ...props }): React.ReactElement => {
const isBlock =
className?.startsWith('language-') || className?.startsWith('hljs');
if (isBlock) {
return (
<code className={cn(className, 'font-mono')} {...props}>
{children}
</code>
);
}
return (
<code
className="rounded bg-surface px-1.5 py-0.5 font-mono text-sm text-primary"
{...props}
>
{children}
</code>
);
},
blockquote: ({ children, ...props }): React.ReactElement => (
<blockquote
className="border-l-2 border-primary pl-4 text-text-secondary"
{...props}
>
{children}
</blockquote>
),
a: ({ children, ...props }): React.ReactElement => (
<a
className="text-primary underline decoration-primary/40 hover:decoration-primary"
target="_blank"
rel="noopener noreferrer"
{...props}
>
{children}
</a>
),
}}
>
{message.content}
</ReactMarkdown>
{message.isStreaming && (
<span className="inline-block h-4 w-0.5 animate-pulse bg-primary align-text-bottom" />
)}
</div>
)}
<div className="mt-0.5 text-[11px] text-text-tertiary">
{new Date(message.timestamp).toLocaleTimeString()}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,70 @@
import { useState, useRef, useCallback, type KeyboardEvent } from 'react';
import { ArrowUp, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface MessageInputProps {
onSend: (message: string) => void;
disabled: boolean;
}
export function MessageInput({ onSend, disabled }: MessageInputProps): React.ReactElement {
const [value, setValue] = useState('');
const textareaRef = useRef<HTMLTextAreaElement>(null);
const handleSend = useCallback((): void => {
const trimmed = value.trim();
if (!trimmed || disabled) return;
onSend(trimmed);
setValue('');
// Reset textarea height
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
textareaRef.current.focus();
}
}, [value, disabled, onSend]);
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>): void => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const handleInput = (e: React.ChangeEvent<HTMLTextAreaElement>): void => {
setValue(e.target.value);
// Auto-expand textarea
const textarea = e.target;
textarea.style.height = 'auto';
textarea.style.height = `${String(Math.min(textarea.scrollHeight, 200))}px`;
};
return (
<div className="border-t border-border bg-surface p-4">
<div className="mx-auto flex max-w-3xl items-end gap-2">
<textarea
ref={textareaRef}
value={value}
onChange={handleInput}
onKeyDown={handleKeyDown}
disabled={disabled}
placeholder="Message Archon..."
rows={1}
className="flex-1 resize-none rounded-lg border border-border bg-background px-4 py-3 text-sm text-text-primary placeholder:text-text-tertiary focus:border-primary focus:outline-none disabled:opacity-50"
style={{ maxHeight: '200px' }}
/>
<Button
onClick={handleSend}
disabled={disabled || !value.trim()}
size="icon"
className="h-10 w-10 shrink-0 rounded-lg bg-primary text-primary-foreground hover:bg-accent-hover disabled:opacity-50"
>
{disabled ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ArrowUp className="h-4 w-4" />
)}
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,121 @@
import { useRef } from 'react';
import { useNavigate } from 'react-router';
import { ArrowDown, MessageSquare } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { MessageBubble } from './MessageBubble';
import { ToolCallCard } from './ToolCallCard';
import { ErrorCard } from './ErrorCard';
import { useAutoScroll } from '@/hooks/useAutoScroll';
import { getWorkflowRunByWorker } from '@/lib/api';
import type { ChatMessage } from '@/lib/types';
function WorkflowDispatchInline({
workflowName,
workerConversationId,
}: {
workflowName: string;
workerConversationId: string;
}): React.ReactElement {
const navigate = useNavigate();
const { data: runData } = useQuery({
queryKey: ['workflowRunByWorker', workerConversationId],
queryFn: () => getWorkflowRunByWorker(workerConversationId),
refetchInterval: (query): number | false => {
const status = query.state.data?.run.status;
if (status === 'completed' || status === 'failed') return false;
return 3000;
},
});
const status = runData?.run.status;
const handleClick = (): void => {
if (runData?.run.id) {
navigate(`/workflows/runs/${runData.run.id}`);
} else {
navigate(`/chat/${encodeURIComponent(workerConversationId)}`);
}
};
const statusIcon =
status === 'completed' ? (
<span className="text-success text-xs shrink-0">&#x2713;</span>
) : status === 'failed' ? (
<span className="text-error text-xs shrink-0">&#x2717;</span>
) : (
<span className="inline-block h-3 w-3 animate-spin rounded-full border-2 border-accent border-t-transparent shrink-0" />
);
return (
<button
onClick={handleClick}
className="flex items-center gap-2 rounded-lg border border-border bg-surface px-3 py-2 text-xs transition-colors hover:bg-surface-elevated hover:border-primary/50 text-left max-w-sm"
>
{statusIcon}
<span className="truncate text-text-primary font-medium">{workflowName}</span>
<span className="text-primary font-medium shrink-0">View &rarr;</span>
</button>
);
}
interface MessageListProps {
messages: ChatMessage[];
isStreaming: boolean;
}
export function MessageList({ messages, isStreaming }: MessageListProps): React.ReactElement {
const containerRef = useRef<HTMLDivElement>(null);
const { isAtBottom, scrollToBottom } = useAutoScroll(containerRef, [messages, isStreaming]);
if (messages.length === 0) {
return (
<div className="flex flex-1 items-center justify-center">
<div className="flex flex-col items-center gap-3 text-text-tertiary">
<MessageSquare className="h-10 w-10" />
<p className="text-sm">Send a message to start chatting</p>
</div>
</div>
);
}
return (
<div className="relative flex-1 overflow-hidden">
<div ref={containerRef} className="h-full overflow-y-auto px-3 py-2">
<div className="mx-auto flex max-w-3xl flex-col gap-1.5 pb-6">
{messages.map(msg => (
<div key={msg.id} className="flex flex-col gap-1">
<MessageBubble message={msg} />
{msg.toolCalls?.map(tool => (
<ToolCallCard key={tool.id} tool={tool} />
))}
{msg.error && <ErrorCard error={msg.error} />}
{msg.workflowDispatch && (
<WorkflowDispatchInline
workflowName={msg.workflowDispatch.workflowName}
workerConversationId={msg.workflowDispatch.workerConversationId}
/>
)}
</div>
))}
</div>
</div>
{/* Jump to bottom button */}
{!isAtBottom && (
<div className="absolute bottom-4 left-1/2 -translate-x-1/2">
<Button
onClick={scrollToBottom}
size="sm"
variant="secondary"
className="rounded-full bg-surface-elevated shadow-lg"
>
<ArrowDown className="mr-1 h-3 w-3" />
Jump to bottom
</Button>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,100 @@
import { useState } from 'react';
import { ChevronRight, Loader2, Terminal } from 'lucide-react';
import type { ToolCallDisplay } from '@/lib/types';
import { cn } from '@/lib/utils';
interface ToolCallCardProps {
tool: ToolCallDisplay;
}
export function ToolCallCard({ tool }: ToolCallCardProps): React.ReactElement {
const [expanded, setExpanded] = useState(tool.isExpanded);
const [showAllOutput, setShowAllOutput] = useState(false);
const isRunning = !tool.output && tool.duration === undefined;
// Get a brief summary from the input
const summary = Object.values(tool.input)[0];
const summaryText =
typeof summary === 'string' ? summary.slice(0, 60) + (summary.length > 60 ? '...' : '') : '';
// Limit output display
const outputLines = tool.output?.split('\n') ?? [];
const isLongOutput = outputLines.length > 50;
const displayOutput = showAllOutput ? tool.output : outputLines.slice(0, 20).join('\n');
return (
<div
className={cn(
'rounded-lg border border-border bg-surface transition-colors hover:border-border-bright',
isRunning && 'border-l-2 border-l-primary'
)}
>
{/* Header - clickable to expand */}
<button
onClick={(): void => {
setExpanded(!expanded);
}}
className="flex h-9 w-full items-center gap-2 px-3 text-left"
>
<ChevronRight
className={cn(
'h-3.5 w-3.5 shrink-0 text-text-tertiary transition-transform duration-150',
expanded && 'rotate-90'
)}
/>
{isRunning ? (
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-primary" />
) : (
<Terminal className="h-3.5 w-3.5 shrink-0 text-text-secondary" />
)}
<span className="truncate font-mono text-xs text-text-secondary">{tool.name}</span>
{summaryText && <span className="truncate text-xs text-text-tertiary">{summaryText}</span>}
<span className="ml-auto shrink-0">
{tool.duration !== undefined && (
<span className="rounded-full bg-surface-elevated px-2 py-0.5 text-[10px] text-text-secondary">
{tool.duration < 1000
? `${String(tool.duration)}ms`
: `${(tool.duration / 1000).toFixed(1)}s`}
</span>
)}
</span>
</button>
{/* Expanded content */}
{expanded && (
<div className="border-t border-border px-3 py-2">
{Object.keys(tool.input).length > 0 && (
<div className="mb-2">
<span className="text-[10px] font-medium uppercase tracking-wider text-text-tertiary">
Input
</span>
<pre className="mt-1 overflow-x-auto rounded-md bg-background p-2 font-mono text-xs text-text-secondary">
{JSON.stringify(tool.input, null, 2)}
</pre>
</div>
)}
{tool.output && (
<div>
<span className="text-[10px] font-medium uppercase tracking-wider text-text-tertiary">
Output
</span>
<pre className="mt-1 max-h-80 overflow-auto rounded-md bg-background p-2 font-mono text-xs text-text-secondary">
{displayOutput}
</pre>
{isLongOutput && !showAllOutput && (
<button
onClick={(): void => {
setShowAllOutput(true);
}}
className="mt-1 text-xs text-text-secondary hover:text-text-primary"
>
Show {String(outputLines.length - 20)} more lines
</button>
)}
</div>
)}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,185 @@
import { useNavigate } from 'react-router';
import { LoopIterationView } from '@/components/workflows/LoopIterationView';
import type { WorkflowState } from '@/lib/types';
interface WorkflowProgressCardProps {
workflow: WorkflowState;
onCancel?: () => void;
compact?: boolean;
onViewFullScreen?: () => void;
}
function StatusIcon({ status }: { status: string }): React.ReactElement {
switch (status) {
case 'completed':
return <span className="text-success">&#x2713;</span>;
case 'running':
return (
<span className="inline-block h-3 w-3 animate-spin rounded-full border-2 border-accent border-t-transparent" />
);
case 'failed':
return <span className="text-error">&#x2717;</span>;
default:
return <span className="text-text-secondary">&#x25CB;</span>;
}
}
function formatDuration(ms: number): string {
if (ms < 1000) return `${String(ms)}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
return `${(ms / 60000).toFixed(1)}m`;
}
export function WorkflowProgressCard({
workflow,
onCancel,
compact,
onViewFullScreen,
}: WorkflowProgressCardProps): React.ReactElement {
const navigate = useNavigate();
const completedSteps = workflow.steps.filter(s => s.status === 'completed').length;
const totalSteps = workflow.steps.length;
const elapsed = Math.max(0, (workflow.completedAt ?? Date.now()) - workflow.startedAt);
const handleViewFullScreen = (): void => {
if (onViewFullScreen) {
onViewFullScreen();
} else {
navigate(`/workflows/runs/${workflow.runId}`);
}
};
// Compact mode: single-line summary card
if (compact) {
return (
<div className="rounded-lg border border-border bg-surface p-2">
<div className="flex items-center gap-2">
<StatusIcon status={workflow.status} />
<span className="flex-1 truncate text-xs font-medium text-text-primary">
{workflow.workflowName}
</span>
<span className="text-[10px] text-text-secondary">
{workflow.isLoop
? `Iter ${String(workflow.currentIteration ?? 0)}`
: `${String(completedSteps)}/${String(totalSteps)}`}
</span>
<span className="text-[10px] text-text-tertiary">{formatDuration(elapsed)}</span>
{workflow.stale && workflow.status === 'running' && (
<span
className="text-[10px] text-yellow-400"
title="Connection lost — status may be outdated"
>
!
</span>
)}
</div>
<div className="flex items-center gap-2 mt-1">
<button
onClick={handleViewFullScreen}
className="text-[10px] text-accent hover:text-accent-bright transition-colors"
>
Open Full View
</button>
{workflow.status === 'running' && onCancel && (
<button
onClick={onCancel}
className="text-[10px] text-text-secondary hover:text-error transition-colors ml-auto"
>
Cancel
</button>
)}
</div>
</div>
);
}
// Full mode: detailed card
return (
<div className="my-2 rounded-lg border border-border bg-surface p-3">
{/* Header */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<StatusIcon status={workflow.status} />
<span className="font-semibold text-sm text-text-primary">
{workflow.status === 'running'
? 'Running'
: workflow.status === 'completed'
? 'Completed'
: 'Failed'}{' '}
{workflow.workflowName}
</span>
</div>
<div className="flex items-center gap-2 text-xs text-text-secondary">
{workflow.isLoop ? (
<span>
Iteration {String(workflow.currentIteration ?? 0)}
{workflow.maxIterations ? `/${String(workflow.maxIterations)}` : ''}
</span>
) : (
<span>
{String(completedSteps)}/{String(totalSteps)} steps
</span>
)}
<span>{formatDuration(elapsed)}</span>
</div>
</div>
{/* Step list */}
{!workflow.isLoop && workflow.steps.length > 0 && (
<div className="space-y-1 mb-2">
{workflow.steps.map(step => (
<div key={step.index} className="flex items-center gap-2 text-xs">
<StatusIcon status={step.status} />
<span
className={step.status === 'running' ? 'text-text-primary' : 'text-text-secondary'}
>
{step.name}
</span>
{step.duration !== undefined && (
<span className="ml-auto text-text-secondary">{formatDuration(step.duration)}</span>
)}
</div>
))}
</div>
)}
{/* Loop iteration display */}
{workflow.isLoop && (
<div className="mb-2">
<LoopIterationView workflow={workflow} />
</div>
)}
{/* Stale indicator — polling lost contact with server */}
{workflow.stale && workflow.status === 'running' && (
<div className="mb-2 text-xs text-yellow-400 bg-yellow-400/10 rounded p-2">
Connection lost status may be outdated. Retrying...
</div>
)}
{/* Error */}
{workflow.error && (
<div className="mb-2 text-xs text-error bg-error/10 rounded p-2">{workflow.error}</div>
)}
{/* Footer buttons */}
<div className="flex items-center gap-2 pt-1 border-t border-border">
<button
onClick={handleViewFullScreen}
className="text-xs text-accent hover:text-accent-bright transition-colors"
>
View Full Screen
</button>
{workflow.status === 'running' && onCancel && (
<button
onClick={onCancel}
className="text-xs text-text-secondary hover:text-error transition-colors ml-auto"
>
Cancel
</button>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,205 @@
import { useState, useRef, useCallback } from 'react';
import { NavLink, useNavigate, useParams } from 'react-router';
import { Pencil, Trash2 } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query';
import type { ConversationResponse } from '@/lib/api';
import { deleteConversation, updateConversation } from '@/lib/api';
import { cn } from '@/lib/utils';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
interface ConversationItemProps {
conversation: ConversationResponse;
badge?: number;
projectName?: string;
status?: 'idle' | 'running' | 'failed';
}
export function ConversationItem({
conversation,
badge,
projectName,
status = 'idle',
}: ConversationItemProps): React.ReactElement {
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState('');
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const queryClient = useQueryClient();
const navigate = useNavigate();
const params = useParams<{ conversationId: string }>();
const displayName = conversation.title
? conversation.title.length > 30
? conversation.title.slice(0, 30) + '...'
: conversation.title
: 'Untitled conversation';
const lastActivity = conversation.last_activity_at
? new Date(
conversation.last_activity_at.endsWith('Z')
? conversation.last_activity_at
: conversation.last_activity_at + 'Z'
).toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
: 'No activity';
const handleDelete = useCallback((): void => {
setDeleteError(null);
void deleteConversation(conversation.id)
.then(() => {
void queryClient.invalidateQueries({ queryKey: ['conversations'] });
if (params.conversationId === conversation.platform_conversation_id) {
void navigate('/');
}
})
.catch((err: Error) => {
setDeleteError(err.message);
});
}, [
conversation.id,
conversation.platform_conversation_id,
queryClient,
navigate,
params.conversationId,
]);
const handleRenameSubmit = useCallback((): void => {
const trimmed = editValue.trim();
if (trimmed && trimmed !== conversation.title) {
void updateConversation(conversation.id, { title: trimmed }).then(() => {
void queryClient.invalidateQueries({ queryKey: ['conversations'] });
});
}
setIsEditing(false);
}, [editValue, conversation.id, conversation.title, queryClient]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent): void => {
if (e.key === 'Enter') {
handleRenameSubmit();
} else if (e.key === 'Escape') {
setIsEditing(false);
}
},
[handleRenameSubmit]
);
return (
<NavLink
to={`/chat/${encodeURIComponent(conversation.platform_conversation_id)}`}
className={({ isActive }): string =>
cn(
'group relative flex min-h-[2.75rem] w-full items-start gap-2 rounded-md px-3 py-2 transition-colors duration-150',
isActive ? 'border-l-2 border-l-primary bg-accent-muted' : 'hover:bg-surface-elevated'
)
}
>
<div
className={cn(
'h-2 w-2 shrink-0 rounded-full',
status === 'running' && 'bg-primary animate-pulse',
status === 'failed' && 'bg-destructive',
status === 'idle' && 'bg-text-tertiary'
)}
/>
<div className="flex min-w-0 flex-1 flex-col">
{isEditing ? (
<input
ref={inputRef}
value={editValue}
onChange={(e): void => {
setEditValue(e.target.value);
}}
onBlur={handleRenameSubmit}
onKeyDown={handleKeyDown}
onClick={(e): void => {
e.preventDefault();
e.stopPropagation();
}}
className="w-full bg-transparent text-sm text-text-primary outline-none border-b border-primary"
/>
) : (
<span
className="truncate text-sm text-text-primary"
title={conversation.title ?? 'Untitled conversation'}
>
{displayName}
</span>
)}
<span className="truncate text-[11px] text-text-tertiary">{lastActivity}</span>
{projectName && (
<span className="truncate text-[10px] text-text-tertiary">{projectName}</span>
)}
</div>
{!isEditing && (
<>
<div className="absolute right-2 top-2 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity duration-150 z-10">
<button
onClick={(e): void => {
e.preventDefault();
e.stopPropagation();
setEditValue(conversation.title ?? '');
setIsEditing(true);
setTimeout(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, 0);
}}
className="p-1 rounded hover:bg-surface-elevated"
title="Rename conversation"
>
<Pencil className="h-3.5 w-3.5 text-text-tertiary hover:text-primary" />
</button>
<button
onClick={(e): void => {
e.preventDefault();
e.stopPropagation();
setDeleteError(null);
setDeleteDialogOpen(true);
}}
className="p-1 rounded hover:bg-surface-elevated"
title="Delete conversation"
>
<Trash2 className="h-3.5 w-3.5 text-text-tertiary hover:text-error" />
</button>
</div>
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete conversation?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete this conversation and its messages. This action
cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
{deleteError && <p className="text-sm text-error px-1">{deleteError}</p>}
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete}>Delete</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)}
{badge !== undefined && badge > 0 && (
<span className="flex h-5 min-w-5 items-center justify-center rounded-full bg-error text-[10px] font-semibold text-white px-1">
{badge > 99 ? '99+' : String(badge)}
</span>
)}
</NavLink>
);
}

View file

@ -0,0 +1,92 @@
import { useState } from 'react';
import { ExternalLink, Copy, Check } from 'lucide-react';
import { cn } from '@/lib/utils';
interface HeaderProps {
title: string;
subtitle?: string;
projectName?: string;
connected?: boolean;
}
function smartPath(fullPath: string): string {
const segments = fullPath.split('/').filter(Boolean);
if (segments.length <= 3) return fullPath;
return '.../' + segments.slice(-3).join('/');
}
export function Header({
title,
subtitle,
projectName,
connected,
}: HeaderProps): React.ReactElement {
const [copied, setCopied] = useState(false);
const openInVSCode = (): void => {
if (subtitle) {
// Normalize backslashes to forward slashes for the vscode:// URI
const normalizedPath = subtitle.replace(/\\/g, '/');
window.open(`vscode://file/${normalizedPath}`, '_blank');
}
};
const copyPath = (): void => {
if (subtitle) {
void navigator.clipboard.writeText(subtitle).then(() => {
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 1500);
});
}
};
return (
<header className="flex h-12 shrink-0 items-center border-b border-border px-6">
<div className="flex min-w-0 flex-1 flex-col justify-center">
<h1 className="text-base font-semibold text-text-primary">{title}</h1>
{subtitle ? (
<button
onClick={copyPath}
className="group flex items-center gap-1 text-xs text-text-secondary truncate max-w-sm hover:text-text-primary transition-colors text-left"
title={subtitle}
>
<span className="truncate">{smartPath(subtitle)}</span>
{copied ? (
<Check className="h-3 w-3 shrink-0 text-success" />
) : (
<Copy className="h-3 w-3 shrink-0 opacity-0 group-hover:opacity-100" />
)}
</button>
) : projectName ? (
<span className="text-xs text-text-secondary">{projectName}</span>
) : connected !== undefined ? (
<span className="text-xs text-text-tertiary italic">No project</span>
) : null}
</div>
<div className="ml-auto flex items-center gap-3">
{subtitle && (
<button
onClick={openInVSCode}
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-text-secondary hover:bg-surface hover:text-text-primary transition-colors"
title="Open in VS Code"
>
<ExternalLink className="h-3.5 w-3.5" />
<span>Open in IDE</span>
</button>
)}
{connected !== undefined && (
<div className="flex items-center gap-2">
<div
className={cn('h-2 w-2 rounded-full', connected ? 'bg-success' : 'bg-text-tertiary')}
/>
<span className="text-xs text-text-tertiary">
{connected ? 'Connected' : 'Disconnected'}
</span>
</div>
)}
</div>
</header>
);
}

View file

@ -0,0 +1,13 @@
import { Outlet } from 'react-router';
import { Sidebar } from './Sidebar';
export function Layout(): React.ReactElement {
return (
<div className="flex h-screen bg-background">
<Sidebar />
<main className="flex flex-1 flex-col overflow-hidden">
<Outlet />
</main>
</div>
);
}

View file

@ -0,0 +1,312 @@
import { useState, useRef, useMemo, useEffect, useCallback } from 'react';
import { NavLink } from 'react-router';
import { Plus, Settings, Loader2, Workflow, Hammer } from 'lucide-react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { SearchBar } from '@/components/sidebar/SearchBar';
import { ProjectSelector } from '@/components/sidebar/ProjectSelector';
import { ProjectDetail } from '@/components/sidebar/ProjectDetail';
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts';
import { listCodebases, addCodebase } from '@/lib/api';
import { cn } from '@/lib/utils';
const SIDEBAR_MIN = 240;
const SIDEBAR_MAX = 400;
const SIDEBAR_DEFAULT = 260;
const STORAGE_KEY = 'archon-sidebar-width';
const PROJECT_STORAGE_KEY = 'archon-selected-project';
function getInitialWidth(): number {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = Number(stored);
if (parsed >= SIDEBAR_MIN && parsed <= SIDEBAR_MAX) return parsed;
}
return SIDEBAR_DEFAULT;
}
function getInitialProjectId(): string | null {
return localStorage.getItem(PROJECT_STORAGE_KEY);
}
const navLinkClass = ({ isActive }: { isActive: boolean }): string =>
cn(
'flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors duration-150',
isActive
? 'border-l-2 border-primary bg-accent-muted text-primary'
: 'text-text-secondary hover:bg-surface-elevated hover:text-text-primary'
);
export function Sidebar(): React.ReactElement {
const [searchQuery, setSearchQuery] = useState('');
const searchInputRef = useRef<HTMLInputElement>(null);
const [width, setWidth] = useState(getInitialWidth);
const isResizing = useRef(false);
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(getInitialProjectId);
// Add-project state
const [showAddInput, setShowAddInput] = useState(false);
const [addValue, setAddValue] = useState('');
const [addLoading, setAddLoading] = useState(false);
const [addError, setAddError] = useState<string | null>(null);
const addInputRef = useRef<HTMLInputElement>(null);
const queryClient = useQueryClient();
const { data: codebases, isLoading: isLoadingCodebases } = useQuery({
queryKey: ['codebases'],
queryFn: listCodebases,
refetchInterval: 30_000,
});
const selectedProject = codebases?.find(cb => cb.id === selectedProjectId) ?? null;
// Auto-select: clear stale selection or auto-select first project
useEffect(() => {
if (!codebases) return;
if (selectedProjectId && !codebases.some(cb => cb.id === selectedProjectId)) {
// Selected project was deleted — clear it
setSelectedProjectId(null);
localStorage.removeItem(PROJECT_STORAGE_KEY);
} else if (!selectedProjectId && codebases.length > 0) {
// No project selected but projects exist — auto-select first
const firstId = codebases[0].id;
setSelectedProjectId(firstId);
localStorage.setItem(PROJECT_STORAGE_KEY, firstId);
}
}, [codebases, selectedProjectId]);
useEffect(() => {
localStorage.setItem(STORAGE_KEY, String(width));
}, [width]);
// Focus input when shown
useEffect(() => {
if (showAddInput) {
addInputRef.current?.focus();
}
}, [showAddInput]);
const handleMouseDown = useCallback(
(e: React.MouseEvent): void => {
e.preventDefault();
isResizing.current = true;
const startX = e.clientX;
const startWidth = width;
document.body.style.userSelect = 'none';
document.body.style.cursor = 'col-resize';
const onMouseMove = (moveEvent: MouseEvent): void => {
const newWidth = Math.min(
SIDEBAR_MAX,
Math.max(SIDEBAR_MIN, startWidth + moveEvent.clientX - startX)
);
setWidth(newWidth);
};
const onMouseUp = (): void => {
isResizing.current = false;
document.body.style.userSelect = '';
document.body.style.cursor = '';
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
};
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
},
[width]
);
const handleSelectProject = useCallback((id: string): void => {
setSelectedProjectId(id);
localStorage.setItem(PROJECT_STORAGE_KEY, id);
}, []);
const handleAddSubmit = useCallback((): void => {
const trimmed = addValue.trim();
if (!trimmed || addLoading) return;
setAddLoading(true);
setAddError(null);
// Detect: starts with / or ~ → local path; otherwise → URL
const input =
trimmed.startsWith('/') || trimmed.startsWith('~') ? { path: trimmed } : { url: trimmed };
void addCodebase(input)
.then(codebase => {
void queryClient.invalidateQueries({ queryKey: ['codebases'] });
handleSelectProject(codebase.id);
setShowAddInput(false);
setAddValue('');
setAddError(null);
})
.catch((err: Error) => {
setAddError(err.message);
})
.finally(() => {
setAddLoading(false);
});
}, [addValue, addLoading, queryClient, handleSelectProject]);
const handleAddKeyDown = useCallback(
(e: React.KeyboardEvent): void => {
if (e.key === 'Enter') {
handleAddSubmit();
} else if (e.key === 'Escape') {
setShowAddInput(false);
setAddValue('');
setAddError(null);
}
},
[handleAddSubmit]
);
const shortcuts = useMemo(
() => ({
'/': (): void => searchInputRef.current?.focus(),
Escape: (): void => {
setSearchQuery('');
searchInputRef.current?.blur();
},
}),
[]
);
useKeyboardShortcuts(shortcuts);
return (
<aside
className="relative flex h-full flex-col border-r border-border bg-surface"
style={{ width: `${String(width)}px` }}
>
{/* Logo */}
<div className="flex flex-col gap-3 p-4">
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary">
<span className="text-sm font-semibold text-primary-foreground">A</span>
</div>
<span className="text-base font-semibold text-text-primary">Archon</span>
</div>
</div>
<Separator className="bg-border" />
{/* Project Selector */}
<div className="px-2 py-2">
<div className="flex items-center justify-between px-1">
<span className="text-[11px] font-semibold uppercase tracking-wider text-text-tertiary">
Projects
</span>
<button
onClick={(): void => {
setShowAddInput(prev => !prev);
setAddError(null);
setAddValue('');
}}
className="p-0.5 rounded hover:bg-surface-elevated transition-colors"
title="Add project"
>
<Plus className="h-3.5 w-3.5 text-text-tertiary hover:text-primary" />
</button>
</div>
{showAddInput && (
<div className="mt-1.5 px-1">
<div className="flex items-center gap-1">
<input
ref={addInputRef}
value={addValue}
onChange={(e): void => {
setAddValue(e.target.value);
}}
onKeyDown={handleAddKeyDown}
onBlur={(): void => {
// Close on blur only if empty and no error
if (!addValue.trim() && !addError) {
setShowAddInput(false);
}
}}
placeholder="GitHub URL or local path"
disabled={addLoading}
className="w-full rounded-md border border-border bg-surface-elevated px-2 py-1 text-xs text-text-primary placeholder:text-text-tertiary focus:border-primary focus:outline-none disabled:opacity-50"
/>
{addLoading && <Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-primary" />}
</div>
{addError && <p className="mt-1 text-[10px] text-error line-clamp-2">{addError}</p>}
</div>
)}
<ProjectSelector
projects={codebases ?? []}
selectedProjectId={selectedProjectId}
onSelectProject={handleSelectProject}
isLoading={isLoadingCodebases}
/>
</div>
<Separator className="bg-border" />
{/* Project-scoped content */}
{selectedProjectId ? (
<>
{/* Search */}
<div className="px-3 py-2">
<SearchBar
value={searchQuery}
onChange={setSearchQuery}
placeholder="Search..."
inputRef={searchInputRef}
/>
</div>
{/* Scoped content */}
<div className="flex-1 overflow-hidden">
<ScrollArea className="h-full px-2 pb-2">
<ProjectDetail
codebaseId={selectedProjectId}
projectName={selectedProject?.name ?? ''}
repositoryUrl={selectedProject?.repository_url}
searchQuery={searchQuery}
/>
</ScrollArea>
</div>
</>
) : (
<div className="flex flex-1 flex-col items-center justify-center gap-2 px-4">
<span className="text-xs text-text-tertiary text-center">
{codebases && codebases.length > 0 ? 'Select a project' : 'Click + to add a repository'}
</span>
</div>
)}
<Separator className="bg-border" />
{/* Navigation */}
<nav className="flex flex-col gap-1 p-2">
<NavLink to="/workflows" end className={navLinkClass}>
<Workflow className="h-4 w-4" />
Workflows
</NavLink>
<NavLink to="/workflows/builder" className={navLinkClass}>
<Hammer className="h-4 w-4" />
Workflow Builder
<span className="ml-auto rounded-full bg-accent-muted px-1.5 py-0.5 text-[10px] font-medium leading-none text-primary">
Soon
</span>
</NavLink>
<NavLink to="/settings" className={navLinkClass}>
<Settings className="h-4 w-4" />
Settings
</NavLink>
</nav>
{/* Resize handle */}
<div
onMouseDown={handleMouseDown}
className="absolute right-0 top-0 bottom-0 w-1.5 cursor-col-resize bg-border/50 hover:bg-primary/40 transition-colors"
/>
</aside>
);
}

View file

@ -0,0 +1,158 @@
import { useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router';
import { listConversations, listWorkflowRuns, createConversation } from '@/lib/api';
import type { WorkflowRunResponse } from '@/lib/api';
import { ConversationItem } from '@/components/conversations/ConversationItem';
import { WorkflowInvoker } from '@/components/sidebar/WorkflowInvoker';
import { cn } from '@/lib/utils';
interface ProjectDetailProps {
codebaseId: string;
projectName: string;
repositoryUrl?: string | null;
searchQuery: string;
}
function RunStatusBadge({ status }: { status: string }): React.ReactElement {
return (
<span
className={cn(
'inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium',
status === 'running' && 'bg-primary/10 text-primary',
status === 'completed' && 'bg-success/10 text-success',
status === 'failed' && 'bg-error/10 text-error'
)}
>
{status}
</span>
);
}
function ensureUtc(timestamp: string): string {
return timestamp.endsWith('Z') ? timestamp : timestamp + 'Z';
}
function formatDuration(startedAt: string, completedAt: string | null): string {
const start = new Date(ensureUtc(startedAt)).getTime();
const end = completedAt ? new Date(ensureUtc(completedAt)).getTime() : Date.now();
const ms = end - start;
if (ms < 1000) return `${String(ms)}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
return `${(ms / 60000).toFixed(1)}m`;
}
export function ProjectDetail({
codebaseId,
projectName,
repositoryUrl,
searchQuery,
}: ProjectDetailProps): React.ReactElement {
const navigate = useNavigate();
const { data: conversations } = useQuery({
queryKey: ['conversations', { codebaseId }],
queryFn: () => listConversations(codebaseId),
refetchInterval: 10_000,
});
const { data: runs } = useQuery({
queryKey: ['workflow-runs', { codebaseId }],
queryFn: () => listWorkflowRuns({ codebaseId, limit: 20 }),
refetchInterval: 10_000,
});
const handleNewChat = async (): Promise<void> => {
try {
const { conversationId } = await createConversation(codebaseId);
navigate(`/chat/${conversationId}`);
} catch (error) {
console.error('[ProjectDetail] Failed to create conversation', { error });
}
};
const handleRunClick = (run: WorkflowRunResponse): void => {
navigate(`/workflows/runs/${run.id}`);
};
// Filter conversations by search
const filteredConversations = conversations?.filter(conv => {
if (!searchQuery) return true;
const query = searchQuery.toLowerCase();
return (conv.title ?? conv.platform_conversation_id).toLowerCase().includes(query);
});
// Filter and sort runs by search and status
const sortedRuns = runs
?.filter(run => {
if (!searchQuery) return true;
return run.workflow_name.toLowerCase().includes(searchQuery.toLowerCase());
})
.sort((a, b) => {
const priority: Record<string, number> = { failed: 0, running: 1, completed: 2 };
return (priority[a.status] ?? 3) - (priority[b.status] ?? 3);
});
return (
<div className="flex flex-col gap-3">
<div className="px-1">
<h3 className="text-sm font-semibold text-text-primary truncate">{projectName}</h3>
{repositoryUrl && (
<p className="text-[10px] text-text-tertiary truncate">{repositoryUrl}</p>
)}
</div>
<button
onClick={handleNewChat}
className="mx-1 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-accent-hover transition-colors"
>
New Chat
</button>
<WorkflowInvoker codebaseId={codebaseId} />
{/* Conversations section */}
<div>
<span className="px-1 text-[11px] font-semibold uppercase tracking-wider text-text-tertiary">
Conversations
</span>
<div className="mt-1 flex flex-col gap-0.5">
{filteredConversations && filteredConversations.length > 0 ? (
filteredConversations.map(conv => (
<ConversationItem key={conv.id} conversation={conv} />
))
) : (
<span className="px-1 text-xs text-text-tertiary">No conversations</span>
)}
</div>
</div>
{/* Workflow runs section */}
<div>
<span className="px-1 text-[11px] font-semibold uppercase tracking-wider text-text-tertiary">
Workflow Runs
</span>
<div className="mt-1 flex flex-col gap-0.5">
{sortedRuns && sortedRuns.length > 0 ? (
sortedRuns.map(run => (
<button
key={run.id}
onClick={(): void => {
handleRunClick(run);
}}
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-xs hover:bg-surface-elevated transition-colors w-full text-left"
>
<span className="truncate flex-1 text-text-primary">{run.workflow_name}</span>
<RunStatusBadge status={run.status} />
<span className="text-text-tertiary shrink-0">
{formatDuration(run.started_at, run.completed_at)}
</span>
</button>
))
) : (
<span className="px-1 text-xs text-text-tertiary">No workflow runs</span>
)}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,134 @@
import { useState, useCallback } from 'react';
import { FolderGit2, Trash2 } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query';
import type { CodebaseResponse } from '@/lib/api';
import { deleteCodebase } from '@/lib/api';
import { cn } from '@/lib/utils';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
interface ProjectSelectorProps {
projects: CodebaseResponse[];
selectedProjectId: string | null;
onSelectProject: (id: string) => void;
isLoading: boolean;
}
export function ProjectSelector({
projects,
selectedProjectId,
onSelectProject,
isLoading,
}: ProjectSelectorProps): React.ReactElement {
const queryClient = useQueryClient();
const [deleteTarget, setDeleteTarget] = useState<CodebaseResponse | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
const handleDelete = useCallback((): void => {
if (!deleteTarget) return;
const id = deleteTarget.id;
setDeleteError(null);
void deleteCodebase(id)
.then(() => {
void queryClient.invalidateQueries({ queryKey: ['codebases'] });
setDeleteTarget(null);
})
.catch((err: Error) => {
setDeleteError(err.message);
});
}, [deleteTarget, queryClient]);
if (isLoading) {
return (
<div className="flex items-center justify-center py-4">
<span className="text-xs text-text-tertiary">Loading...</span>
</div>
);
}
if (projects.length === 0) {
return (
<div className="flex flex-col items-center justify-center gap-2 py-6">
<FolderGit2 className="h-8 w-8 text-text-tertiary" />
<span className="text-xs text-text-tertiary">No projects yet</span>
<span className="text-[10px] text-text-tertiary">Click + to add a repository</span>
</div>
);
}
return (
<>
<div className="flex flex-col gap-0.5 mt-1">
{projects.map(project => (
<div key={project.id} className="group relative">
<button
onClick={(): void => {
onSelectProject(project.id);
}}
className={cn(
'flex items-center gap-2 rounded-md px-3 py-1.5 text-left transition-colors w-full',
selectedProjectId === project.id
? 'border-l-2 border-primary bg-accent-muted text-primary'
: 'text-text-secondary hover:bg-surface-elevated hover:text-text-primary'
)}
>
<FolderGit2 className="h-4 w-4 shrink-0" />
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm">{project.name}</span>
{project.repository_url && (
<span className="truncate text-[10px] text-text-tertiary">
{project.repository_url}
</span>
)}
</div>
</button>
<button
onClick={(e): void => {
e.stopPropagation();
setDeleteError(null);
setDeleteTarget(project);
}}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-elevated"
title="Remove project"
>
<Trash2 className="h-3.5 w-3.5 text-text-tertiary hover:text-error" />
</button>
</div>
))}
</div>
<AlertDialog
open={deleteTarget !== null}
onOpenChange={(open): void => {
if (!open) {
setDeleteTarget(null);
setDeleteError(null);
}
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove project?</AlertDialogTitle>
<AlertDialogDescription>
This will remove <strong>{deleteTarget?.name}</strong> from Archon, delete its
workspace directory and worktrees. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
{deleteError && <p className="text-sm text-error px-1">{deleteError}</p>}
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete}>Remove</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View file

@ -0,0 +1,42 @@
import { Search, X } from 'lucide-react';
import type { RefObject } from 'react';
interface SearchBarProps {
value: string;
onChange: (value: string) => void;
placeholder: string;
inputRef?: RefObject<HTMLInputElement | null>;
}
export function SearchBar({
value,
onChange,
placeholder,
inputRef,
}: SearchBarProps): React.ReactElement {
return (
<div className="relative flex items-center">
<Search className="absolute left-2 h-3.5 w-3.5 text-text-tertiary" />
<input
ref={inputRef}
type="text"
value={value}
onChange={(e): void => {
onChange(e.target.value);
}}
placeholder={placeholder}
className="h-8 w-full rounded-md border border-border bg-surface pl-7 pr-7 text-sm text-text-primary placeholder:text-text-tertiary outline-none focus:border-primary transition-colors"
/>
{value && (
<button
onClick={(): void => {
onChange('');
}}
className="absolute right-2 p-0.5 rounded hover:bg-surface-elevated"
>
<X className="h-3 w-3 text-text-tertiary" />
</button>
)}
</div>
);
}

View file

@ -0,0 +1,117 @@
import { useState } from 'react';
import { useNavigate } from 'react-router';
import { useQuery } from '@tanstack/react-query';
import { Loader2, X } from 'lucide-react';
import { listWorkflows, createConversation, runWorkflow } from '@/lib/api';
interface WorkflowInvokerProps {
codebaseId: string;
}
export function WorkflowInvoker({ codebaseId }: WorkflowInvokerProps): React.ReactElement | null {
const navigate = useNavigate();
const [selectedWorkflow, setSelectedWorkflow] = useState<string | null>(null);
const [message, setMessage] = useState('');
const [running, setRunning] = useState(false);
const [error, setError] = useState<string | null>(null);
const { data: workflows } = useQuery({
queryKey: ['workflows'],
queryFn: () => listWorkflows(),
});
if (!workflows || workflows.length === 0) return null;
const handleCancel = (): void => {
setSelectedWorkflow(null);
setMessage('');
setError(null);
};
const handleRun = async (): Promise<void> => {
if (!selectedWorkflow || !message.trim() || running) return;
setRunning(true);
setError(null);
try {
const { conversationId } = await createConversation(codebaseId);
await runWorkflow(selectedWorkflow, conversationId, message.trim());
setSelectedWorkflow(null);
setMessage('');
navigate(`/chat/${conversationId}`);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to start workflow');
} finally {
setRunning(false);
}
};
if (!selectedWorkflow) {
return (
<div className="mx-1">
<select
value=""
onChange={(e): void => {
setSelectedWorkflow(e.target.value);
setError(null);
}}
className="w-full rounded-md border border-border bg-surface-elevated px-2 py-1.5 text-xs text-text-secondary cursor-pointer focus:border-primary focus:outline-none"
>
<option value="" disabled>
Run workflow...
</option>
{workflows.map(wf => (
<option key={wf.name} value={wf.name}>
{wf.name}
</option>
))}
</select>
</div>
);
}
return (
<div className="mx-1 rounded-md border border-border bg-surface-elevated p-2">
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-medium text-text-primary">{selectedWorkflow}</span>
<button
onClick={handleCancel}
className="p-0.5 rounded hover:bg-surface transition-colors"
title="Cancel"
>
<X className="h-3 w-3 text-text-tertiary" />
</button>
</div>
<input
type="text"
value={message}
onChange={(e): void => {
setMessage(e.target.value);
}}
onKeyDown={(e): void => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
void handleRun();
}
if (e.key === 'Escape') handleCancel();
}}
placeholder="Enter message..."
disabled={running}
className="w-full rounded-md border border-border bg-surface px-2 py-1 text-xs text-text-primary placeholder:text-text-tertiary focus:border-primary focus:outline-none disabled:opacity-50"
autoFocus
/>
<div className="flex items-center justify-end gap-2 mt-1.5">
{error && <span className="text-[10px] text-error flex-1 line-clamp-1">{error}</span>}
<button
onClick={(): void => {
void handleRun();
}}
disabled={running || !message.trim()}
className="flex items-center gap-1 rounded-md bg-primary px-2 py-1 text-[11px] font-medium text-primary-foreground hover:bg-accent-hover transition-colors disabled:opacity-50"
>
{running && <Loader2 className="h-3 w-3 animate-spin" />}
{running ? 'Starting...' : 'Run'}
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,121 @@
import * as React from 'react';
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
import { cn } from '@/lib/utils';
import { buttonVariants } from '@/components/ui/button';
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef<
React.ComponentRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
ref={ref}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef<
React.ComponentRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-border bg-surface-elevated p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className
)}
{...props}
/>
</AlertDialogPortal>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
function AlertDialogHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div className={cn('flex flex-col space-y-2 text-center sm:text-left', className)} {...props} />
);
}
AlertDialogHeader.displayName = 'AlertDialogHeader';
function AlertDialogFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
{...props}
/>
);
}
AlertDialogFooter.displayName = 'AlertDialogFooter';
const AlertDialogTitle = React.forwardRef<
React.ComponentRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold text-text-primary', className)}
{...props}
/>
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ComponentRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn('text-sm text-text-secondary', className)}
{...props}
/>
));
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ComponentRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants({ variant: 'destructive' }), className)}
{...props}
/>
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ComponentRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', className)}
{...props}
/>
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};

View file

@ -0,0 +1,46 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { Slot } from 'radix-ui';
import { cn } from '@/lib/utils';
const badgeVariants = cva(
'inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
secondary: 'bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
destructive:
'bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
ghost: '[a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
link: 'text-primary underline-offset-4 [a&]:hover:underline',
},
},
defaultVariants: {
variant: 'default',
},
}
);
function Badge({
className,
variant = 'default',
asChild = false,
...props
}: React.ComponentProps<'span'> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : 'span';
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
);
}
export { Badge, badgeVariants };

View file

@ -0,0 +1,62 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { Slot } from 'radix-ui';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive:
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
'icon-xs': "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
'icon-sm': 'size-8',
'icon-lg': 'size-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
function Button({
className,
variant = 'default',
size = 'default',
asChild = false,
...props
}: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : 'button';
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };

View file

@ -0,0 +1,75 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
function Card({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card"
className={cn(
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
className
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-header"
className={cn(
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
className
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-title"
className={cn('leading-none font-semibold', className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
}
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-action"
className={cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', className)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="card-content" className={cn('px-6', className)} {...props} />;
}
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-footer"
className={cn('flex items-center px-6 [.border-t]:pt-6', className)}
{...props}
/>
);
}
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };

View file

@ -0,0 +1,21 @@
'use client';
import { Collapsible as CollapsiblePrimitive } from 'radix-ui';
function Collapsible({ ...props }: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return <CollapsiblePrimitive.CollapsibleTrigger data-slot="collapsible-trigger" {...props} />;
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return <CollapsiblePrimitive.CollapsibleContent data-slot="collapsible-content" {...props} />;
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent };

View file

@ -0,0 +1,21 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
return (
<input
type={type}
data-slot="input"
className={cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
className
)}
{...props}
/>
);
}
export { Input };

View file

@ -0,0 +1,54 @@
import * as React from 'react';
import { ScrollArea as ScrollAreaPrimitive } from 'radix-ui';
import { cn } from '@/lib/utils';
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn('relative', className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
);
}
function ScrollBar({
className,
orientation = 'vertical',
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
'flex touch-none p-px transition-colors select-none',
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent',
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent',
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
);
}
export { ScrollArea, ScrollBar };

View file

@ -0,0 +1,28 @@
'use client';
import * as React from 'react';
import { Separator as SeparatorPrimitive } from 'radix-ui';
import { cn } from '@/lib/utils';
function Separator({
className,
orientation = 'horizontal',
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
className
)}
{...props}
/>
);
}
export { Separator };

View file

@ -0,0 +1,18 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
return (
<textarea
data-slot="textarea"
className={cn(
'border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className
)}
{...props}
/>
);
}
export { Textarea };

View file

@ -0,0 +1,51 @@
import * as React from 'react';
import { Tooltip as TooltipPrimitive } from 'radix-ui';
import { cn } from '@/lib/utils';
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
);
}
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
}
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
'bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance',
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

View file

@ -0,0 +1,63 @@
import type { WorkflowArtifact } from '@/lib/types';
interface ArtifactSummaryProps {
artifacts: WorkflowArtifact[];
}
function ArtifactIcon({ type }: { type: string }): React.ReactElement {
switch (type) {
case 'pr':
return <span className="text-accent">PR</span>;
case 'commit':
return <span className="text-success">C</span>;
case 'branch':
return <span className="text-text-secondary">B</span>;
case 'file':
case 'file_created':
case 'file_modified':
return <span className="text-text-secondary">F</span>;
default:
return <span className="text-text-secondary">*</span>;
}
}
export function ArtifactSummary({ artifacts }: ArtifactSummaryProps): React.ReactElement {
if (artifacts.length === 0) {
return <></>;
}
return (
<div className="rounded-lg border border-border bg-surface p-3">
<h4 className="text-xs font-semibold text-text-secondary uppercase tracking-wider mb-2">
Artifacts
</h4>
<div className="space-y-1.5">
{artifacts.map((artifact, idx) => (
<div key={idx} className="flex items-center gap-2 text-sm">
<ArtifactIcon type={artifact.type} />
{artifact.url ? (
<a
href={artifact.url}
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:text-accent-bright transition-colors truncate"
>
{artifact.label}
</a>
) : (
<span className="text-text-primary truncate">{artifact.label}</span>
)}
{artifact.path && (
<span
className="text-xs text-text-secondary ml-auto shrink-0 truncate max-w-[200px]"
title={artifact.path}
>
{artifact.path}
</span>
)}
</div>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,67 @@
import type { WorkflowState } from '@/lib/types';
interface LoopIterationViewProps {
workflow: WorkflowState;
}
function formatDuration(ms: number): string {
if (ms < 1000) return `${String(ms)}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
return `${(ms / 60000).toFixed(1)}m`;
}
export function LoopIterationView({ workflow }: LoopIterationViewProps): React.ReactElement {
const elapsed = (workflow.completedAt ?? Date.now()) - workflow.startedAt;
const current = workflow.currentIteration ?? 0;
const max = workflow.maxIterations;
return (
<div className="space-y-2">
{/* Iteration counter */}
<div className="flex items-center justify-between text-sm">
<span className="text-text-primary font-medium">
Iteration {String(current)}
{max ? ` of ${String(max)}` : ''}
</span>
<span className="text-xs text-text-secondary">{formatDuration(elapsed)}</span>
</div>
{/* Progress bar */}
{max && max > 0 && (
<div className="h-1.5 rounded-full bg-surface-inset overflow-hidden">
<div
className={`h-full rounded-full transition-all ${
workflow.status === 'failed' ? 'bg-error' : 'bg-accent'
}`}
style={{ width: `${String(Math.min(100, (current / max) * 100))}%` }}
/>
</div>
)}
{/* Status */}
<div className="text-xs text-text-secondary">
{workflow.status === 'running' && (
<span className="flex items-center gap-1.5">
<span className="inline-block h-2 w-2 animate-spin rounded-full border border-accent border-t-transparent" />
Running iteration...
</span>
)}
{workflow.status === 'completed' && (
<span className="text-success">Loop completed after {String(current)} iterations</span>
)}
{workflow.status === 'failed' && (
<div className="space-y-1">
<span className="text-error">
{max && current >= max
? `Reached max iterations (${String(max)}). Consider increasing the limit.`
: 'Loop failed'}
</span>
{workflow.error && (
<div className="text-error bg-error/10 rounded p-1.5">{workflow.error}</div>
)}
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,77 @@
import type { ParallelAgentState } from '@/lib/types';
interface ParallelBlockViewProps {
agents: ParallelAgentState[];
stepName: string;
}
function StatusIcon({ status }: { status: string }): React.ReactElement {
switch (status) {
case 'completed':
return <span className="text-success text-sm">&#x2713;</span>;
case 'running':
return (
<span className="inline-block h-3 w-3 animate-spin rounded-full border-2 border-accent border-t-transparent" />
);
case 'failed':
return <span className="text-error text-sm">&#x2717;</span>;
default:
return <span className="text-text-secondary text-sm">&#x25CB;</span>;
}
}
function formatDuration(ms: number): string {
if (ms < 1000) return `${String(ms)}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
return `${(ms / 60000).toFixed(1)}m`;
}
function overallStatus(agents: ParallelAgentState[]): string {
if (agents.some(a => a.status === 'failed')) return 'failed';
if (agents.some(a => a.status === 'running')) return 'running';
if (agents.every(a => a.status === 'completed')) return 'completed';
return 'pending';
}
export function ParallelBlockView({
agents,
stepName,
}: ParallelBlockViewProps): React.ReactElement {
const status = overallStatus(agents);
const completed = agents.filter(a => a.status === 'completed').length;
return (
<div className="space-y-0.5">
{/* Parent row */}
<div className="flex items-center gap-2 text-sm">
<StatusIcon status={status} />
<span className="truncate flex-1">{stepName}</span>
<span className="text-xs text-text-secondary shrink-0">
({String(completed)}/{String(agents.length)} agents)
</span>
</div>
{/* Nested agent list */}
<div className="ml-4 border-l border-border pl-3 space-y-0.5">
{agents.map(agent => (
<div key={agent.index} className="flex items-center gap-2 text-xs">
<StatusIcon status={agent.status} />
<span
className={agent.status === 'running' ? 'text-text-primary' : 'text-text-secondary'}
>
{agent.name}
</span>
{agent.duration !== undefined && (
<span className="ml-auto text-text-secondary">{formatDuration(agent.duration)}</span>
)}
{agent.error && (
<span className="ml-auto text-error truncate max-w-[150px]" title={agent.error}>
{agent.error}
</span>
)}
</div>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,77 @@
import { useRef } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useAutoScroll } from '@/hooks/useAutoScroll';
interface StepLogsProps {
runId: string;
stepIndex: number;
lines?: string[];
}
export function StepLogs({ runId, stepIndex, lines = [] }: StepLogsProps): React.ReactElement {
const containerRef = useRef<HTMLDivElement | null>(null);
const { isAtBottom, scrollToBottom } = useAutoScroll(containerRef, [lines.length]);
const virtualizer = useVirtualizer({
count: lines.length,
getScrollElement: () => containerRef.current,
estimateSize: () => 24,
overscan: 20,
});
if (lines.length === 0) {
return (
<div className="flex-1 overflow-auto p-4 font-mono text-sm bg-surface-inset">
<div className="text-text-secondary text-xs mb-2">
Step {String(stepIndex)} logs &middot; Run {runId.slice(0, 8)}
</div>
<div className="text-text-secondary italic">
Live log output will appear here during workflow execution.
</div>
</div>
);
}
return (
<div className="flex-1 flex flex-col bg-surface-inset relative">
<div className="text-text-secondary text-xs px-4 pt-3 pb-1">
Step {String(stepIndex)} logs &middot; Run {runId.slice(0, 8)} &middot;{' '}
{String(lines.length)} lines
</div>
<div ref={containerRef} className="flex-1 overflow-auto px-4 pb-4 font-mono text-sm">
<div
style={{
height: `${String(virtualizer.getTotalSize())}px`,
width: '100%',
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map(virtualRow => (
<div
key={virtualRow.index}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${String(virtualRow.size)}px`,
transform: `translateY(${String(virtualRow.start)}px)`,
}}
className="text-text-primary whitespace-pre-wrap break-all"
>
{lines[virtualRow.index]}
</div>
))}
</div>
</div>
{!isAtBottom && (
<button
onClick={scrollToBottom}
className="absolute bottom-4 right-4 bg-accent text-white text-xs px-3 py-1.5 rounded-full shadow-lg hover:bg-accent-bright transition-colors"
>
Jump to bottom
</button>
)}
</div>
);
}

View file

@ -0,0 +1,67 @@
import { ParallelBlockView } from './ParallelBlockView';
import type { WorkflowStepState } from '@/lib/types';
interface StepProgressProps {
steps: WorkflowStepState[];
activeStepIndex: number;
onStepClick: (index: number) => void;
}
function StatusIcon({ status }: { status: string }): React.ReactElement {
switch (status) {
case 'completed':
return <span className="text-success text-sm">&#x2713;</span>;
case 'running':
return (
<span className="inline-block h-3 w-3 animate-spin rounded-full border-2 border-accent border-t-transparent" />
);
case 'failed':
return <span className="text-error text-sm">&#x2717;</span>;
default:
return <span className="text-text-secondary text-sm">&#x25CB;</span>;
}
}
function formatDuration(ms: number): string {
if (ms < 1000) return `${String(ms)}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
return `${(ms / 60000).toFixed(1)}m`;
}
export function StepProgress({
steps,
activeStepIndex,
onStepClick,
}: StepProgressProps): React.ReactElement {
return (
<div className="space-y-1 p-2">
{steps.map(step => (
<button
key={step.index}
onClick={(): void => {
onStepClick(step.index);
}}
className={`w-full text-left px-2 py-1.5 rounded transition-colors ${
step.index === activeStepIndex
? 'bg-accent/10 border-l-2 border-accent'
: 'hover:bg-surface-hover'
}`}
>
{step.agents && step.agents.length > 0 ? (
<ParallelBlockView agents={step.agents} stepName={step.name} />
) : (
<div className="flex items-center gap-2 text-sm">
<StatusIcon status={step.status} />
<span className="truncate flex-1">{step.name}</span>
{step.duration !== undefined && (
<span className="text-xs text-text-secondary shrink-0">
{formatDuration(step.duration)}
</span>
)}
</div>
)}
</button>
))}
</div>
);
}

View file

@ -0,0 +1,272 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router';
import { MessageSquare } from 'lucide-react';
import { StepProgress } from './StepProgress';
import { StepLogs } from './StepLogs';
import { WorkflowLogs } from './WorkflowLogs';
import { ArtifactSummary } from './ArtifactSummary';
import { useWorkflowStatus } from '@/hooks/useWorkflowStatus';
import { getWorkflowRun, getWorkflowRunByWorker, getCodebase } from '@/lib/api';
import type { WorkflowState, ArtifactType } from '@/lib/types';
function ensureUtc(timestamp: string): string {
return timestamp.endsWith('Z') ? timestamp : timestamp + 'Z';
}
interface WorkflowExecutionProps {
runId: string;
}
function StatusBadge({ status }: { status: string }): React.ReactElement {
const colors: Record<string, string> = {
pending: 'bg-accent/20 text-accent',
running: 'bg-accent/20 text-accent',
completed: 'bg-success/20 text-success',
failed: 'bg-error/20 text-error',
};
return (
<span
className={`px-2 py-0.5 rounded-full text-xs font-medium ${colors[status] ?? 'bg-surface text-text-secondary'}`}
>
{status}
</span>
);
}
function formatDuration(ms: number): string {
if (ms < 1000) return `${String(ms)}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
return `${(ms / 60000).toFixed(1)}m`;
}
export function WorkflowExecution({ runId }: WorkflowExecutionProps): React.ReactElement {
const navigate = useNavigate();
const { workflows, handlers: workflowHandlers } = useWorkflowStatus();
const [selectedStep, setSelectedStep] = useState(0);
const [initialData, setInitialData] = useState<WorkflowState | null>(null);
const [workerPlatformId, setWorkerPlatformId] = useState<string | null>(null);
const [parentPlatformId, setParentPlatformId] = useState<string | null>(null);
const [codebaseName, setCodebaseName] = useState<string | null>(null);
const [workerRunId, setWorkerRunId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
// Fetch initial data
useEffect(() => {
getWorkflowRun(runId)
.then(data => {
if (data.run.worker_platform_id) {
setWorkerPlatformId(data.run.worker_platform_id);
}
if (data.run.parent_platform_id) {
setParentPlatformId(data.run.parent_platform_id);
}
setInitialData({
runId: data.run.id,
workflowName: data.run.workflow_name,
status: data.run.status,
steps: ((): {
index: number;
name: string;
status: 'running' | 'completed' | 'failed';
duration?: number;
}[] => {
const stepMap = new Map<
number,
{
index: number;
name: string;
status: 'running' | 'completed' | 'failed';
duration?: number;
}
>();
for (const e of data.events.filter(
ev => ev.event_type.startsWith('step_') || ev.event_type.startsWith('loop_iteration_')
)) {
const idx = e.step_index ?? 0;
const existing = stepMap.get(idx);
const status =
e.event_type === 'step_started' || e.event_type === 'loop_iteration_started'
? ('running' as const)
: e.event_type === 'step_completed' || e.event_type === 'loop_iteration_completed'
? ('completed' as const)
: ('failed' as const);
if (!existing || status !== 'running') {
stepMap.set(idx, {
index: idx,
name: e.step_name ?? `Step ${String(idx + 1)}`,
status,
duration: e.data.duration_ms as number | undefined,
});
}
}
return Array.from(stepMap.values()).sort((a, b) => a.index - b.index);
})(),
artifacts: data.events
.filter(e => e.event_type === 'workflow_artifact')
.map(e => {
const d = e.data;
return {
type: (d.artifactType as ArtifactType) ?? 'commit',
label: (d.label as string) ?? '',
url: d.url as string | undefined,
path: d.path as string | undefined,
};
})
.filter(a => a.label || a.url || a.path),
isLoop: data.events.some(ev => ev.event_type.startsWith('loop_iteration_')),
startedAt: new Date(ensureUtc(data.run.started_at)).getTime(),
completedAt: data.run.completed_at
? new Date(ensureUtc(data.run.completed_at)).getTime()
: undefined,
});
if (data.run.codebase_id) {
getCodebase(data.run.codebase_id)
.then(cb => {
setCodebaseName(cb.name);
})
.catch((err: unknown) => {
console.warn('[WorkflowExecution] Failed to load codebase name', {
codebaseId: data.run.codebase_id,
error: err instanceof Error ? err.message : err,
});
});
}
})
.catch((err: unknown) => {
setError((err as Error).message);
});
}, [runId]);
// Look up the workflow run associated with this worker conversation
useEffect(() => {
if (!workerPlatformId) return;
getWorkflowRunByWorker(workerPlatformId)
.then(result => {
if (result) {
setWorkerRunId(result.run.id);
}
})
.catch((err: unknown) => {
// Non-critical — "View Run" link just won't appear
console.warn('[WorkflowExecution] Failed to look up worker run', {
workerPlatformId,
error: err instanceof Error ? err.message : err,
});
});
}, [workerPlatformId]);
// Prefer live state over initial data
const workflow = workflows.get(runId) ?? initialData;
// Force re-render every second while workflow is running (for live timer)
const [, setTick] = useState(0);
useEffect(() => {
if (workflow?.status !== 'running' && workflow?.status !== 'pending') return;
const interval = setInterval(() => {
setTick(t => t + 1);
}, 1000);
return (): void => {
clearInterval(interval);
};
}, [workflow?.status]);
if (error) {
return (
<div className="flex items-center justify-center h-full text-error">
<p>Failed to load workflow run: {error}</p>
</div>
);
}
if (!workflow) {
return (
<div className="flex items-center justify-center h-full text-text-secondary">
<p>Loading workflow execution...</p>
</div>
);
}
const elapsed = Math.max(0, (workflow.completedAt ?? Date.now()) - workflow.startedAt);
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-border">
<button
onClick={(): void => {
navigate(-1);
}}
className="text-text-secondary hover:text-text-primary transition-colors text-sm"
title="Back"
>
&larr;
</button>
<div className="flex items-center gap-2 min-w-0">
<h2 className="font-semibold text-text-primary truncate">{workflow.workflowName}</h2>
<StatusBadge status={workflow.status} />
</div>
<div className="flex items-center gap-2 ml-auto shrink-0">
{codebaseName && <span className="text-xs text-text-secondary">{codebaseName}</span>}
{parentPlatformId && (
<button
onClick={(): void => {
navigate(`/chat/${encodeURIComponent(parentPlatformId)}`);
}}
className="flex items-center gap-1 text-xs text-primary hover:text-accent-bright transition-colors"
title="View parent conversation"
>
<MessageSquare className="h-3 w-3" />
<span>Chat</span>
</button>
)}
{workerRunId && (
<button
onClick={(): void => {
navigate(`/workflows/runs/${workerRunId}`);
}}
className="flex items-center gap-1 text-xs text-primary hover:text-accent-bright transition-colors"
title="View workflow run details"
>
<span>View Run</span>
</button>
)}
<span className="text-xs text-text-secondary">{formatDuration(elapsed)}</span>
</div>
</div>
{/* Body: Step list + Logs */}
<div className="flex flex-1 overflow-hidden">
{/* Left panel: Step list */}
<div className="w-64 border-r border-border overflow-auto">
<StepProgress
steps={workflow.steps}
activeStepIndex={selectedStep}
onStepClick={setSelectedStep}
/>
</div>
{/* Right panel: Logs + Artifacts */}
<div className="flex-1 flex flex-col overflow-hidden">
<div className="flex-1 overflow-auto">
{workerPlatformId ? (
<WorkflowLogs
conversationId={workerPlatformId}
startedAt={workflow.startedAt}
workflowHandlers={workflowHandlers}
/>
) : (
<StepLogs runId={runId} stepIndex={selectedStep} />
)}
</div>
{workflow.status !== 'running' &&
workflow.status !== 'pending' &&
workflow.artifacts.length > 0 && (
<div className="border-t border-border p-3">
<ArtifactSummary artifacts={workflow.artifacts} />
</div>
)}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,207 @@
import { useState } from 'react';
import { useNavigate } from 'react-router';
import { useQuery } from '@tanstack/react-query';
import {
listWorkflows,
listWorkflowRuns,
listCodebases,
createConversation,
runWorkflow,
type WorkflowDefinitionResponse,
type WorkflowRunResponse,
} from '@/lib/api';
import { Button } from '@/components/ui/button';
const PROJECT_STORAGE_KEY = 'archon-selected-project';
export function WorkflowList(): React.ReactElement {
const navigate = useNavigate();
const [selectedWorkflow, setSelectedWorkflow] = useState<string | null>(null);
const [runMessage, setRunMessage] = useState('');
const [running, setRunning] = useState(false);
const [runError, setRunError] = useState<string | null>(null);
const [projectId, setProjectId] = useState<string | null>(
localStorage.getItem(PROJECT_STORAGE_KEY)
);
const { data: codebases } = useQuery({
queryKey: ['codebases'],
queryFn: listCodebases,
});
const handleRun = async (workflowName: string): Promise<void> => {
if (!runMessage.trim() || running || !projectId) return;
setRunning(true);
setRunError(null);
try {
const { conversationId } = await createConversation(projectId);
await runWorkflow(workflowName, conversationId, runMessage.trim());
setRunMessage('');
setSelectedWorkflow(null);
navigate(`/chat/${conversationId}`);
} catch (error) {
console.error('[Workflows] Failed to run workflow', { error });
setRunError(
error instanceof Error
? `Failed to start workflow: ${error.message}`
: 'Failed to start workflow. Check server connectivity.'
);
} finally {
setRunning(false);
}
};
const { data: workflows, isLoading: loadingWorkflows } = useQuery({
queryKey: ['workflows'],
queryFn: () => listWorkflows(),
});
const { data: runs, isLoading: loadingRuns } = useQuery({
queryKey: ['workflow-runs'],
queryFn: () => listWorkflowRuns({ limit: 20 }),
refetchInterval: 5000,
});
if (loadingWorkflows) {
return (
<div className="flex items-center justify-center h-32 text-text-secondary text-sm">
Loading workflows...
</div>
);
}
return (
<div className="space-y-6">
{/* Available Workflows */}
<section>
<h3 className="text-sm font-semibold text-text-primary mb-3">Available Workflows</h3>
{!workflows || workflows.length === 0 ? (
<div className="text-sm text-text-secondary">
No workflows found. Add workflow definitions to{' '}
<code className="text-xs bg-surface-inset px-1 py-0.5 rounded">.archon/workflows/</code>
</div>
) : (
<div className="grid gap-2">
{workflows.map((wf: WorkflowDefinitionResponse) => (
<div key={wf.name}>
<button
onClick={(): void => {
setSelectedWorkflow(selectedWorkflow === wf.name ? null : wf.name);
setRunMessage('');
setRunError(null);
}}
className={`w-full text-left rounded-lg border p-3 transition-colors ${
selectedWorkflow === wf.name
? 'border-accent bg-accent/5'
: 'border-border bg-surface hover:bg-surface-hover'
}`}
>
<div className="flex items-center justify-between">
<span className="font-medium text-sm text-text-primary">{wf.name}</span>
</div>
{wf.description && (
<p className="text-xs text-text-secondary mt-1">{wf.description}</p>
)}
</button>
{selectedWorkflow === wf.name && (
<div className="mt-2 p-3 rounded-lg border border-border bg-surface-inset">
<div className="flex items-center gap-2 mb-2">
<label className="text-xs text-text-secondary shrink-0">Run on</label>
<select
value={projectId ?? ''}
onChange={(e): void => {
setProjectId(e.target.value || null);
}}
className="flex-1 min-w-0 rounded-md border border-border bg-surface px-2 py-1 text-xs text-text-primary focus:outline-none focus:ring-1 focus:ring-accent"
>
<option value="" disabled>
Select a project...
</option>
{codebases?.map(cb => (
<option key={cb.id} value={cb.id}>
{cb.name}
</option>
))}
</select>
</div>
<input
type="text"
value={runMessage}
onChange={(e): void => {
setRunMessage(e.target.value);
}}
placeholder="Enter a message for this workflow..."
className="w-full px-3 py-2 rounded-md border border-border bg-surface text-sm text-text-primary placeholder:text-text-secondary focus:outline-none focus:ring-1 focus:ring-accent disabled:opacity-50"
onKeyDown={(e): void => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
void handleRun(wf.name);
}
}}
disabled={running || !projectId}
/>
<div className="flex justify-end mt-2">
<Button
size="sm"
onClick={(): void => {
void handleRun(wf.name);
}}
disabled={running || !runMessage.trim() || !projectId}
>
{running ? 'Starting...' : `Run ${wf.name}`}
</Button>
</div>
{runError && <p className="text-xs text-error mt-1">{runError}</p>}
</div>
)}
</div>
))}
</div>
)}
</section>
{/* Recent Runs */}
<section>
<h3 className="text-sm font-semibold text-text-primary mb-3">Recent Runs</h3>
{loadingRuns ? (
<div className="text-sm text-text-secondary">Loading...</div>
) : !runs || runs.length === 0 ? (
<div className="text-sm text-text-secondary">No workflow runs yet.</div>
) : (
<div className="space-y-1">
{runs.map((run: WorkflowRunResponse) => (
<button
key={run.id}
onClick={(): void => {
navigate(`/workflows/runs/${run.id}`);
}}
className="flex items-center gap-3 w-full text-left px-3 py-2 rounded-md hover:bg-surface-hover transition-colors"
>
<RunStatusDot status={run.status} />
<div className="flex-1 min-w-0">
<div className="text-sm text-text-primary truncate">{run.workflow_name}</div>
<div className="text-xs text-text-secondary truncate">{run.user_message}</div>
</div>
<span className="text-xs text-text-secondary shrink-0">
{new Date(run.started_at).toLocaleDateString()}
</span>
</button>
))}
</div>
)}
</section>
</div>
);
}
function RunStatusDot({ status }: { status: string }): React.ReactElement {
const colors: Record<string, string> = {
running: 'bg-accent',
completed: 'bg-success',
failed: 'bg-error',
};
return (
<span className={`h-2 w-2 rounded-full shrink-0 ${colors[status] ?? 'bg-text-secondary'}`} />
);
}

View file

@ -0,0 +1,205 @@
import { useState, useCallback, useEffect } from 'react';
import { MessageList } from '@/components/chat/MessageList';
import { useSSE } from '@/hooks/useSSE';
import { getMessages } from '@/lib/api';
import type { MessageResponse } from '@/lib/api';
import type {
ChatMessage,
ToolCallDisplay,
ErrorDisplay,
WorkflowStepEvent,
WorkflowStatusEvent,
ParallelAgentEvent,
WorkflowArtifactEvent,
} from '@/lib/types';
interface WorkflowLogsProps {
conversationId: string;
startedAt?: number;
workflowHandlers?: {
onWorkflowStep: (event: WorkflowStepEvent) => void;
onWorkflowStatus: (event: WorkflowStatusEvent) => void;
onParallelAgent: (event: ParallelAgentEvent) => void;
onWorkflowArtifact: (event: WorkflowArtifactEvent) => void;
};
}
/**
* Read-only chat view for a workflow's worker conversation.
* Loads historical messages and streams live updates via SSE.
*/
export function WorkflowLogs({
conversationId,
startedAt,
workflowHandlers,
}: WorkflowLogsProps): React.ReactElement {
const [messages, setMessages] = useState<ChatMessage[]>([]);
// Load historical messages on mount
useEffect(() => {
void getMessages(conversationId)
.then((rows: MessageResponse[]) => {
if (rows.length === 0) return;
const hydrated: ChatMessage[] = rows.map(row => {
let meta: {
toolCalls?: {
name: string;
input: Record<string, unknown>;
duration?: number;
}[];
error?: ErrorDisplay;
} = {};
try {
meta = JSON.parse(row.metadata) as typeof meta;
} catch {
console.warn('[WorkflowLogs] Corrupted message metadata', { messageId: row.id });
}
return {
id: row.id,
role: row.role,
content: row.content,
toolCalls: meta.toolCalls?.map((tc, i) => ({
...tc,
id: `${row.id}-tool-${String(i)}`,
startedAt: 0,
isExpanded: false,
duration: tc.duration ?? 0,
})),
error: meta.error,
timestamp: new Date(row.created_at).getTime(),
isStreaming: false,
};
});
const filtered = startedAt ? hydrated.filter(m => m.timestamp >= startedAt) : hydrated;
setMessages(prev => (prev.length > 0 ? prev : filtered));
})
.catch((e: unknown) => {
console.error('[WorkflowLogs] Failed to load message history', {
conversationId,
error: e instanceof Error ? e.message : e,
});
});
}, [conversationId, startedAt]);
const onText = useCallback((content: string): void => {
setMessages(prev => {
const last = prev[prev.length - 1];
if (last?.role === 'assistant' && last.isStreaming) {
return [...prev.slice(0, -1), { ...last, content: last.content + content }];
}
return [
...prev,
{
id: `msg-${String(Date.now())}`,
role: 'assistant' as const,
content,
timestamp: Date.now(),
isStreaming: true,
toolCalls: [],
},
];
});
}, []);
const onToolCall = useCallback((name: string, input: Record<string, unknown>): void => {
setMessages(prev => {
const last = prev[prev.length - 1];
if (last?.role === 'assistant') {
const now = Date.now();
const updatedExistingTools = (last.toolCalls ?? []).map(tc =>
!tc.output && tc.duration === undefined ? { ...tc, duration: now - tc.startedAt } : tc
);
const newTool: ToolCallDisplay = {
id: `tool-${String(now)}`,
name,
input,
startedAt: now,
isExpanded: false,
};
return [
...prev.slice(0, -1),
{
...last,
isStreaming: false,
toolCalls: [...updatedExistingTools, newTool],
},
];
}
return prev;
});
}, []);
const onToolResult = useCallback((name: string, output: string, duration: number): void => {
setMessages(prev => {
const last = prev[prev.length - 1];
if (last?.role === 'assistant' && last.toolCalls) {
const updatedTools = last.toolCalls.map(tc =>
tc.name === name && !tc.output ? { ...tc, output, duration } : tc
);
return [...prev.slice(0, -1), { ...last, toolCalls: updatedTools }];
}
return prev;
});
}, []);
const onError = useCallback((error: ErrorDisplay): void => {
setMessages(prev => {
const last = prev[prev.length - 1];
if (last?.role === 'assistant') {
return [...prev.slice(0, -1), { ...last, isStreaming: false, error }];
}
return [
...prev,
{
id: `msg-${String(Date.now())}`,
role: 'assistant',
content: '',
error,
timestamp: Date.now(),
},
];
});
}, []);
const onLockChange = useCallback((isLocked: boolean): void => {
if (!isLocked) {
const now = Date.now();
setMessages(prev =>
prev.map(msg => {
const needsToolFix = msg.toolCalls?.some(tc => !tc.output && tc.duration === undefined);
const needsStreamFix = msg.isStreaming;
if (!needsToolFix && !needsStreamFix) return msg;
return {
...msg,
isStreaming: false,
toolCalls: needsToolFix
? msg.toolCalls?.map(tc =>
!tc.output && tc.duration === undefined
? { ...tc, duration: now - tc.startedAt }
: tc
)
: msg.toolCalls,
};
})
);
}
}, []);
const onSessionInfo = useCallback((_sessionId: string, _cost?: number): void => {
// No-op for read-only view
}, []);
useSSE(conversationId, {
onText,
onToolCall,
onToolResult,
onError,
onLockChange,
onSessionInfo,
...workflowHandlers,
});
const isStreaming = messages.some(m => m.isStreaming);
return <MessageList messages={messages} isStreaming={isStreaming} />;
}

View file

@ -0,0 +1,48 @@
import { useEffect, useRef, useState, useCallback, type RefObject } from 'react';
export function useAutoScroll(
containerRef: RefObject<HTMLElement | null>,
dependencies: unknown[]
): {
isAtBottom: boolean;
scrollToBottom: () => void;
} {
const [isAtBottom, setIsAtBottom] = useState(true);
const userScrolledUpRef = useRef(false);
const scrollToBottom = useCallback((): void => {
const el = containerRef.current;
if (el) {
el.scrollTop = el.scrollHeight;
userScrolledUpRef.current = false;
setIsAtBottom(true);
}
}, [containerRef]);
// Detect user scroll position
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const handleScroll = (): void => {
const threshold = 50;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < threshold;
setIsAtBottom(atBottom);
userScrolledUpRef.current = !atBottom;
};
el.addEventListener('scroll', handleScroll, { passive: true });
return (): void => {
el.removeEventListener('scroll', handleScroll);
};
}, [containerRef]);
// Auto-scroll when dependencies change (new messages)
useEffect(() => {
if (!userScrolledUpRef.current) {
scrollToBottom();
}
}, dependencies);
return { isAtBottom, scrollToBottom };
}

View file

@ -0,0 +1,42 @@
import { useEffect, useCallback } from 'react';
type ShortcutHandler = () => void;
type ShortcutMap = Record<string, ShortcutHandler>;
export function useKeyboardShortcuts(shortcuts: ShortcutMap): void {
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
// Don't trigger shortcuts when typing in inputs
const target = e.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
// Allow Escape even in inputs
if (e.key !== 'Escape') return;
}
const mod = e.metaKey || e.ctrlKey;
let key = '';
if (mod && e.key >= '1' && e.key <= '9') {
key = `mod+${e.key}`;
} else if (e.key === '/') {
key = '/';
} else if (e.key === 'Escape') {
key = 'Escape';
}
if (key && shortcuts[key]) {
e.preventDefault();
shortcuts[key]();
}
},
[shortcuts]
);
useEffect(() => {
window.addEventListener('keydown', handleKeyDown);
return (): void => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [handleKeyDown]);
}

View file

@ -0,0 +1,194 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import type {
SSEEvent,
ErrorDisplay,
WorkflowStepEvent,
WorkflowStatusEvent,
ParallelAgentEvent,
WorkflowArtifactEvent,
WorkflowDispatchEvent,
WorkflowOutputPreviewEvent,
} from '@/lib/types';
import { SSE_BASE_URL } from '@/lib/api';
function parseSSEEvent(raw: string): SSEEvent | null {
try {
const parsed = JSON.parse(raw) as Record<string, unknown>;
if (!parsed || typeof parsed.type !== 'string') {
console.error('[SSE] Malformed event: missing type field', { raw });
return null;
}
return parsed as unknown as SSEEvent;
} catch (parseErr) {
console.error('[SSE] Failed to parse event:', {
raw,
error: (parseErr as Error).message,
});
return null;
}
}
interface SSEHandlers {
onText: (content: string) => void;
onToolCall: (name: string, input: Record<string, unknown>) => void;
onToolResult: (name: string, output: string, duration: number) => void;
onError: (error: ErrorDisplay) => void;
onLockChange: (locked: boolean, queuePosition?: number) => void;
onSessionInfo: (sessionId: string, cost?: number) => void;
onWorkflowStep?: (event: WorkflowStepEvent) => void;
onWorkflowStatus?: (event: WorkflowStatusEvent) => void;
onParallelAgent?: (event: ParallelAgentEvent) => void;
onWorkflowArtifact?: (event: WorkflowArtifactEvent) => void;
onWorkflowDispatch?: (event: WorkflowDispatchEvent) => void;
onWorkflowOutputPreview?: (event: WorkflowOutputPreviewEvent) => void;
onWarning?: (message: string) => void;
}
export function useSSE(
conversationId: string | null,
handlers: SSEHandlers
): { connected: boolean } {
const [connected, setConnected] = useState(false);
const handlersRef = useRef(handlers);
handlersRef.current = handlers;
// Text batching: accumulate text for 50ms before dispatching
const textBufferRef = useRef('');
const flushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const flushText = useCallback((): void => {
if (textBufferRef.current) {
handlersRef.current.onText(textBufferRef.current);
textBufferRef.current = '';
}
flushTimerRef.current = null;
}, []);
useEffect(() => {
if (!conversationId) return;
const eventSource = new EventSource(`${SSE_BASE_URL}/api/stream/${conversationId}`);
eventSource.onopen = (): void => {
setConnected(true);
};
eventSource.onerror = (): void => {
setConnected(false);
console.warn('[SSE] Connection error', {
conversationId,
readyState: eventSource.readyState,
});
if (eventSource.readyState === EventSource.CLOSED) {
handlersRef.current.onError({
message: 'Lost connection to server. Please refresh the page.',
classification: 'transient',
suggestedActions: ['Refresh the page', 'Check that the server is running'],
});
}
};
eventSource.onmessage = (event: MessageEvent): void => {
const data = parseSSEEvent(event.data as string);
if (!data) {
handlersRef.current.onError({
message: 'Received malformed response from server',
classification: 'transient',
suggestedActions: ['Refresh the page if chat appears stuck'],
});
return;
}
try {
const h = handlersRef.current;
switch (data.type) {
case 'text':
textBufferRef.current += data.content;
if (!flushTimerRef.current) {
flushTimerRef.current = setTimeout(flushText, 50);
}
break;
case 'tool_call':
h.onToolCall(data.name, data.input);
break;
case 'tool_result':
h.onToolResult(data.name, data.output, data.duration);
break;
case 'error':
h.onError({
message: data.message,
classification: data.classification ?? 'transient',
suggestedActions: data.suggestedActions ?? [],
});
break;
case 'conversation_lock':
// Flush any buffered text before processing lock change,
// otherwise text arriving just before lock release creates
// a streaming message that never gets cleared.
if (!data.locked && textBufferRef.current) {
if (flushTimerRef.current) {
clearTimeout(flushTimerRef.current);
flushTimerRef.current = null;
}
flushText();
}
h.onLockChange(data.locked, data.queuePosition);
break;
case 'session_info':
h.onSessionInfo(data.sessionId, data.cost);
break;
case 'workflow_step':
h.onWorkflowStep?.(data);
break;
case 'workflow_status':
h.onWorkflowStatus?.(data);
break;
case 'parallel_agent':
h.onParallelAgent?.(data);
break;
case 'workflow_artifact':
h.onWorkflowArtifact?.(data);
break;
case 'workflow_dispatch':
h.onWorkflowDispatch?.(data);
break;
case 'workflow_output_preview':
h.onWorkflowOutputPreview?.(data);
break;
case 'warning':
h.onWarning?.(data.message);
break;
case 'heartbeat':
break;
default: {
console.warn('[SSE] Unknown event type', { type: (data as { type: string }).type });
break;
}
}
} catch (handlerError) {
console.error('[SSE] Handler error for event type:', data.type, handlerError);
try {
handlersRef.current.onError({
message: `Failed to process ${data.type} event. UI may be out of sync.`,
classification: 'transient',
suggestedActions: ['Refresh the page if chat appears stuck'],
});
} catch {
// Avoid infinite loop if onError itself throws
}
}
};
return (): void => {
eventSource.close();
setConnected(false);
if (flushTimerRef.current) {
clearTimeout(flushTimerRef.current);
flushText();
}
};
}, [conversationId, flushText]);
return { connected };
}

View file

@ -0,0 +1,241 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import { getWorkflowRun } from '@/lib/api';
import type {
WorkflowState,
WorkflowStepState,
WorkflowStepEvent,
WorkflowStatusEvent,
ParallelAgentEvent,
WorkflowArtifactEvent,
} from '@/lib/types';
interface UseWorkflowStatusReturn {
workflows: Map<string, WorkflowState>;
activeWorkflow: WorkflowState | null;
handlers: {
onWorkflowStep: (event: WorkflowStepEvent) => void;
onWorkflowStatus: (event: WorkflowStatusEvent) => void;
onParallelAgent: (event: ParallelAgentEvent) => void;
onWorkflowArtifact: (event: WorkflowArtifactEvent) => void;
};
}
export function useWorkflowStatus(): UseWorkflowStatusReturn {
const [workflows, setWorkflows] = useState<Map<string, WorkflowState>>(new Map());
const handleWorkflowStatus = useCallback((event: WorkflowStatusEvent): void => {
setWorkflows(prev => {
const next = new Map(prev);
const existing = next.get(event.runId);
if (!existing) {
// New workflow or first SSE event for a REST-loaded workflow
next.set(event.runId, {
runId: event.runId,
workflowName: event.workflowName,
status: event.status,
steps: [],
artifacts: [],
isLoop: false,
startedAt: event.timestamp,
completedAt: event.status !== 'running' ? event.timestamp : undefined,
error: event.error,
});
} else {
next.set(event.runId, {
...existing,
status: event.status,
error: event.error,
completedAt: event.status !== 'running' ? event.timestamp : undefined,
});
}
return next;
});
}, []);
const handleWorkflowStep = useCallback((event: WorkflowStepEvent): void => {
setWorkflows(prev => {
const next = new Map(prev);
const wf = next.get(event.runId);
if (!wf) return prev;
const steps = [...wf.steps];
const existingIdx = steps.findIndex(s => s.index === event.step);
const stepState: WorkflowStepState = {
index: event.step,
name: event.name,
status: event.status,
duration: event.duration,
agents: existingIdx >= 0 ? steps[existingIdx].agents : undefined,
};
if (existingIdx >= 0) {
steps[existingIdx] = { ...steps[existingIdx], ...stepState };
} else {
steps.push(stepState);
}
const isLoop = event.iteration !== undefined;
next.set(event.runId, {
...wf,
steps,
isLoop,
currentIteration: event.iteration,
maxIterations: isLoop && event.total > 0 ? event.total : wf.maxIterations,
});
return next;
});
}, []);
const handleParallelAgent = useCallback((event: ParallelAgentEvent): void => {
setWorkflows(prev => {
const next = new Map(prev);
const wf = next.get(event.runId);
if (!wf) return prev;
const steps = [...wf.steps];
const stepIdx = steps.findIndex(s => s.index === event.step);
if (stepIdx < 0) return prev;
const step = { ...steps[stepIdx] };
const agents = [...(step.agents ?? [])];
const agentIdx = agents.findIndex(a => a.index === event.agentIndex);
const agentState = {
index: event.agentIndex,
name: event.name,
status: event.status,
duration: event.duration,
error: event.error,
};
if (agentIdx >= 0) {
agents[agentIdx] = agentState;
} else {
agents.push(agentState);
}
step.agents = agents;
steps[stepIdx] = step;
next.set(event.runId, { ...wf, steps });
return next;
});
}, []);
const handleWorkflowArtifact = useCallback((event: WorkflowArtifactEvent): void => {
setWorkflows(prev => {
const next = new Map(prev);
const wf = next.get(event.runId);
if (!wf) return prev;
next.set(event.runId, {
...wf,
artifacts: [
...wf.artifacts,
{
type: event.artifactType,
label: event.label,
url: event.url,
path: event.path,
},
],
});
return next;
});
}, []);
// Poll for stuck workflows: if any workflow is "running" for >30s, check REST API
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
const hasRunning = Array.from(workflows.values()).some(wf => wf.status === 'running');
if (hasRunning && !pollingRef.current) {
pollingRef.current = setInterval(() => {
for (const wf of workflows.values()) {
if (wf.status !== 'running') continue;
// Only poll workflows running for >30s (safety net, not primary mechanism)
if (Date.now() - wf.startedAt < 30_000) continue;
void getWorkflowRun(wf.runId)
.then(data => {
const serverStatus = data.run.status;
if (serverStatus === 'completed' || serverStatus === 'failed') {
setWorkflows(prev => {
const next = new Map(prev);
const existing = next.get(wf.runId);
if (existing?.status === 'running') {
next.set(wf.runId, {
...existing,
status: serverStatus,
completedAt: data.run.completed_at
? new Date(
data.run.completed_at.endsWith('Z')
? data.run.completed_at
: data.run.completed_at + 'Z'
).getTime()
: Date.now(),
});
}
return next;
});
}
})
.catch((err: unknown) => {
console.warn('[WorkflowStatus] Polling failed', {
runId: wf.runId,
error: err instanceof Error ? err.message : err,
});
setWorkflows(prev => {
const next = new Map(prev);
const existing = next.get(wf.runId);
if (existing?.status === 'running') {
next.set(wf.runId, { ...existing, stale: true });
}
return next;
});
});
}
}, 15_000);
} else if (!hasRunning && pollingRef.current) {
clearInterval(pollingRef.current);
pollingRef.current = null;
}
return (): void => {
if (pollingRef.current) {
clearInterval(pollingRef.current);
pollingRef.current = null;
}
};
}, [workflows]);
// Find the most recent running workflow
let activeWorkflow: WorkflowState | null = null;
for (const wf of workflows.values()) {
if (wf.status === 'running') {
if (!activeWorkflow || wf.startedAt > activeWorkflow.startedAt) {
activeWorkflow = wf;
}
}
}
// If no running workflow, show the most recent completed/failed
if (!activeWorkflow) {
for (const wf of workflows.values()) {
if (!activeWorkflow || wf.startedAt > activeWorkflow.startedAt) {
activeWorkflow = wf;
}
}
}
return {
workflows,
activeWorkflow,
handlers: {
onWorkflowStep: handleWorkflowStep,
onWorkflowStatus: handleWorkflowStatus,
onParallelAgent: handleParallelAgent,
onWorkflowArtifact: handleWorkflowArtifact,
},
};
}

249
packages/web/src/index.css Normal file
View file

@ -0,0 +1,249 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@import 'shadcn/tailwind.css';
@custom-variant dark (&:is(.dark *));
/* Dark-only theme - uses our custom palette as root defaults */
:root {
/* App-specific colors */
--surface: oklch(0.18 0.008 260);
--surface-elevated: oklch(0.22 0.01 260);
--border-bright: oklch(0.35 0.015 260);
--text-primary: oklch(0.93 0.005 260);
--text-secondary: oklch(0.65 0.01 260);
--text-tertiary: oklch(0.45 0.01 260);
--accent-hover: oklch(0.58 0.18 250);
--accent-muted: oklch(0.3 0.08 250);
--surface-inset: oklch(0.12 0.005 260);
--surface-hover: oklch(0.24 0.01 260);
--accent-bright: oklch(0.72 0.18 250);
--success: oklch(0.65 0.17 155);
--warning: oklch(0.75 0.15 75);
--error: oklch(0.6 0.2 25);
/* shadcn variables mapped to dark theme */
--radius: 0.625rem;
--background: oklch(0.14 0.005 260);
--foreground: oklch(0.93 0.005 260);
--card: oklch(0.18 0.008 260);
--card-foreground: oklch(0.93 0.005 260);
--popover: oklch(0.18 0.008 260);
--popover-foreground: oklch(0.93 0.005 260);
--primary: oklch(0.65 0.18 250);
--primary-foreground: oklch(0.98 0.005 260);
--secondary: oklch(0.22 0.01 260);
--secondary-foreground: oklch(0.93 0.005 260);
--muted: oklch(0.22 0.01 260);
--muted-foreground: oklch(0.65 0.01 260);
--accent: oklch(0.25 0.06 250);
--accent-foreground: oklch(0.93 0.005 260);
--destructive: oklch(0.6 0.2 25);
--border: oklch(0.28 0.01 260);
--input: oklch(0.28 0.01 260);
--ring: oklch(0.65 0.18 250);
--chart-1: oklch(0.65 0.18 250);
--chart-2: oklch(0.65 0.17 155);
--chart-3: oklch(0.75 0.15 75);
--chart-4: oklch(0.6 0.2 25);
--chart-5: oklch(0.58 0.18 250);
--sidebar: oklch(0.18 0.008 260);
--sidebar-foreground: oklch(0.93 0.005 260);
--sidebar-primary: oklch(0.65 0.18 250);
--sidebar-primary-foreground: oklch(0.98 0.005 260);
--sidebar-accent: oklch(0.25 0.06 250);
--sidebar-accent-foreground: oklch(0.93 0.005 260);
--sidebar-border: oklch(0.28 0.01 260);
--sidebar-ring: oklch(0.65 0.18 250);
}
@theme inline {
/* App-specific theme colors */
--color-background: var(--background);
--color-surface: var(--surface);
--color-surface-elevated: var(--surface-elevated);
--color-border: var(--border);
--color-border-bright: var(--border-bright);
--color-text-primary: var(--text-primary);
--color-text-secondary: var(--text-secondary);
--color-text-tertiary: var(--text-tertiary);
--color-accent: var(--accent);
--color-accent-hover: var(--accent-hover);
--color-accent-muted: var(--accent-muted);
--color-surface-inset: var(--surface-inset);
--color-surface-hover: var(--surface-hover);
--color-accent-bright: var(--accent-bright);
--color-success: var(--success);
--color-warning: var(--warning);
--color-error: var(--error);
--font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
/* shadcn theme mappings */
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
}
/* Base body styling */
body {
background-color: var(--background);
color: var(--text-primary);
font-family: var(--font-sans);
}
/* Scrollbar styling for dark theme */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--background);
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--border-bright);
}
/* Highlight.js dark theme overrides */
.hljs {
background: var(--surface) !important;
color: var(--text-primary) !important;
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
/* Chat markdown typography (replaces @tailwindcss/typography prose classes) */
.chat-markdown p {
margin-top: 0.5em;
margin-bottom: 0.5em;
}
.chat-markdown p:first-child {
margin-top: 0;
}
.chat-markdown p:last-child {
margin-bottom: 0;
}
.chat-markdown h1,
.chat-markdown h2,
.chat-markdown h3,
.chat-markdown h4 {
font-weight: 600;
margin-top: 1em;
margin-bottom: 0.4em;
line-height: 1.3;
}
.chat-markdown h1 {
font-size: 1.25em;
}
.chat-markdown h2 {
font-size: 1.125em;
}
.chat-markdown h3 {
font-size: 1em;
}
.chat-markdown ul,
.chat-markdown ol {
padding-left: 1.5em;
margin-top: 0.4em;
margin-bottom: 0.4em;
}
.chat-markdown ul {
list-style-type: disc;
}
.chat-markdown ol {
list-style-type: decimal;
}
.chat-markdown li {
margin-top: 0.15em;
margin-bottom: 0.15em;
}
.chat-markdown li > p {
margin-top: 0.2em;
margin-bottom: 0.2em;
}
.chat-markdown hr {
border-color: var(--border);
margin-top: 1em;
margin-bottom: 1em;
}
.chat-markdown strong {
font-weight: 600;
}
.chat-markdown table {
width: 100%;
border-collapse: collapse;
margin-top: 0.5em;
margin-bottom: 0.5em;
}
.chat-markdown th,
.chat-markdown td {
border: 1px solid var(--border);
padding: 0.3em 0.6em;
text-align: left;
}
.chat-markdown th {
font-weight: 600;
background: var(--surface);
}

242
packages/web/src/lib/api.ts Normal file
View file

@ -0,0 +1,242 @@
/**
* API client functions for the Archon Web UI.
* Uses relative URLs - Vite proxy handles routing in dev.
* SSE streams bypass the proxy in dev mode (Vite proxy buffers SSE responses).
*/
/**
* Base URL for SSE streams. In dev, bypasses Vite proxy by connecting directly
* to the backend server. In production, uses relative URLs (same origin).
* Uses the page hostname so it works from any network interface.
*/
export const SSE_BASE_URL = import.meta.env.DEV ? `http://${window.location.hostname}:3090` : '';
export interface ConversationResponse {
id: string;
platform_type: string;
platform_conversation_id: string;
codebase_id: string | null;
cwd: string | null;
ai_assistant_type: string;
title: string | null;
last_activity_at: string | null;
created_at: string;
updated_at: string;
}
export interface CodebaseResponse {
id: string;
name: string;
repository_url: string | null;
default_cwd: string;
ai_assistant_type: string;
commands: Record<string, { path: string; description: string }>;
created_at: string;
updated_at: string;
}
export interface HealthResponse {
status: string;
adapter: string;
concurrency: {
active: number;
queuedTotal: number;
maxConcurrent: number;
};
}
async function fetchJSON<T>(url: string, options?: RequestInit): Promise<T> {
const res = await fetch(url, options);
if (!res.ok) {
const body = await res.text();
const truncated = body.length > 200 ? body.slice(0, 200) + '...' : body;
const path = new URL(url, window.location.origin).pathname;
const error = new Error(`API error ${String(res.status)} (${path}): ${truncated}`);
(error as Error & { status: number }).status = res.status;
throw error;
}
return res.json() as Promise<T>;
}
// Conversations
export async function listConversations(codebaseId?: string): Promise<ConversationResponse[]> {
const params = new URLSearchParams();
if (codebaseId) params.set('codebaseId', codebaseId);
const qs = params.toString();
return fetchJSON<ConversationResponse[]>(`/api/conversations${qs ? `?${qs}` : ''}`);
}
export async function createConversation(
codebaseId?: string
): Promise<{ conversationId: string; id: string }> {
return fetchJSON('/api/conversations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ codebaseId }),
});
}
export async function updateConversation(
id: string,
updates: { title?: string }
): Promise<{ success: boolean }> {
return fetchJSON(`/api/conversations/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
});
}
export async function deleteConversation(id: string): Promise<{ success: boolean }> {
return fetchJSON(`/api/conversations/${id}`, { method: 'DELETE' });
}
export async function sendMessage(
conversationId: string,
message: string
): Promise<{ accepted: boolean; status: string }> {
return fetchJSON(`/api/conversations/${conversationId}/message`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message }),
});
}
// Messages
export interface MessageResponse {
id: string;
conversation_id: string;
role: 'user' | 'assistant';
content: string;
metadata: string;
created_at: string;
}
export async function getMessages(conversationId: string, limit = 200): Promise<MessageResponse[]> {
return fetchJSON<MessageResponse[]>(
`/api/conversations/${encodeURIComponent(conversationId)}/messages?limit=${String(limit)}`
);
}
// Codebases
export async function listCodebases(): Promise<CodebaseResponse[]> {
return fetchJSON<CodebaseResponse[]>('/api/codebases');
}
export async function getCodebase(id: string): Promise<CodebaseResponse> {
return fetchJSON<CodebaseResponse>(`/api/codebases/${id}`);
}
export async function addCodebase(
input: { url: string } | { path: string }
): Promise<CodebaseResponse> {
return fetchJSON<CodebaseResponse>('/api/codebases', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
}
export async function deleteCodebase(id: string): Promise<{ success: boolean }> {
return fetchJSON<{ success: boolean }>(`/api/codebases/${id}`, { method: 'DELETE' });
}
// Workflows
export interface WorkflowDefinitionResponse {
name: string;
description?: string;
steps: unknown[];
}
export interface WorkflowRunResponse {
id: string;
workflow_name: string;
conversation_id: string;
codebase_id: string | null;
current_step_index: number;
status: 'pending' | 'running' | 'completed' | 'failed';
user_message: string;
metadata: Record<string, unknown>;
started_at: string;
completed_at: string | null;
last_activity_at: string | null;
worker_platform_id?: string;
parent_platform_id?: string;
}
export interface WorkflowEventResponse {
id: string;
workflow_run_id: string;
event_type: string;
step_index: number | null;
step_name: string | null;
data: Record<string, unknown>;
created_at: string;
}
export async function listWorkflows(cwd?: string): Promise<WorkflowDefinitionResponse[]> {
const params = cwd ? `?cwd=${encodeURIComponent(cwd)}` : '';
const result = await fetchJSON<{ workflows: WorkflowDefinitionResponse[] }>(
`/api/workflows${params}`
);
return result.workflows;
}
export async function runWorkflow(
name: string,
conversationId: string,
message: string
): Promise<{ accepted: boolean; status: string }> {
return fetchJSON(`/api/workflows/${encodeURIComponent(name)}/run`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ conversationId, message }),
});
}
export async function listWorkflowRuns(options?: {
conversationId?: string;
status?: string;
limit?: number;
codebaseId?: string;
}): Promise<WorkflowRunResponse[]> {
const params = new URLSearchParams();
if (options?.conversationId) params.set('conversationId', options.conversationId);
if (options?.status) params.set('status', options.status);
if (options?.limit) params.set('limit', String(options.limit));
if (options?.codebaseId) params.set('codebaseId', options.codebaseId);
const qs = params.toString();
const result = await fetchJSON<{ runs: WorkflowRunResponse[] }>(
`/api/workflows/runs${qs ? `?${qs}` : ''}`
);
return result.runs;
}
export async function getWorkflowRun(
runId: string
): Promise<{ run: WorkflowRunResponse; events: WorkflowEventResponse[] }> {
return fetchJSON(`/api/workflows/runs/${encodeURIComponent(runId)}`);
}
export async function getWorkflowRunByWorker(
workerPlatformId: string
): Promise<{ run: WorkflowRunResponse } | null> {
try {
return await fetchJSON(`/api/workflows/runs/by-worker/${encodeURIComponent(workerPlatformId)}`);
} catch (e: unknown) {
// 404 means no run exists yet — expected during dispatch
if ((e as Error & { status?: number }).status === 404) {
return null;
}
throw e;
}
}
export async function getConfig(): Promise<{ config: Record<string, unknown>; database: string }> {
return fetchJSON('/api/config');
}
// System
export async function getHealth(): Promise<HealthResponse> {
return fetchJSON<HealthResponse>('/api/health');
}

View file

@ -0,0 +1,11 @@
import type { ChatMessage } from '@/lib/types';
const messageCache = new Map<string, ChatMessage[]>();
export function getCachedMessages(id: string): ChatMessage[] {
return messageCache.get(id) ?? [];
}
export function setCachedMessages(id: string, msgs: ChatMessage[]): void {
messageCache.set(id, msgs);
}

View file

@ -0,0 +1,234 @@
/**
* Frontend-specific types for the Archon Web UI.
* SSE event types match what the Web adapter emits.
*/
import type {
WorkflowRunStatus,
WorkflowStepStatus,
ArtifactType,
} from '@archon/core/workflows/types';
export type { WorkflowRunStatus, WorkflowStepStatus, ArtifactType };
// Base SSE event
interface BaseSSEEvent {
type: string;
timestamp: number;
}
// Text streaming
export interface TextEvent extends BaseSSEEvent {
type: 'text';
content: string;
isComplete: boolean;
}
// Tool call started
export interface ToolCallEvent extends BaseSSEEvent {
type: 'tool_call';
name: string;
input: Record<string, unknown>;
}
// Tool call completed
export interface ToolResultEvent extends BaseSSEEvent {
type: 'tool_result';
name: string;
output: string;
duration: number;
}
// Session metadata
export interface SessionInfoEvent extends BaseSSEEvent {
type: 'session_info';
sessionId: string;
cost?: number;
tokensIn?: number;
tokensOut?: number;
}
// Conversation lock status
export interface ConversationLockEvent extends BaseSSEEvent {
type: 'conversation_lock';
conversationId: string;
locked: boolean;
queuePosition?: number;
}
// Error with classification
export interface ErrorEvent extends BaseSSEEvent {
type: 'error';
message: string;
classification?: 'transient' | 'fatal';
suggestedActions?: string[];
}
// Warning (non-fatal, informational)
export interface WarningEvent extends BaseSSEEvent {
type: 'warning';
message: string;
}
// Keep-alive
export interface HeartbeatEvent extends BaseSSEEvent {
type: 'heartbeat';
}
// Workflow step update
export interface WorkflowStepEvent extends BaseSSEEvent {
type: 'workflow_step';
runId: string;
step: number;
total: number;
name: string;
status: WorkflowStepStatus;
duration?: number;
iteration?: number;
}
/** SSE events only carry active run statuses 'pending' is excluded because
* the server never emits a status event for a run that hasn't started yet. */
export type ActiveWorkflowRunStatus = Exclude<WorkflowRunStatus, 'pending'>;
// Workflow run status
export interface WorkflowStatusEvent extends BaseSSEEvent {
type: 'workflow_status';
runId: string;
workflowName: string;
status: ActiveWorkflowRunStatus;
error?: string;
}
// Parallel agent status
export interface ParallelAgentEvent extends BaseSSEEvent {
type: 'parallel_agent';
runId: string;
step: number;
agentIndex: number;
totalAgents: number;
name: string;
status: WorkflowStepStatus;
duration?: number;
error?: string;
}
// Workflow artifact
export interface WorkflowArtifactEvent extends BaseSSEEvent {
type: 'workflow_artifact';
runId: string;
artifactType: ArtifactType;
label: string;
url?: string;
path?: string;
}
// Background workflow dispatch
export interface WorkflowDispatchEvent extends BaseSSEEvent {
type: 'workflow_dispatch';
workerConversationId: string;
workflowName: string;
}
// Background workflow output preview
export interface WorkflowOutputPreviewEvent extends BaseSSEEvent {
type: 'workflow_output_preview';
runId: string;
lines: string[];
}
/**
* Discriminated union of all SSE event types emitted by the Web adapter.
* Parsed from JSON with no runtime validation the server is trusted.
*/
export type SSEEvent =
| TextEvent
| ToolCallEvent
| ToolResultEvent
| SessionInfoEvent
| ConversationLockEvent
| ErrorEvent
| WarningEvent
| HeartbeatEvent
| WorkflowStepEvent
| WorkflowStatusEvent
| ParallelAgentEvent
| WorkflowArtifactEvent
| WorkflowDispatchEvent
| WorkflowOutputPreviewEvent;
// UI State types
/**
* UI state for a single chat message. Mixes display state (isStreaming, isExpanded)
* with persisted data (content, toolCalls). When loading from the API, display
* fields default to their inactive states.
*/
export interface ChatMessage {
id: string;
role: 'user' | 'assistant';
content: string;
toolCalls?: ToolCallDisplay[];
error?: ErrorDisplay;
timestamp: number;
isStreaming?: boolean;
workflowDispatch?: {
workerConversationId: string;
workflowName: string;
};
}
export interface ToolCallDisplay {
id: string;
name: string;
input: Record<string, unknown>;
output?: string;
duration?: number;
startedAt: number;
isExpanded: boolean;
}
export interface ErrorDisplay {
message: string;
classification: 'transient' | 'fatal';
suggestedActions: string[];
}
// Workflow UI State types
export interface WorkflowStepState {
index: number;
name: string;
status: WorkflowStepStatus;
duration?: number;
agents?: ParallelAgentState[];
}
export interface ParallelAgentState {
index: number;
name: string;
status: WorkflowStepStatus;
duration?: number;
error?: string;
}
export interface WorkflowArtifact {
type: ArtifactType;
label: string;
url?: string;
path?: string;
}
export interface WorkflowState {
runId: string;
workflowName: string;
status: WorkflowRunStatus;
steps: WorkflowStepState[];
artifacts: WorkflowArtifact[];
isLoop: boolean;
currentIteration?: number;
maxIterations?: number;
startedAt: number;
completedAt?: number;
error?: string;
stale?: boolean;
}

View file

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

15
packages/web/src/main.tsx Normal file
View file

@ -0,0 +1,15 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App';
import './index.css';
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error('Root element not found');
}
createRoot(rootElement).render(
<StrictMode>
<App />
</StrictMode>
);

View file

@ -0,0 +1,9 @@
import { useParams } from 'react-router';
import { ChatInterface } from '@/components/chat/ChatInterface';
export function ChatPage(): React.ReactElement {
const { '*': rawConversationId } = useParams();
const conversationId = rawConversationId ? decodeURIComponent(rawConversationId) : undefined;
return <ChatInterface key={conversationId ?? 'new'} conversationId={conversationId ?? 'new'} />;
}

View file

@ -0,0 +1,168 @@
import { Link, useNavigate } from 'react-router';
import { useQuery } from '@tanstack/react-query';
import { MessageSquare, PlayCircle, Plus } from 'lucide-react';
import {
listConversations,
listWorkflowRuns,
createConversation,
type ConversationResponse,
type WorkflowRunResponse,
} from '@/lib/api';
const PROJECT_STORAGE_KEY = 'archon-selected-project';
function formatTime(dateStr: string | null): string {
if (!dateStr) return '';
const d = new Date(dateStr.endsWith('Z') ? dateStr : dateStr + 'Z');
return d.toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
const STATUS_COLORS: Record<string, string> = {
running: 'bg-primary',
completed: 'bg-success',
failed: 'bg-destructive',
pending: 'bg-text-tertiary',
};
function ConversationRow({ conv }: { conv: ConversationResponse }): React.ReactElement {
const title = conv.title ?? 'Untitled conversation';
return (
<Link
to={`/chat/${encodeURIComponent(conv.platform_conversation_id)}`}
className="flex items-center gap-3 rounded-md px-3 py-2 hover:bg-surface-elevated transition-colors"
>
<MessageSquare className="h-4 w-4 shrink-0 text-text-tertiary" />
<span className="truncate text-sm text-text-primary">{title}</span>
<span className="ml-auto shrink-0 text-xs text-text-tertiary">
{formatTime(conv.last_activity_at)}
</span>
</Link>
);
}
function WorkflowRunRow({ run }: { run: WorkflowRunResponse }): React.ReactElement {
return (
<Link
to={`/workflows/runs/${encodeURIComponent(run.id)}`}
className="flex items-center gap-3 rounded-md px-3 py-2 hover:bg-surface-elevated transition-colors"
>
<div
className={`h-2 w-2 shrink-0 rounded-full ${STATUS_COLORS[run.status] ?? 'bg-text-tertiary'}`}
/>
<span className="truncate text-sm text-text-primary">{run.workflow_name}</span>
<span className="ml-auto shrink-0 text-xs text-text-tertiary">
{formatTime(run.started_at)}
</span>
</Link>
);
}
export function DashboardPage(): React.ReactElement {
const navigate = useNavigate();
const savedProjectId = localStorage.getItem(PROJECT_STORAGE_KEY);
const { data: conversations, isLoading: loadingConvs } = useQuery({
queryKey: ['conversations', { codebaseId: savedProjectId }],
queryFn: () => listConversations(savedProjectId ?? undefined),
});
const { data: workflowRuns, isLoading: loadingRuns } = useQuery({
queryKey: ['workflowRuns', { codebaseId: savedProjectId }],
queryFn: () => listWorkflowRuns({ codebaseId: savedProjectId ?? undefined, limit: 10 }),
});
const recentConversations = (conversations ?? []).slice(0, 10);
const recentRuns = (workflowRuns ?? []).slice(0, 10);
const isLoading = loadingConvs || loadingRuns;
const handleNewChat = async (): Promise<void> => {
if (!savedProjectId) return;
try {
const { conversationId } = await createConversation(savedProjectId);
navigate(`/chat/${conversationId}`);
} catch (error) {
console.error('[Dashboard] Failed to create conversation', { error });
}
};
return (
<div className="flex flex-1 flex-col">
<div className="flex-1 overflow-auto p-6">
{isLoading ? (
<div className="flex items-center justify-center py-12">
<span className="text-sm text-text-tertiary">Loading...</span>
</div>
) : recentConversations.length === 0 && recentRuns.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-4 py-16">
<MessageSquare className="h-10 w-10 text-text-tertiary" />
<p className="text-sm text-text-tertiary">No conversations yet</p>
<button
onClick={handleNewChat}
disabled={!savedProjectId}
className="flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Plus className="h-4 w-4" />
New Chat
</button>
</div>
) : (
<div className="grid gap-6 lg:grid-cols-2">
{/* Recent Conversations */}
<section>
<div className="mb-2 flex items-center justify-between">
<h2 className="text-sm font-semibold text-text-secondary">Recent Conversations</h2>
<button
onClick={handleNewChat}
disabled={!savedProjectId}
className="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-text-secondary hover:bg-surface-elevated hover:text-text-primary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Plus className="h-3.5 w-3.5" />
New
</button>
</div>
<div className="rounded-lg border border-border bg-surface">
{recentConversations.length > 0 ? (
<div className="divide-y divide-border">
{recentConversations.map(conv => (
<ConversationRow key={conv.id} conv={conv} />
))}
</div>
) : (
<div className="flex items-center justify-center py-8">
<span className="text-xs text-text-tertiary">No conversations</span>
</div>
)}
</div>
</section>
{/* Recent Workflow Runs */}
<section>
<div className="mb-2 flex items-center">
<h2 className="text-sm font-semibold text-text-secondary">Recent Workflow Runs</h2>
</div>
<div className="rounded-lg border border-border bg-surface">
{recentRuns.length > 0 ? (
<div className="divide-y divide-border">
{recentRuns.map(run => (
<WorkflowRunRow key={run.id} run={run} />
))}
</div>
) : (
<div className="flex items-center gap-2 justify-center py-8">
<PlayCircle className="h-4 w-4 text-text-tertiary" />
<span className="text-xs text-text-tertiary">No workflow runs</span>
</div>
)}
</div>
</section>
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,98 @@
import { useQuery } from '@tanstack/react-query';
import { Header } from '@/components/layout/Header';
import { getConfig, getHealth } from '@/lib/api';
export function SettingsPage(): React.ReactElement {
const { data, isLoading, error } = useQuery({
queryKey: ['config'],
queryFn: getConfig,
});
const {
data: health,
isLoading: healthLoading,
error: healthError,
} = useQuery({
queryKey: ['health'],
queryFn: getHealth,
});
return (
<div className="flex flex-1 flex-col overflow-hidden">
<Header title="Settings" />
<div className="flex-1 overflow-auto p-4">
<div className="space-y-4 max-w-2xl">
<section>
<h3 className="text-sm font-semibold text-text-primary mb-2">System Health</h3>
{healthLoading && <div className="text-sm text-text-secondary">Checking health...</div>}
{healthError && (
<div className="text-sm text-error">
Failed to check health:{' '}
{healthError instanceof Error ? healthError.message : 'Unknown error'}. Check that
the server is running.
</div>
)}
{health && (
<div className="rounded-lg border border-border bg-surface p-3">
<div className="grid grid-cols-2 gap-2 text-sm">
<div>
<span className="text-text-secondary">Status: </span>
<span className="text-text-primary font-medium">{health.status}</span>
</div>
<div>
<span className="text-text-secondary">Adapter: </span>
<span className="text-text-primary font-medium">{health.adapter}</span>
</div>
<div>
<span className="text-text-secondary">Active: </span>
<span className="text-text-primary font-medium">
{health.concurrency.active}
</span>
</div>
<div>
<span className="text-text-secondary">Queued: </span>
<span className="text-text-primary font-medium">
{health.concurrency.queuedTotal}
</span>
</div>
<div>
<span className="text-text-secondary">Max Concurrent: </span>
<span className="text-text-primary font-medium">
{health.concurrency.maxConcurrent}
</span>
</div>
</div>
</div>
)}
</section>
{isLoading && <div className="text-sm text-text-secondary">Loading configuration...</div>}
{error && (
<div className="text-sm text-error">
Failed to load configuration:{' '}
{error instanceof Error ? error.message : 'Unknown error'}. Try refreshing the page.
</div>
)}
{data && (
<>
<section>
<h3 className="text-sm font-semibold text-text-primary mb-2">Database</h3>
<div className="rounded-lg border border-border bg-surface p-3">
<span className="text-sm text-text-secondary">Type: </span>
<span className="text-sm text-text-primary font-medium">{data.database}</span>
</div>
</section>
<section>
<h3 className="text-sm font-semibold text-text-primary mb-2">Configuration</h3>
<pre className="rounded-lg border border-border bg-surface-inset p-3 text-xs text-text-secondary overflow-auto max-h-96 font-mono">
{JSON.stringify(data.config, null, 2)}
</pre>
</section>
</>
)}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,22 @@
import { Hammer } from 'lucide-react';
export function WorkflowBuilderPage(): React.ReactElement {
return (
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex flex-1 flex-col items-center justify-center gap-4">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-surface-elevated">
<Hammer className="h-8 w-8 text-text-tertiary" />
</div>
<div className="flex flex-col items-center gap-2">
<h2 className="text-lg font-semibold text-text-primary">Workflow Builder</h2>
<span className="rounded-full bg-accent-muted px-3 py-1 text-xs font-medium text-primary">
Coming Soon
</span>
<p className="max-w-sm text-center text-sm text-text-tertiary">
Design and compose multi-step AI workflows visually.
</p>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,16 @@
import { useParams } from 'react-router';
import { WorkflowExecution } from '@/components/workflows/WorkflowExecution';
export function WorkflowExecutionPage(): React.ReactElement {
const { runId } = useParams<{ runId: string }>();
if (!runId) {
return (
<div className="flex items-center justify-center h-full text-text-secondary">
<p>No workflow run ID specified.</p>
</div>
);
}
return <WorkflowExecution key={runId} runId={runId} />;
}

View file

@ -0,0 +1,11 @@
import { WorkflowList } from '@/components/workflows/WorkflowList';
export function WorkflowsPage(): React.ReactElement {
return (
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex-1 overflow-auto p-4">
<WorkflowList />
</div>
</div>
);
}

View file

@ -0,0 +1,18 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"noEmit": true,
"jsx": "react-jsx",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"skipLibCheck": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@archon/core": ["../core/src"],
"@archon/core/*": ["../core/src/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View file

@ -0,0 +1,26 @@
import path from 'path';
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3090',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
sourcemap: true,
},
});