diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..eed07a4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,35 @@ +name: Bug report +description: Something isn't working as expected +labels: [bug] +body: + - type: textarea + id: description + attributes: + label: What happened? + description: A clear description of the bug. + validations: + required: true + + - type: textarea + id: reproduction + attributes: + label: Steps to reproduce + placeholder: | + 1. Start opencode + 2. Open Agentree + 3. ... + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected behavior + validations: + required: true + + - type: input + id: version + attributes: + label: opencode version + placeholder: e.g. 1.3.13 diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..6c6853f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,23 @@ +name: Feature request +description: Suggest an improvement or new capability +labels: [enhancement] +body: + - type: textarea + id: problem + attributes: + label: Problem or motivation + description: What are you trying to do that you can't do today? + validations: + required: true + + - type: textarea + id: solution + attributes: + label: Proposed solution + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives considered diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..2ab4112 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,16 @@ +Closes # + +## What + + + +## Why + + + +## Test plan + + +- [ ] `pnpm test` passes +- [ ] `pnpm exec tsc --noEmit` passes +- [ ] Manually verified in browser diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a975bf7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: ci + +on: + pull_request: + branches: [main] + +jobs: + ci: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 9 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - name: Install dependencies + run: pnpm install + + - name: Type check (server) + run: pnpm exec tsc --noEmit + + - name: Type check (client) + run: pnpm exec tsc --noEmit -p tsconfig.client.json + + - name: Test + run: pnpm test diff --git a/README.md b/README.md index 6d58c81..edb385c 100644 --- a/README.md +++ b/README.md @@ -1,74 +1,307 @@ # Agentree -> Figma-like infinite canvas for visualizing and controlling AI agent trees in real-time. +A Figma-like infinite canvas for supervising live [opencode](https://opencode.ai) agent session trees in real time. -Agentree lets you see your agent processes the way Figma lets you see design objects — on an infinite canvas, live, with full control. +Agentree sits on top of opencode and adds a visual layer: see all your running sessions at a glance, inspect message threads, approve permissions, send prompts, and trace fork/subtask lineage — without leaving a single browser tab. -## What it does +![Agentree demo](assets/demo.gif) -- **Infinite canvas** — zoom in/out, drag, explore your entire agent process tree -- **Live tree** — subprocess and thread nodes appear as they spawn, connected by animated edges -- **Real-time control** — click any node, chat with that agent directly from the panel -- **Approval flows** — when an agent needs permission or asks a question, the edge lights up and you approve/deny inline -- **Event-driven** — no polling, everything reacts to opencode SSE events +--- ## How it works -Agentree sits on top of [opencode](https://opencode.ai) and uses its session tree as the source of truth. - ``` -opencode session (root process) - └─ /session/{id}/children → subprocess nodes - └─ forked sessions → thread nodes +opencode (Docker) Agentree server (Node/Hono) Agentree client (React) + | | | + sessions, <-- proxy --> | -- SSE stream --> canvas, panel, approvals + messages, | + events SQLite overlay + (node positions, + project metadata, + session relations) ``` -Each node on the canvas = one opencode session. The canvas just visualizes what opencode already knows, plus stores node positions locally in SQLite. +- **opencode** is the source of truth for sessions, messages, and execution state. +- **Agentree server** proxies opencode, rebroadcasts its SSE event stream, and persists canvas layout in a local SQLite database. +- **Agentree client** renders the canvas and panel, consuming SSE for live updates. -## Stack +--- -- **Canvas** — React + React Flow (infinite canvas, custom nodes/edges) -- **Layout** — dagre `rankdir: BT` (roots at bottom, branches grow upward) -- **Backend** — Hono + opencode SDK -- **DB** — SQLite (canvas layout only — opencode owns session state) -- **Real-time** — opencode `GET /global/event` SSE +## Prerequisites -## Status +| Tool | Version | +|------|---------| +| Node.js | 20+ | +| pnpm | 9+ | +| Docker | any recent | -🌱 Early development. PRD in [`docs/PRD.md`](docs/PRD.md). +--- -OpenCode integration notes and supervision source map live in [`docs/OPENCODE_INTEGRATION.md`](docs/OPENCODE_INTEGRATION.md). +## Setup -## Run opencode with Docker +### 1. Install dependencies -Agentree expects a running `opencode serve` instance at `http://localhost:6543`. +```bash +pnpm install +``` -1. Copy the example env file. -2. Start the container. -3. Run Agentree locally with `pnpm run dev`. +### 2. Configure opencode ```bash -cd agentree cp .env.opencode.example .env.opencode -# edit .env.opencode — set OPENCODE_SERVER_PASSWORD to a strong secret -docker compose --env-file .env.opencode -f docker-compose.opencode.yml up -d --build -export OPENCODE_SERVER_USERNAME=opencode -export OPENCODE_SERVER_PASSWORD= -pnpm run dev ``` -Useful checks: +Edit `.env.opencode` as needed: + +| Variable | Default | Description | +|----------|---------|-------------| +| `OPENCODE_IMAGE` | `ghcr.io/anomalyco/opencode:latest` | Docker image for opencode | +| `OPENCODE_PORT` | `6543` | Port opencode listens on | +| `OPENCODE_WORKSPACE_DIR` | `..` | Host path mounted as `/workspace` inside the container | +| `OPENCODE_CONFIG_HOST_DIR` | `~/.config/opencode` | opencode config persistence | +| `OPENCODE_DATA_HOST_DIR` | `~/.local/share/opencode` | opencode session data persistence | +| `OPENCODE_SERVER_USERNAME` | `opencode` | HTTP basic auth username (optional) | +| `OPENCODE_SERVER_PASSWORD` | _(empty)_ | HTTP basic auth password — set this to secure access | +| `TZ` | `Asia/Seoul` | Timezone inside the container | + +> **Tip:** `OPENCODE_WORKSPACE_DIR=..` mounts the parent of the `agentree` directory as the workspace. If your repo root is elsewhere, set this to an absolute path. + +### 3. Start opencode + +```bash +docker compose --env-file .env.opencode -f docker-compose.opencode.yml up -d +``` + +Check it is healthy: ```bash +docker compose --env-file .env.opencode -f docker-compose.opencode.yml ps +# or curl -u opencode: http://localhost:6543/global/health -docker compose --env-file .env.opencode -f docker-compose.opencode.yml logs -f opencode ``` -Notes: +### 4. Run database migrations + +```bash +pnpm run db:migrate +``` + +### 5. Start Agentree + +```bash +pnpm run dev +``` + +| Service | URL | +|---------|-----| +| Agentree UI | http://localhost:5174 | +| Agentree API | http://localhost:3001 | +| opencode | http://localhost:6543 | + +--- + +## UI walkthrough + +### Home screen + +When you open the app you land on the **Home screen** — a grid of project cards. + +- Projects are **auto-created** from the working directory of each session (e.g. sessions running inside `apps/foo` all map to one project card). +- Project names are **editable inline** — click the name to rename. +- Click a project card to open its canvas. +- **View All** opens a canvas with every session regardless of project. + +### Canvas + +The canvas shows sessions as nodes connected by edges. + +#### Node status colors + +| Color | Meaning | +|-------|---------| +| Green | Session is actively running | +| Yellow | Waiting for a permission grant | +| Orange | Waiting for an answer to a question | +| Blue | Idle (waiting for a prompt) | +| Gray | Completed / done | +| Red | Failed or errored | + +#### Interactions + +| Action | How | +|--------|-----| +| Select a session | Click its node | +| Pan | Click and drag the canvas background | +| Zoom | Scroll wheel | +| Reposition a node | Drag the node — position is saved automatically | +| Create a relation | Draw an edge from one node's handle to another, then choose `linked` or `detached` | +| New session | Click **+ New Session** in the top toolbar | + +#### Toolbar + +- **+ New Session** — opens a dialog to create a new opencode session with an optional title. +- **View: Recent / All** — toggle between the 8 most-recently-active root sessions and every session. +- **Compat info** — shows opencode SDK/server version compatibility status. A warning here means some features may not work. +- **← Projects** — return to the Home screen. + +### Session panel + +Click any node to open the **Session panel** on the right. + +#### What you can see + +- Session ID, parent session, working directory, timestamps. +- Full message thread with rendered parts: + - **Text** — assistant prose. + - **Reasoning** — model chain-of-thought (if available). + - **Tool** — tool calls with input/output, running / completed / error states. + - **Patch** — unified diffs of file changes. + - **Subtask** — linked child session spawned by this session. + - **File** — file references. +- Latest provider/model/cost metadata. + +#### Approval queue + +When a session is paused waiting for approval, the panel shows an inline queue: + +- **Permission requests** — choose *Once*, *Always*, or *Reject*. +- **Questions** — select from offered answers or reject. + +The floating **ApprovalQueue** badge at the bottom of the canvas also shows pending counts across all sessions. Click it to jump to the oldest unanswered request. + +#### Actions + +- **Send prompt** — type and submit a message to the session. +- **Create subtask** — spin up a child session with a prompt, description, agent, and model. +- **Abort** — cancel the current running operation. + +--- + +## Project structure + +``` +src/ + client/ + HomeScreen.tsx # Project cards grid + App.tsx # Root: HomeScreen ↔ Canvas routing + canvas/ + AgentCanvas.tsx # React Flow canvas + toolbar + AgentNode.tsx # Session node renderer + AgentEdge.tsx # Custom edge renderer + ProjectTabBar.tsx # Per-project tab strip + SessionListSidebar.tsx # Collapsible session list + panel/ + SessionPanel.tsx # Right-side detail + message thread + ApprovalQueue.tsx # Floating approval badge + SubtaskDialog.tsx # Create subtask modal + store/ + agentStore.ts # Zustand store + SSE event handling + + server/ + index.ts # Hono server entry point + db/ + schema.ts # Drizzle ORM table definitions + index.ts # DB instance + helpers + routes/ + session.ts # /api/session/* — CRUD + prompt/subtask + canvas.ts # /api/canvas/* — node position persistence + tree.ts # /api/tree — full graph snapshot + project.ts # /api/project/* — project management + system.ts # /api/health + opencode/ + types.ts # Normalized AgentreeSession / Message types + compat-1.3.ts # Adapter for @opencode-ai/sdk 1.3.x + sse/ + broadcaster.ts # SSE fan-out to all connected clients +``` + +--- + +## Database + +SQLite file is created at `agentree.db` in the project root on first run. + +| Table | Purpose | +|-------|---------| +| `project` | One row per project; stores name and directory key | +| `canvas_node` | Per-session canvas position, pin state, detach state | +| `session_fork` | Fork lineage (auto-detected from opencode) | +| `session_relation` | User-drawn edges between sessions (linked / detached) | +| `task_invocation` | Subtask → child session linkage | + +To regenerate migrations after schema changes: + +```bash +pnpm run db:generate +pnpm run db:migrate +``` + +--- + +## Development + +```bash +# Run all unit tests +pnpm test + +# Run opencode integration tests (requires live opencode) +pnpm run test:opencode + +# Build for production +pnpm run build + +# Preview production build +pnpm run preview +``` + +--- + +## API reference + +The Agentree server exposes a REST API at `http://localhost:3001/api`. + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/api/health` | Health check + opencode connection status | +| `GET` | `/api/tree` | Full session graph (nodes, edges, status, relations) | +| `GET` | `/api/events` | SSE stream for real-time canvas updates | +| `POST` | `/api/session` | Create a new session | +| `GET` | `/api/session/:id` | Get session details | +| `GET` | `/api/session/:id/messages` | Fetch message thread | +| `GET` | `/api/session/:id/diff` | File diffs | +| `GET` | `/api/session/:id/tasks` | Task invocations | +| `POST` | `/api/session/:id/prompt` | Send a prompt | +| `POST` | `/api/session/:id/subtask` | Create a subtask session | +| `PATCH` | `/api/canvas/:id` | Update node position / pin state | +| `POST` | `/api/permission/:requestID/reply` | Approve or reject a permission | +| `POST` | `/api/question/:requestID/reply` | Answer a question | +| `GET` | `/api/project` | List projects | +| `PATCH` | `/api/project/:id` | Rename a project | +| `DELETE` | `/api/project/:id` | Delete a project | + +--- + +## Troubleshooting + +**Canvas is empty / no sessions appear** + +1. Confirm opencode is running: `docker compose --env-file .env.opencode -f docker-compose.opencode.yml ps` +2. Check `http://localhost:3001/api/health` — should return `{ "opencode": "ok" }`. +3. Check the browser console and server logs for SSE connection errors. + +**Session stuck on yellow / orange** + +The session is waiting for a permission grant or question answer. Open the session panel and respond in the Approval queue, or click the floating ApprovalQueue badge. + +**Node positions reset on restart** + +Positions are only persisted once you drag a node. Nodes you have never dragged are laid out by dagre on each page load. + +**opencode version mismatch warning** + +Agentree is tested against `@opencode-ai/sdk 1.3.x`. If the compat banner shows warnings, some features (e.g. certain event types) may not work as expected. Pin `OPENCODE_IMAGE` to a known-good tag in `.env.opencode`. -- The compose file uses the official image `ghcr.io/anomalyco/opencode:latest` and runs `opencode serve --hostname 0.0.0.0 --port 6543`. -- Host `~/.config/opencode` and `~/.local/share/opencode` are mounted into the container so auth, config, and session history persist. -- `OPENCODE_WORKSPACE_DIR` defaults to `..`, so the repo root is mounted at `/workspace`. -- If you expose the port beyond localhost, set `OPENCODE_SERVER_PASSWORD` in `.env.opencode` so the server is not left unsecured. +--- ## License diff --git a/assets/demo.gif b/assets/demo.gif new file mode 100644 index 0000000..4a12332 Binary files /dev/null and b/assets/demo.gif differ diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md deleted file mode 100644 index eb48521..0000000 --- a/docs/HANDOFF.md +++ /dev/null @@ -1,236 +0,0 @@ -# Agentree Handoff - -Last updated: 2026-04-06 - ---- - -## Project context - -Agentree is a Figma-like infinite canvas for supervising live `opencode` session trees. - -- **Client** — React + React Flow canvas, Zustand store, side panel controls -- **Server** — Hono API that proxies `opencode`, rebroadcasts SSE, and stores overlay metadata in SQLite - -Core principle: `opencode` is the source of truth for sessions and execution state. Agentree only stores what `opencode` doesn't know — canvas positions, custom labels, and session relations. - ---- - -## Architecture - -``` -apps/agentree/ -├── src/ -│ ├── client/ -│ │ ├── canvas/ # AgentCanvas, AgentNode, AgentEdge, GroupHeaderNode -│ │ ├── panel/ # SessionPanel, ApprovalQueue, SubtaskDialog -│ │ └── store/ # agentStore.ts — Zustand, SSE apply, graph build -│ └── server/ -│ ├── routes/ # tree, session, canvas, approval, relation, system -│ ├── sse/ # broadcaster.ts — opencode SSE → client broadcast -│ ├── opencode/ # SDK adapter + compat layer + normalizer -│ └── db/ # schema.ts, index.ts — SQLite overlay -├── drizzle/ # migrations -└── docs/ -``` - ---- - -## Current state — Phase 1 + Phase 2 complete - -### Canvas - -- Infinite canvas with zoom/pan (React Flow) -- dagre auto-layout (`rankdir: BT` — roots at bottom, branches grow upward) -- Directory-based group headers -- Recent / All view modes -- Node drag → SQLite position persist (`canvas_node.pinned`) -- Auto-layout skips pinned nodes -- Real-time node addition/update via SSE - -### Node states and edge styles - -| State | Color | Trigger | -|-------|-------|---------| -| `running` | green | `session.status` | -| `needs-permission` | yellow | `permission.asked` | -| `needs-answer` | orange | `question.asked` | -| `idle` | blue | `session.idle` | -| `done` | gray | `session.status` done | -| `failed` | red | `session.error` | - -| Relation | Edge color | Dash | -|----------|-----------|------| -| parent-child (default) | `#374151` | solid | -| `fork` | `#14b8a6` teal | `8 4` | -| `linked` | `#818cf8` indigo | `4 2` | -| `merged-view` | `#a78bfa` violet | solid | -| `detached` | `#6b7280` gray | `2 6` | -| `needs-permission` | yellow | animated | -| `needs-answer` | orange | animated | - -### Session panel - -Renders the selected session. Contains: - -- Header: title, session ID, status badge -- Fork source banner + navigation -- `session.diff` hint (if fired) -- Metadata block: model, provider, cwd, total cost, total tokens (derived from AssistantMessage fields — no extra API call) -- Action buttons: Spawn subtask, Fork session -- Inline permission/question approval UI -- Child sessions waiting (permission/question) with inline reply controls -- Todo list — collapsible, from `todo.updated` SSE -- **Relations section** — shows non-fork relations to/from this session; `[+ Link]` form to add `linked`/`merged-view`/`detached` relations; `×` to delete; creating a relation also adds the corresponding edge on the canvas -- Message history — structured part rendering for all 12 SDK part types: - - `text` — monospace pre-wrap - - `reasoning` — italic, muted, `⟳` prefix - - `tool` — pill + state dot (pending/running/completed/error) + collapsible output - - `patch` — file list with `+N`/`-N` - - `subtask` — teal left border, agent name - - `file` — MIME badge; image thumbnail for `image/*` - - `step-finish` — cost/token summary line (right-aligned, 10px) - - `agent` — `↳ agent: {name}`, italic - - `retry` — `⚠ retry #{n}: {error}` in orange - - `compaction`, `step-start`, `snapshot` — filtered (noise) - - unknown types — `[{type}]` fallback, never throws -- Live message refresh — 600ms debounce on `lastActivityBySession` SSE activity -- Prompt input + Send / Abort - -### Approval queue - -`ApprovalQueue.tsx` — floating overlay showing all sessions with pending permission/question across the entire canvas (not just the selected one). - -### SSE event coverage in store (`applyEvent`) - -| Event | Handler | -|-------|---------| -| `session.status` | status → `running` | -| `session.idle` | status → `idle`, clears `lastActivity` | -| `session.error` | status → `failed` | -| `permission.asked/updated` | status → `needs-permission`, stores payload | -| `question.asked/updated` | status → `needs-answer`, stores payload | -| `permission.replied` | clears pending permission | -| `question.replied/rejected` | clears pending question | -| `message.part.delta/updated` | updates `lastActivityBySession` (triggers panel refresh) | -| `session.created/updated` | updates session list, rebuilds graph | -| `session.deleted` | removes session from all store maps | -| `todo.updated` | updates `todosBySession[sessionId]` | -| `session.diff` | updates `diffBySession[sessionId]` | -| `command.executed` | updates `lastActivityBySession` (triggers panel refresh) | - -### DB overlay tables - -| Table | Purpose | -|-------|---------| -| `canvas_node` | node position (x, y), custom label, pinned flag | -| `session_fork` | legacy fork lineage (kept for backward compat) | -| `session_relation` | generalized relation overlay: `fork`/`linked`/`detached`/`merged-view` | - -### API surface - -| Method | Path | Notes | -|--------|------|-------| -| GET | `/api/health` | server liveness | -| GET | `/api/tree` | sessions + status + canvas overlay + relations | -| GET | `/api/events` | SSE broadcast from opencode | -| GET | `/api/session/:id` | session detail | -| GET | `/api/session/:id/messages` | message history (`?limit=N`) | -| POST | `/api/session` | create session | -| POST | `/api/session/:id/prompt` | send prompt | -| POST | `/api/session/:id/abort` | abort | -| POST | `/api/session/:id/fork` | fork — dual-writes `session_fork` + `session_relation` | -| POST | `/api/session/:id/subtask` | create subtask | -| POST | `/api/permission/:id/reply` | approve/deny permission | -| POST | `/api/question/:id/reply` | answer question | -| POST | `/api/question/:id/reject` | reject question | -| PATCH | `/api/canvas/:id` | save node position/label/pinned | -| GET | `/api/canvas/:id` | get canvas state | -| POST | `/api/relation` | create `linked`/`merged-view`/`detached` relation | -| DELETE | `/api/relation/:id` | delete relation | -| GET | `/api/system/compat` | opencode SDK compat report | - ---- - -## Release checklist status - -| Item | Status | -|------|--------| -| `LICENSE` (Apache-2.0) | ✅ added | -| `.gitignore` — `.env.*`, `*.db*`, `node_modules/`, `dist/` | ✅ done | -| `README.md` — Apache-2.0 license line | ✅ fixed | -| README public cleanup — hardcoded `/home/statpan/...` path, default password in example | ✅ done | -| KIPO 상표 출원 (9류 + 42류, 104,000원) | ❌ manual | - ---- - -## Remaining work - -### Must-do before public release - -**KIPO trademark filing** — "Agentree", 9류 + 42류, 출원료 104,000원. Must file before public GitHub push. - -### Phase 3 (post-release) - -| Feature | Notes | -|---------|-------| -| Canvas drag-to-connect | drag from node → another node → pick relation type | -| Multi-operator cursors | Figma-style presence — who is watching which node | -| Node memo / tags | freeform annotations per node | -| Execution history timeline | tool calls, patches, diffs in order | -| npm / Docker packaging | `npm create agentree`, or `docker run` one-liner | - ---- - -## Key file reference - -### Server - -| File | Purpose | -|------|---------| -| `src/server/index.ts` | Hono app, router registration, migration | -| `src/server/routes/tree.ts` | GET /api/tree | -| `src/server/routes/session.ts` | session CRUD + fork + subtask | -| `src/server/routes/relation.ts` | POST/DELETE /api/relation | -| `src/server/routes/canvas.ts` | PATCH/GET /api/canvas/:id | -| `src/server/routes/approval.ts` | permission + question reply | -| `src/server/sse/broadcaster.ts` | opencode SSE → client rebroadcast | -| `src/server/opencode/index.ts` | adapter entry point | -| `src/server/opencode/normalize.ts` | SDK response normalization | -| `src/server/opencode/compat.ts` | version compat detection | -| `src/server/db/schema.ts` | Drizzle schema | -| `src/server/db/index.ts` | DB query functions | - -### Client - -| File | Purpose | -|------|---------| -| `src/client/store/agentStore.ts` | all client state — graph build, SSE apply, relations | -| `src/client/canvas/AgentCanvas.tsx` | canvas root, SSE subscription, tree reload | -| `src/client/canvas/AgentNode.tsx` | node component | -| `src/client/canvas/AgentEdge.tsx` | edge component | -| `src/client/panel/SessionPanel.tsx` | full session side panel | -| `src/client/panel/ApprovalQueue.tsx` | floating approval overlay | -| `src/client/panel/SubtaskDialog.tsx` | subtask creation modal | -| `src/client/App.tsx` | root layout | - ---- - -## Quick verification - -```bash -pnpm run build # must pass with zero TS errors - -pnpm run dev # start dev server (requires opencode running at localhost:6543) - -curl http://localhost:3001/api/health -curl http://localhost:3001/api/tree | jq '.relations' -``` - -Browser checklist: -- nodes load on canvas -- selecting a session shows metadata block (model / cwd / cost) -- tool call parts are collapsible in the panel -- `[+ Link]` in Relations section creates an edge between two sessions with the correct color/dash -- `×` on a relation removes the edge -- permission/question edges animate; inline approval clears them -- dragging a node persists position after reload diff --git a/docs/PRD.md b/docs/PRD.md deleted file mode 100644 index a86552b..0000000 --- a/docs/PRD.md +++ /dev/null @@ -1,293 +0,0 @@ -# Agentree PRD - -> 목적: Agent 실행 트리를 Figma처럼 인피니트 캔버스로 시각화하고, 실시간 채팅으로 제어할 수 있는 풀스택 오픈소스 대시보드 -> 기준일: 2026-04-06 (Phase 1 완료 기준으로 갱신) - ---- - -## 1. 핵심 컨셉 - -Figma가 디자인 오브젝트를 캔버스에서 동시에 다루듯이, Agent를 캔버스에서 다룬다. - -- process → subprocess → thread 계층이 캔버스 위에 폭포처럼 펼쳐짐 -- 줌인/아웃, 드래그로 전체 실행 트리를 탐색 -- 노드 선택 → 실시간 채팅으로 해당 agent에 직접 지시 -- 파생 agent/thread가 생성될 때 캔버스에 실시간으로 노드 추가 - -시장에 없는 이유: agent 실행이 대부분 CLI/로그 기반이라 실행 트리를 시각적으로 탐색하는 툴 자체가 없음. - ---- - -## 2. 핵심 발견 — opencode session 트리가 백엔드 - -opencode SDK를 분석한 결과, 계층 구조가 이미 내장되어 있음: - -- `GET /session` — 전체 세션 목록 -- `GET /session/{id}/children` — 자식 세션 조회 (subprocess/thread에 해당) -- `POST /session/{id}/fork` — 세션 파생 (thread 생성에 해당) -- `GET /global/event` SSE — 전체 이벤트 실시간 스트림 - -따라서 agentproc의 `process → subprocess → thread` 계층은 **opencode session 트리에 직접 매핑**된다. - -``` -opencode session (root) ← process - └─ /session/{id}/children ← subprocess - └─ fork된 session ← thread -``` - -opencode가 native하게 지원하지 않는 관계/메타데이터는 Agentree DB에 오버레이로 저장한다. -향후 opencode가 동일 기능을 지원하면 오버레이를 대체하거나 응용한다. - ---- - -## 3. DB 역할 - -Agentree SQLite는 opencode가 모르는 것을 저장하는 **오버레이 레이어**다. - -### 저장 대상 - -| 테이블 | 내용 | -|--------|------| -| `canvas_node` | 노드 위치 (x, y), 사용자 정의 레이블, pinned 여부 | -| `session_fork` | fork 관계 (하위 호환 유지) | -| `session_relation` | 일반화된 세션 간 관계 오버레이 | - -### session_relation 관계 타입 - -| 타입 | 설명 | -|------|------| -| `fork` | fork된 세션 (session_fork의 일반화) | -| `linked` | 사용자가 수동으로 연결한 관계 | -| `detached` | 연결 해제된 관계 | -| `merged-view` | 병합 뷰로 묶인 관계 | - -세션 상태(status, parent, messages)는 여전히 opencode가 source of truth. - ---- - -## 4. 범위 - -### Phase 1 — 완료 ✓ - -- 인피니트 캔버스 (줌/팬/드래그) -- opencode session 트리 → 캔버스 노드 트리 자동 렌더링 -- 노드 상태 배지 (running / done / failed / needs-permission / needs-answer) -- 노드 선택 → 사이드 패널에서 실시간 채팅으로 agent 제어 -- `GET /global/event` SSE → 노드 상태 실시간 반영 -- 노드 위치 드래그 → SQLite 영속화 (pinned) -- permission / question 승인 UI (인라인 + 플로팅 ApprovalQueue) -- fork 시각화 (FORK 배지, teal 점선 엣지, 포크 소스 표시) -- subtask 생성 UI -- opencode SDK 버전 호환성 어댑터 + 경고 표시 -- 일반화된 session_relation 오버레이 모델 (DB + API + 엣지 스타일 기반) - -### Phase 2 — 진행 예정 - -**Supervision 강화** - -- SessionPanel 확장 - - 세션 메타데이터 블록 (model, provider, agent, cwd, token/cost) - - 구조화된 파트 렌더링: `subtask`, `tool`, `file`, `patch` - - 미사용 SSE 이벤트 처리: `message.part.updated`, `todo.updated`, `session.diff`, `command.executed` -- connect / disconnect / merge 관계 UI (session_relation 기반) - -**캔버스 관계 표현** - -- 관계 타입별 엣지 스타일 (linked: 인디고, merged-view: 바이올렛, detached: 회색 점선) -- 노드 간 수동 관계 연결/해제 UI - -### Phase 3 — 향후 - -- 다중 오퍼레이터 (Figma 커서처럼 누가 어느 노드 보는지 표시) -- 노드 메모/태그 -- 실행 히스토리 타임라인 (tool 호출, patch, diff 순서대로) -- 오픈소스 배포 (npm / Docker) - ---- - -## 5. 아키텍처 - -``` -apps/agentree/ -├── src/ -│ ├── client/ # React + React Flow -│ │ ├── canvas/ # 인피니트 캔버스 + 노드 렌더링 -│ │ ├── panel/ # 사이드 패널 (채팅, 승인, 관계) -│ │ └── store/ # 실시간 상태 (Zustand) -│ └── server/ # Hono 서버 -│ ├── routes/ # REST API (opencode SDK 프록시) -│ ├── sse/ # opencode SSE → 클라이언트 브로드캐스트 -│ ├── opencode/ # opencode SDK 연동 + 호환성 어댑터 -│ └── db/ # SQLite 오버레이 (canvas_node, session_relation) -├── drizzle/ # 마이그레이션 -└── docs/ -``` - ---- - -## 6. 데이터 모델 - -### canvas_node - -| 컬럼 | 타입 | 설명 | -|------|------|------| -| session_id | text PK | opencode session ID | -| label | text | 사용자 정의 레이블 | -| canvas_x | real | 캔버스 X 위치 | -| canvas_y | real | 캔버스 Y 위치 | -| pinned | integer | 0/1 — 자동 레이아웃 대상 여부 | -| updated_at | text | ISO8601 | - -### session_fork (하위 호환 유지) - -| 컬럼 | 타입 | 설명 | -|------|------|------| -| session_id | text PK | fork된 세션 ID | -| forked_from_session_id | text | 원본 세션 ID | -| created_at | text | ISO8601 | - -### session_relation - -| 컬럼 | 타입 | 설명 | -|------|------|------| -| id | integer PK | auto-increment | -| from_session_id | text | 소스 세션 (엣지 방향: 부모) | -| to_session_id | text | 타겟 세션 (엣지 방향: 자식/fork) | -| relation_type | text | `fork` \| `linked` \| `detached` \| `merged-view` | -| created_at | text | ISO8601 | - ---- - -## 7. API - -### 트리 조회 - -| 메서드 | 경로 | 설명 | -|--------|------|------| -| GET | /api/tree | 세션 목록 + 상태 + 캔버스 오버레이 + 관계 반환 | -| GET | /api/health | 서버 + opencode 연결 상태 | - -### 세션 제어 - -| 메서드 | 경로 | 설명 | -|--------|------|------| -| GET | /api/session/:id | 세션 상세 | -| GET | /api/session/:id/messages | 메시지 히스토리 | -| POST | /api/session | 새 세션 생성 | -| POST | /api/session/:id/prompt | 프롬프트 전송 | -| POST | /api/session/:id/abort | 실행 중단 | -| POST | /api/session/:id/fork | 세션 fork (session_fork + session_relation 듀얼 라이트) | -| POST | /api/session/:id/subtask | 서브태스크 생성 | - -### 승인 제어 - -| 메서드 | 경로 | 설명 | -|--------|------|------| -| POST | /api/permission/:requestID/reply | permission 승인/거부 | -| POST | /api/question/:requestID/reply | question 답변 | -| POST | /api/question/:requestID/reject | question 거부 | - -### 캔버스 상태 - -| 메서드 | 경로 | 설명 | -|--------|------|------| -| PATCH | /api/canvas/:id | 노드 위치/레이블/pinned 저장 | -| GET | /api/canvas/:id | 노드 캔버스 상태 조회 | - -### 실시간 - -| 메서드 | 경로 | 설명 | -|--------|------|------| -| GET | /api/events | opencode SSE 클라이언트 브로드캐스트 | - ---- - -## 8. 프론트 핵심 동작 - -### 캔버스 - -- React Flow 기반 인피니트 캔버스 -- 초기 레이아웃: dagre 자동 배치 (rankdir: BT — 뿌리가 아래, 가지가 위) -- 노드 드래그 → SQLite 위치 저장 (pinned) -- SSE 이벤트 → 새 노드 실시간 추가 -- recent / all 뷰 모드 -- 디렉토리 기준 그룹 헤더 - -### 노드 상태 - -| 상태 | 색상 | 트리거 | -|------|------|--------| -| `running` | 초록 | `session.status` | -| `needs-permission` | 노랑 | `permission.asked` | -| `needs-answer` | 주황 | `question.asked` | -| `idle` | 파랑 | `session.idle` | -| `done` | 회색 | `session.status` (done) | -| `failed` | 빨강 | `session.error` | - -### 엣지 스타일 - -| 조건 | 색상 | 선 스타일 | 애니메이션 | -|------|------|-----------|-----------| -| 기본 부모-자식 | `#374151` | 실선 | 없음 | -| fork 관계 | `#14b8a6` (teal) | `8 4` 점선 | 없음 | -| linked 관계 | `#818cf8` (indigo) | `4 2` 점선 | 없음 | -| merged-view | `#a78bfa` (violet) | 실선 | 없음 | -| detached | `#6b7280` (gray) | `2 6` 점선 | 없음 | -| needs-permission | 노랑 | 점선 | 있음 | -| needs-answer | 주황 | 점선 | 있음 | - -### 사이드 패널 - -- 세션 메시지 히스토리 (현재: text/reasoning 파트) -- 채팅 입력 → prompt 전송 -- abort 버튼 -- permission / question 인라인 응답 UI -- 자식 세션 pending 항목 표시 -- fork 소스 표시 및 네비게이션 -- subtask / fork 생성 버튼 - ---- - -## 9. 비즈니스 모델 - -**라이선스: Apache-2.0** - -현재 단계에서 AGPL/BSL은 과잉 대응이다. 이유: -- 로컬 실행 기반이라 클라우드 프로바이더 strip-mining 위협이 낮음 -- 초기 채택 확산이 방어보다 중요한 시점 -- Apache-2.0은 특허 방어 조항 포함 + 상표는 별도 보호 - -**상표: 공개 전 KIPO 출원 (9류 + 42류)** - -오픈소스로 공개해도 "Agentree" 상표는 라이선스가 보호하지 않음. 출원일 기준으로 권리가 발생하므로, 공개 전 출원이 필요하다. - -**수익화: 장기 오픈코어 방향 (현재는 미결)** - -| 레이어 | 공개 여부 | 내용 | -|--------|-----------|------| -| 코어 | 오픈소스 (Apache-2.0) | 캔버스, 세션 제어, 오버레이 DB, SSE | -| 팀 레이어 | 유료 (향후, TBD) | 멀티 오퍼레이터, 권한 관리, hosted 연결 | - -팀 기능 수요가 실제로 발생하는 시점에 오픈코어 경계를 설계한다. 지금 설계하면 수요보다 수익 구조를 먼저 상정하는 꼴이 됨. - -**공개 전 체크리스트** -- [ ] KIPO 상표 출원 (9류 + 42류, 출원료 104,000원) -- [ ] `LICENSE` 파일 추가 (Apache-2.0) -- [ ] `.gitignore` 정리 -- [ ] `README.md` 공개용으로 정리 -- [ ] `.env` 계열 파일 제외 확인 - ---- - -## 10. 기술 스택 - -| 레이어 | 선택 | 이유 | -|--------|------|------| -| 프론트 | React + Vite | | -| 캔버스 | React Flow | 인피니트 캔버스, 노드 커스텀, 줌/팬 기본 제공 | -| 레이아웃 | dagre | 트리 자동 배치 | -| 상태관리 | Zustand | SSE 이벤트 → 캔버스 상태 연동 | -| 백엔드 | Hono (Node) | TypeScript-first, SSE 내장 | -| ORM | Drizzle | TypeScript 타입 안전 | -| DB | SQLite (WAL) | 오버레이 전용 — opencode가 session 상태의 source of truth | -| AI 엔진 | opencode SDK (`@opencode-ai/sdk`) | session 트리가 process 계층의 source of truth | diff --git a/drizzle/0003_task_invocation.sql b/drizzle/0003_task_invocation.sql new file mode 100644 index 0000000..c0dbafe --- /dev/null +++ b/drizzle/0003_task_invocation.sql @@ -0,0 +1,12 @@ +CREATE TABLE `task_invocation` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `parent_session_id` text NOT NULL, + `message_id` text, + `part_id` text, + `child_session_id` text, + `agent` text NOT NULL, + `description` text NOT NULL, + `prompt_preview` text NOT NULL, + `created_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) NOT NULL, + `updated_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) NOT NULL +); diff --git a/drizzle/0004_detach_from_parent.sql b/drizzle/0004_detach_from_parent.sql new file mode 100644 index 0000000..818615f --- /dev/null +++ b/drizzle/0004_detach_from_parent.sql @@ -0,0 +1 @@ +ALTER TABLE `canvas_node` ADD `detached` integer DEFAULT 0 NOT NULL; diff --git a/drizzle/0005_projects.sql b/drizzle/0005_projects.sql new file mode 100644 index 0000000..c08c3fe --- /dev/null +++ b/drizzle/0005_projects.sql @@ -0,0 +1,8 @@ +CREATE TABLE `project` ( + `id` text NOT NULL PRIMARY KEY, + `name` text NOT NULL, + `directory_key` text NOT NULL UNIQUE, + `created_at` text NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) +); +--> statement-breakpoint +ALTER TABLE `canvas_node` ADD `project_id` text REFERENCES project(id); diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index f6221c3..f8bb545 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -22,6 +22,27 @@ "when": 1775600000000, "tag": "0002_session_relation", "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1775601000000, + "tag": "0003_task_invocation", + "breakpoints": true + }, + { + "idx": 4, + "version": "6", + "when": 1744070400000, + "tag": "0004_detach_from_parent", + "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1744416000000, + "tag": "0005_projects", + "breakpoints": true } ] } diff --git a/package.json b/package.json index 916ea2f..0567e02 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "build": "tsc -p tsconfig.json && vite build", "preview": "vite preview", "test": "vitest run", + "test:opencode": "dotenv -e .env.opencode -- env AGENTREE_OPENCODE_INTEGRATION=1 AGENTREE_OPENCODE_LLM=1 vitest run --config vitest.opencode.config.ts", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate" }, diff --git a/src/client/App.tsx b/src/client/App.tsx index 4e1fbc6..cf71c5f 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -1,8 +1,10 @@ -import { Component, type ReactNode } from 'react' +import { Component, useEffect, type ReactNode } from 'react' import { AgentCanvas } from './canvas/AgentCanvas' import { SessionPanel } from './panel/SessionPanel' import { ApprovalQueue } from './panel/ApprovalQueue' import { SubtaskDialog } from './panel/SubtaskDialog' +import { SessionListSidebar } from './canvas/SessionListSidebar' +import { HomeScreen } from './HomeScreen' import { useAgentStore } from './store/agentStore' // C5: Error Boundary — catches render errors and shows recovery UI @@ -45,35 +47,49 @@ class ErrorBoundary extends Component<{ children: ReactNode }, { error: Error | } export default function App() { + const appView = useAgentStore((s) => s.appView) const selectedSessionId = useAgentStore((s) => s.selectedSessionId) const subtaskTargetSessionId = useAgentStore((s) => s.subtaskTargetSessionId) const setSubtaskTargetSession = useAgentStore((s) => s.setSubtaskTargetSession) const applySessionTree = useAgentStore((s) => s.applySessionTree) async function refreshTree() { - const response = await fetch('/api/tree') - const data = await response.json() - applySessionTree(data) + try { + const response = await fetch('/api/tree') + const data = await response.json() + applySessionTree(data) + } catch (err) { + console.error('[App] refreshTree failed', err) + } } + useEffect(() => { + void refreshTree() + }, []) + return ( -
-
- + {appView === 'home' ? ( + + ) : ( +
+ +
+ +
+ {selectedSessionId && } + + {subtaskTargetSessionId && ( + setSubtaskTargetSession(null)} + onCreated={() => { + void refreshTree() + }} + /> + )}
- {selectedSessionId && } - - {subtaskTargetSessionId && ( - setSubtaskTargetSession(null)} - onCreated={() => { - void refreshTree() - }} - /> - )} -
+ )} ) } diff --git a/src/client/HomeScreen.tsx b/src/client/HomeScreen.tsx new file mode 100644 index 0000000..b747a14 --- /dev/null +++ b/src/client/HomeScreen.tsx @@ -0,0 +1,256 @@ +import { useState } from 'react' +import { useAgentStore, STATUS_COLORS } from './store/agentStore' +import type { Project } from './store/agentStore' + +function ProjectCard({ + project, + sessionCount, + runningCount, + pendingCount, + onEnter, +}: { + project: Project + sessionCount: number + runningCount: number + pendingCount: number + onEnter: () => void +}) { + const [editing, setEditing] = useState(false) + const [nameValue, setNameValue] = useState(project.name) + + async function saveName() { + const trimmed = nameValue.trim() + if (!trimmed || trimmed === project.name) { + setNameValue(project.name) + setEditing(false) + return + } + await fetch(`/api/project/${project.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: trimmed }), + }) + setEditing(false) + } + + return ( +
{ (e.currentTarget as HTMLDivElement).style.borderColor = '#334155' }} + onMouseLeave={(e) => { (e.currentTarget as HTMLDivElement).style.borderColor = '#1f2937' }} + > +
+ {editing ? ( + setNameValue(e.target.value)} + onBlur={() => void saveName()} + onKeyDown={(e) => { + if (e.key === 'Enter') void saveName() + if (e.key === 'Escape') { setNameValue(project.name); setEditing(false) } + }} + style={{ + background: '#1e293b', + border: '1px solid #38bdf8', + borderRadius: 6, + color: '#e2e8f0', + fontSize: 15, + fontWeight: 600, + padding: '3px 8px', + outline: 'none', + flex: 1, + }} + /> + ) : ( + setEditing(true)} + title="Click to rename" + > + {project.name} + + )} +
+ +
+ + {project.directoryKey} + +
+ +
+ + {sessionCount} session{sessionCount !== 1 ? 's' : ''} + + {runningCount > 0 && ( + + + {runningCount} running + + )} + {pendingCount > 0 && ( + + + {pendingCount} pending + + )} +
+ + +
+ ) +} + +export function HomeScreen() { + const projects = useAgentStore((s) => s.projects) + const sessions = useAgentStore((s) => s.sessions) + const statusBySession = useAgentStore((s) => s.statusBySession) + const setActiveProjectKey = useAgentStore((s) => s.setActiveProjectKey) + const setAppView = useAgentStore((s) => s.setAppView) + + const projectStats = projects.map((project) => { + const projectSessions = sessions.filter((s) => s.projectId === project.id) + const runningCount = projectSessions.filter((s) => + statusBySession[s.id] === 'running', + ).length + const pendingCount = projectSessions.filter((s) => + statusBySession[s.id] === 'needs-permission' || statusBySession[s.id] === 'needs-answer', + ).length + return { project, sessionCount: projectSessions.length, runningCount, pendingCount } + }) + + function enterProject(projectId: string) { + setActiveProjectKey(projectId) + setAppView('canvas') + } + + function viewAll() { + setActiveProjectKey(null) + setAppView('canvas') + } + + return ( +
+ {/* Header */} +
+
+ + agentree + + + + {projects.length} project{projects.length !== 1 ? 's' : ''}, {sessions.length} session{sessions.length !== 1 ? 's' : ''} + +
+ +
+ + {/* Project grid */} +
+ {projectStats.length === 0 ? ( +
+ No projects yet + Sessions will appear here once opencode is connected +
+ ) : ( +
+ {projectStats.map(({ project, sessionCount, runningCount, pendingCount }) => ( + enterProject(project.id)} + /> + ))} +
+ )} +
+
+ ) +} diff --git a/src/client/canvas/AgentCanvas.tsx b/src/client/canvas/AgentCanvas.tsx index b60a4bb..339c5f4 100644 --- a/src/client/canvas/AgentCanvas.tsx +++ b/src/client/canvas/AgentCanvas.tsx @@ -16,6 +16,7 @@ import { useAgentStore } from '../store/agentStore' import { AgentNode } from './AgentNode' import { AgentEdge } from './AgentEdge' import { GroupHeaderNode } from './GroupHeaderNode' +import { ProjectTabBar } from './ProjectTabBar' type ConnRelationType = 'linked' | 'detached' @@ -141,10 +142,17 @@ export function AgentCanvas() { const edges = useAgentStore((s) => s.edges) const groupHeaders = useAgentStore((s) => s.groupHeaders) const compat = useAgentStore((s) => s.compat) + const sessions = useAgentStore((s) => s.sessions) + const projects = useAgentStore((s) => s.projects) + const activeProjectKey = useAgentStore((s) => s.activeProjectKey) + const pendingScrollToSessionId = useAgentStore((s) => s.pendingScrollToSessionId) const applySessionTree = useAgentStore((s) => s.applySessionTree) const applyEvent = useAgentStore((s) => s.applyEvent) const setSelectedSession = useAgentStore((s) => s.setSelectedSession) const setViewMode = useAgentStore((s) => s.setViewMode) + const setActiveProjectKey = useAgentStore((s) => s.setActiveProjectKey) + const setAppView = useAgentStore((s) => s.setAppView) + const setPendingScrollToSessionId = useAgentStore((s) => s.setPendingScrollToSessionId) const onNodesChange = useAgentStore((s) => s.onNodesChange) const pinNode = useAgentStore((s) => s.pinNode) const addRelation = useAgentStore((s) => s.addRelation) @@ -158,6 +166,8 @@ export function AgentCanvas() { setPendingConn({ source: connection.source, target: connection.target }) }, []) const previousViewMode = useRef(viewMode) + const previousActiveKey = useRef(activeProjectKey) + const hasTabBar = true // always show project header bar const reactFlowRef = useRef | null>(null) async function reloadTree() { @@ -203,7 +213,13 @@ export function AgentCanvas() { if (cancelled) return es = new EventSource('/api/events') es.onmessage = (e) => { - try { applyEvent(JSON.parse(e.data)) } catch (err) { console.warn('[sse] failed to parse event:', err) } + try { + const event = JSON.parse(e.data) + applyEvent(event) + if (event.type === 'session.created' || event.type === 'message.part.updated') { + reloadTree().catch(console.error) + } + } catch (err) { console.warn('[sse] failed to parse event:', err) } } es.onerror = () => { console.warn('[sse] connection lost, reconnecting in 3s...') @@ -224,7 +240,9 @@ export function AgentCanvas() { useEffect(() => { if (nodes.length === 0 || !reactFlowRef.current) return - const shouldFrame = !hasFramedInitialView.current || previousViewMode.current !== viewMode + const shouldFrame = !hasFramedInitialView.current + || previousViewMode.current !== viewMode + || previousActiveKey.current !== activeProjectKey if (!shouldFrame) return reactFlowRef.current.fitView({ @@ -236,10 +254,39 @@ export function AgentCanvas() { }) hasFramedInitialView.current = true previousViewMode.current = viewMode - }, [nodes, viewMode]) + previousActiveKey.current = activeProjectKey + }, [nodes, viewMode, activeProjectKey]) + + useEffect(() => { + if (!pendingScrollToSessionId || !reactFlowRef.current) return + const node = nodes.find((n) => n.id === pendingScrollToSessionId) + if (!node) return + reactFlowRef.current.setCenter( + node.position.x + 100, + node.position.y + 30, + { zoom: 1.0, duration: 400 }, + ) + setPendingScrollToSessionId(null) + }, [pendingScrollToSessionId, nodes]) return (
+ setAppView('home')} + onSelectAll={() => setActiveProjectKey(null)} + /> + {nodes.length === 0 && sessions.length > 0 && ( +
+ No sessions in this project +
+ )} {treeError && (
+ <> + + {label && ( + +
+ TASK {label} +
+
+ )} + ) } diff --git a/src/client/canvas/AgentNode.tsx b/src/client/canvas/AgentNode.tsx index 5bfa43c..56964d1 100644 --- a/src/client/canvas/AgentNode.tsx +++ b/src/client/canvas/AgentNode.tsx @@ -1,21 +1,12 @@ import { useState } from 'react' import { Handle, Position, type NodeProps } from '@xyflow/react' -import type { AgentNodeData, NodeStatus } from '../store/agentStore' -import { useAgentStore } from '../store/agentStore' +import type { AgentNodeData } from '../store/agentStore' +import { useAgentStore, STATUS_COLORS } from '../store/agentStore' import { fetchJson } from '../utils/fetchJson' -const STATUS_COLOR: Record = { - running: '#22c55e', - 'needs-permission': '#eab308', - 'needs-answer': '#f97316', - idle: '#3b82f6', - done: '#6b7280', - failed: '#ef4444', -} - export function AgentNode({ data, selected }: NodeProps) { const d = data as AgentNodeData - const color = STATUS_COLOR[d.status] ?? '#6b7280' + const color = STATUS_COLORS[d.status] ?? '#6b7280' const [showActivity, setShowActivity] = useState(false) const [showActions, setShowActions] = useState(false) const isRunning = d.status === 'running' @@ -45,6 +36,16 @@ export function AgentNode({ data, selected }: NodeProps) { if (forked?.id) setSelectedSession(forked.id) } + async function toggleDetach(event: React.MouseEvent) { + event.stopPropagation() + await fetch(`/api/canvas/${d.sessionId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ detached: !d.detached }), + }) + await refreshTree() + } + async function deleteSession(event: React.MouseEvent) { event.stopPropagation() if (!window.confirm(`Delete "${d.label}"?`)) return @@ -106,6 +107,19 @@ export function AgentNode({ data, selected }: NodeProps) { > Fork + {d.hasParent && ( + + )}
)} + {d.taskCount > 0 && ( +
0 ? '#2563eb' : '#0f766e', + color: '#eff6ff', + borderRadius: 999, + padding: '2px 6px', + fontSize: 9, + fontWeight: 800, + letterSpacing: '0.04em', + boxShadow: '0 0 0 2px #1a1a1a', + zIndex: 1, + }} + title={`${d.taskCount} task${d.taskCount !== 1 ? 's' : ''}${d.pendingTaskCount > 0 ? `, ${d.pendingTaskCount} pending child` : ''}`} + > + TASK {d.taskCount} +
+ )} + {d.incomingTask && !d.forkedFromSessionId && ( +
+ TASK +
+ )}
diff --git a/src/client/canvas/ProjectTabBar.tsx b/src/client/canvas/ProjectTabBar.tsx new file mode 100644 index 0000000..684b1b9 --- /dev/null +++ b/src/client/canvas/ProjectTabBar.tsx @@ -0,0 +1,92 @@ +import type { ActiveProjectKey, Project } from '../store/agentStore' + +type Props = { + projects: Project[] + activeProjectKey: ActiveProjectKey + totalSessionCount: number + onBack: () => void + onSelectAll: () => void +} + +export function ProjectTabBar({ projects, activeProjectKey, totalSessionCount, onBack, onSelectAll }: Props) { + const activeProject = activeProjectKey ? projects.find((p) => p.id === activeProjectKey) : null + + return ( +
+ +
+ {activeProject ? ( + + {activeProject.name} + + ) : ( + + All Sessions + ({totalSessionCount}) + + )} + {activeProject && ( + <> +
+ + + )} +
+ ) +} diff --git a/src/client/canvas/SessionListSidebar.tsx b/src/client/canvas/SessionListSidebar.tsx new file mode 100644 index 0000000..21dbc3f --- /dev/null +++ b/src/client/canvas/SessionListSidebar.tsx @@ -0,0 +1,200 @@ +import { useMemo, useState } from 'react' +import { useAgentStore, STATUS_COLORS } from '../store/agentStore' + +export function SessionListSidebar() { + const sessions = useAgentStore((s) => s.sessions) + const nodes = useAgentStore((s) => s.nodes) + const projects = useAgentStore((s) => s.projects) + const statusBySession = useAgentStore((s) => s.statusBySession) + const selectedSessionId = useAgentStore((s) => s.selectedSessionId) + const setSelectedSession = useAgentStore((s) => s.setSelectedSession) + const setActiveProjectKey = useAgentStore((s) => s.setActiveProjectKey) + const setPendingScrollToSessionId = useAgentStore((s) => s.setPendingScrollToSessionId) + + const [collapsed, setCollapsed] = useState(true) + const [search, setSearch] = useState('') + + const visibleNodeIds = useMemo(() => new Set(nodes.map((n) => n.id)), [nodes]) + + const filteredSessions = useMemo(() => { + const q = search.toLowerCase().trim() + if (!q) return sessions + return sessions.filter((s) => + (s.title ?? s.id).toLowerCase().includes(q) || + s.directory.toLowerCase().includes(q), + ) + }, [sessions, search]) + + const projectNameById = useMemo( + () => new Map(projects.map((p) => [p.id, p.name])), + [projects], + ) + + const grouped = useMemo(() => { + const map = new Map() + for (const s of filteredSessions) { + const k = s.projectId ?? 'unknown' + const arr = map.get(k) ?? [] + arr.push(s) + map.set(k, arr) + } + return [...map.entries()] + .sort(([a], [b]) => (projectNameById.get(a) ?? a).localeCompare(projectNameById.get(b) ?? b)) + .map(([key, items]) => ({ + key, + label: projectNameById.get(key) ?? key, + sessions: [...items].sort((a, b) => b.time.updated - a.time.updated), + })) + }, [filteredSessions, projectNameById]) + + function handleClick(sessionId: string) { + setSelectedSession(sessionId) + if (!visibleNodeIds.has(sessionId)) { + setActiveProjectKey(null) + } + setPendingScrollToSessionId(sessionId) + } + + if (collapsed) { + return ( +
setCollapsed(false)} + title="Open session list" + > + + ▶ + +
+ ) + } + + return ( +
+ {/* Header */} +
+ + Sessions + + +
+ + {/* Search */} +
+ setSearch(e.target.value)} + placeholder="Search..." + style={{ + width: '100%', + background: '#1e293b', + border: '1px solid #334155', + borderRadius: 6, + color: '#e2e8f0', + fontSize: 11, + padding: '5px 8px', + outline: 'none', + boxSizing: 'border-box', + }} + /> +
+ + {/* Session list */} +
+ {grouped.map(({ key, label, sessions: groupSessions }) => ( +
+
+ {label} ({groupSessions.length}) +
+ {groupSessions.map((session) => { + const status = statusBySession[session.id] ?? 'idle' + const isVisible = visibleNodeIds.has(session.id) + const isSelected = session.id === selectedSessionId + return ( +
handleClick(session.id)} + style={{ + display: 'flex', + alignItems: 'center', + gap: 7, + padding: '5px 10px', + cursor: 'pointer', + background: isSelected ? 'rgba(56,189,248,0.12)' : isVisible ? 'rgba(56,189,248,0.05)' : 'transparent', + borderLeft: isSelected ? '2px solid #38bdf8' : '2px solid transparent', + }} + > +
+ + {session.title ?? session.id.slice(0, 8)} + +
+ ) + })} +
+ ))} + {grouped.length === 0 && ( +
+ No sessions found +
+ )} +
+
+ ) +} diff --git a/src/client/main.tsx b/src/client/main.tsx index be4ff18..3fba072 100644 --- a/src/client/main.tsx +++ b/src/client/main.tsx @@ -2,6 +2,11 @@ import React from 'react' import ReactDOM from 'react-dom/client' import './index.css' import App from './App' +import { useAgentStore } from './store/agentStore' + +if (import.meta.env.DEV) { + (window as unknown as Record).__agentStore = useAgentStore +} ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/src/client/panel/PeerReviewSection.tsx b/src/client/panel/PeerReviewSection.tsx index 4d66690..937e0cd 100644 --- a/src/client/panel/PeerReviewSection.tsx +++ b/src/client/panel/PeerReviewSection.tsx @@ -67,9 +67,10 @@ export function PeerReviewSection({ sessionId }: { sessionId: string }) { let summary: string | null = null if (summaryOk && messages.length > 0) { const last = messages[messages.length - 1] - const parts = (last as { parts?: Array<{ type: string; text?: string }> }).parts - if (last && (last as { role?: string }).role === 'assistant' && parts) { - const textPart = parts.find((p: { type: string; text?: string }) => p.type === 'text') + const isMsg = (x: unknown): x is { role: string; parts: Array<{ type: string; text?: string }> } => + typeof x === 'object' && x !== null && 'role' in x && 'parts' in x && Array.isArray((x as { parts: unknown }).parts) + if (isMsg(last) && last.role === 'assistant') { + const textPart = last.parts.find((p) => p.type === 'text') if (textPart?.text) summary = textPart.text } } diff --git a/src/client/panel/SessionPanel.tsx b/src/client/panel/SessionPanel.tsx index 85f8b38..ac050eb 100644 --- a/src/client/panel/SessionPanel.tsx +++ b/src/client/panel/SessionPanel.tsx @@ -31,7 +31,8 @@ type MessagePart = | { id: string; type: 'agent'; name: string } | { id: string; type: 'retry'; attempt: number; error: unknown } | { id: string; type: 'compaction'; auto: boolean } - | { id: string; type: string } + | { id: string; type: 'step-start' } + | { id: string; type: 'snapshot' } type SessionMessage = { info: { @@ -54,6 +55,16 @@ type SessionDetails = { title?: string } +// ─── Type guards ───────────────────────────────────────────────────────────── + +function asPermissionLike(v: unknown): { requestID?: string; id?: string; title?: string } { + return (v ?? {}) as { requestID?: string; id?: string; title?: string } +} + +function asQuestionLike(v: unknown): { requestID?: string; id?: string; questions?: Array<{ id?: string; question?: string; label?: string }> } { + return (v ?? {}) as { requestID?: string; id?: string; questions?: Array<{ id?: string; question?: string; label?: string }> } +} + // ─── Helpers ───────────────────────────────────────────────────────────────── function formatTime(value?: number) { @@ -224,7 +235,7 @@ function PartRow({ part }: { part: MessagePart }) {
{part.files.map((f, i) => ( -
+
{f.filename} {(f.additions ?? 0) > 0 && +{f.additions}} {(f.deletions ?? 0) > 0 && -{f.deletions}} @@ -572,7 +583,7 @@ export function SessionPanel({ sessionId }: { sessionId: string }) { useEffect(() => { if (!lastActivity) return const t = setTimeout(() => { - void refreshMessages() + void refreshMessages().catch(console.error) }, 600) return () => clearTimeout(t) }, [lastActivity]) // eslint-disable-line react-hooks/exhaustive-deps @@ -647,8 +658,8 @@ export function SessionPanel({ sessionId }: { sessionId: string }) { async function replyPermission(reply: 'once' | 'always' | 'reject') { if (!pendingPermission || typeof pendingPermission !== 'object') return - const requestID = (pendingPermission as { requestID?: string; id?: string }).requestID - ?? (pendingPermission as { requestID?: string; id?: string }).id + const requestID = asPermissionLike(pendingPermission).requestID + ?? asPermissionLike(pendingPermission).id if (!requestID) return const res = await fetch(`/api/permission/${requestID}/reply`, { method: 'POST', @@ -660,8 +671,8 @@ export function SessionPanel({ sessionId }: { sessionId: string }) { async function submitQuestion() { if (!pendingQuestion || typeof pendingQuestion !== 'object') return - const requestID = (pendingQuestion as { requestID?: string; id?: string }).requestID - ?? (pendingQuestion as { requestID?: string; id?: string }).id + const requestID = asQuestionLike(pendingQuestion).requestID + ?? asQuestionLike(pendingQuestion).id const firstQuestion = questionItems[0] if (!requestID || !firstQuestion?.id || !questionAnswer.trim()) return const res = await fetch(`/api/question/${requestID}/reply`, { @@ -677,15 +688,15 @@ export function SessionPanel({ sessionId }: { sessionId: string }) { async function rejectQuestion() { if (!pendingQuestion || typeof pendingQuestion !== 'object') return - const requestID = (pendingQuestion as { requestID?: string; id?: string }).requestID - ?? (pendingQuestion as { requestID?: string; id?: string }).id + const requestID = asQuestionLike(pendingQuestion).requestID + ?? asQuestionLike(pendingQuestion).id if (!requestID) return const res = await fetch(`/api/question/${requestID}/reject`, { method: 'POST' }) if (!res.ok) throw new Error('Failed to reject question') } async function replyChildPermission(childId: string, reply: 'once' | 'always' | 'reject') { - const p = pendingPermissions[childId] as { requestID?: string; id?: string } | undefined + const p = asPermissionLike(pendingPermissions[childId]) const requestID = p?.requestID ?? p?.id if (!requestID) return try { @@ -701,7 +712,7 @@ export function SessionPanel({ sessionId }: { sessionId: string }) { } async function submitChildQuestion(childId: string) { - const q = pendingQuestions[childId] as { requestID?: string; id?: string; questions?: Array<{ id?: string }> } | undefined + const q = asQuestionLike(pendingQuestions[childId]) const requestID = q?.requestID ?? q?.id const firstQ = q?.questions?.[0] const answer = childAnswers[childId]?.trim() @@ -720,8 +731,8 @@ export function SessionPanel({ sessionId }: { sessionId: string }) { } async function rejectChildQuestion(childId: string) { - const q = pendingQuestions[childId] as { requestID?: string; id?: string } | undefined - const requestID = q?.requestID ?? q?.id + const q = asQuestionLike(pendingQuestions[childId]) + const requestID = q.requestID ?? q.id if (!requestID) return try { const res = await fetch(`/api/question/${requestID}/reject`, { method: 'POST' }) @@ -863,7 +874,7 @@ export function SessionPanel({ sessionId }: { sessionId: string }) {
Permission Request
- {String((pendingPermission as { title?: string }).title ?? 'This session is waiting for permission.')} + {String(asPermissionLike(pendingPermission).title ?? 'This session is waiting for permission.')}
diff --git a/src/client/panel/SubtaskDialog.tsx b/src/client/panel/SubtaskDialog.tsx index 584ebd0..ae712bc 100644 --- a/src/client/panel/SubtaskDialog.tsx +++ b/src/client/panel/SubtaskDialog.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useEffect, useMemo, useState } from 'react' type SubtaskDialogProps = { sessionId: string @@ -6,6 +6,14 @@ type SubtaskDialogProps = { onCreated?: () => void } +type AgentInfo = { + name: string + description?: string + mode: 'subagent' | 'primary' | 'all' + hidden?: boolean + native?: boolean +} + const fieldStyle = { width: '100%', background: '#0b0b0b', @@ -21,9 +29,31 @@ export function SubtaskDialog({ sessionId, onClose, onCreated }: SubtaskDialogPr const [prompt, setPrompt] = useState('') const [description, setDescription] = useState('') const [agent, setAgent] = useState('build') + const [agents, setAgents] = useState([]) const [submitting, setSubmitting] = useState(false) const [error, setError] = useState(null) + useEffect(() => { + let cancelled = false + fetch('/api/agents') + .then((res) => (res.ok ? res.json() : [])) + .then((data) => { + if (cancelled || !Array.isArray(data)) return + setAgents(data) + const preferred = data.find((item: AgentInfo) => !item.hidden && item.mode === 'subagent') + ?? data.find((item: AgentInfo) => !item.hidden && item.mode === 'all') + ?? data.find((item: AgentInfo) => item.name === 'build') + if (preferred?.name) setAgent(preferred.name) + }) + .catch(() => {}) + return () => { cancelled = true } + }, []) + + const sortedAgents = useMemo(() => [...agents].sort((left, right) => { + const rank = (agentInfo: AgentInfo) => agentInfo.mode === 'subagent' ? 0 : agentInfo.mode === 'all' ? 1 : 2 + return rank(left) - rank(right) || left.name.localeCompare(right.name) + }), [agents]) + async function submit() { const trimmedPrompt = prompt.trim() if (!trimmedPrompt || submitting) return @@ -81,7 +111,22 @@ export function SubtaskDialog({ sessionId, onClose, onCreated }: SubtaskDialogPr
{sessionId}
{error &&
{error}
}
- setAgent(e.target.value)} placeholder="Agent name" style={fieldStyle} /> + {sortedAgents.length > 0 ? ( + + ) : ( + setAgent(e.target.value)} placeholder="Agent name" style={fieldStyle} /> + )} + {sortedAgents.find((item) => item.name === agent)?.description && ( +
+ {sortedAgents.find((item) => item.name === agent)?.description} +
+ )} setDescription(e.target.value)} placeholder="Short description" style={fieldStyle} />