diff --git a/.agent/EXECPLAN.md b/.agent/EXECPLAN.md deleted file mode 100644 index f079bc62..00000000 --- a/.agent/EXECPLAN.md +++ /dev/null @@ -1,375 +0,0 @@ -# True Multi-Agent Projects (Local Folders + Git Init + Per-Tile Agent IDs) - -This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds. - -Maintain this document in accordance with `.agent/PLANS.md`. - -## Purpose / Big Picture - -After this change, the web UI can create a “project” by name and the server will create a real folder in your home directory (for example `~/example-project`), initialize it as a Git repository, and create a basic `.gitignore` that prevents committing `.env` files. - -Inside a project, the UI can create multiple “agent tiles”. Each tile will be a true, isolated Clawdbot agent by using a unique `agentId` in the session key (for example `agent:proj-example-project-coder-a1b2c3:main`). This gives each tile its own Clawdbot state directory subtree (`~/.clawdbot/agents//...`) and its own default workspace (`~/clawd-`), so sessions, auth profiles, and transcripts do not collide between tiles. - -You can see it working by: - -1. Starting the Clawdbot gateway and this Next.js dev server. -2. Creating a project named `example-project` in the UI. -3. Verifying the folder `~/example-project` exists, contains a `.git/` directory, and `.gitignore` contains `.env`. -4. Creating two tiles in that project (for example “Coder” and “Research” roles) and sending each a message. -5. Verifying transcripts land in different directories: - - `~/.clawdbot/agents//sessions/*.jsonl` - - `~/.clawdbot/agents//sessions/*.jsonl` - -## Progress - -- [x] (2026-01-25 18:30Z) Read `.agent/PLANS.md` and audited current app structure (projects store, tiles, gateway client). -- [x] (2026-01-25 18:55Z) Implemented project creation side effects (slugify, mkdir, `git init`, `.gitignore`) and updated the UI to accept only a project name. -- [x] (2026-01-25 18:55Z) Implemented per-tile `agentId`/`role` storage with a v1→v2 store migration and v2 session key derivation. -- [x] (2026-01-25 19:31Z) Added server-side tile creation endpoint with workspace bootstrap + auth profile copy and optional tile deletion endpoint. -- [x] (2026-01-25 19:31Z) Wired UI tile creation/deletion to server endpoints and updated client API helpers. -- [ ] (2026-01-25 19:38Z) Validate end-to-end (completed: `npm run lint`, `npm run build`; remaining: manual project/tile creation + gateway transcript verification). - -## Surprises & Discoveries - -- Observation: (none yet) - Evidence: (none yet) - -## Decision Log - -- Decision: Do not patch Clawdbot’s `clawdbot.json` to add `agents.list` entries for every tile. - Rationale: The gateway’s `config.patch`/`config.apply` schedules a restart, which is too disruptive for a UI that creates tiles frequently. For WebChat (`chat.send`) the gateway resolves `agentId` directly from the `sessionKey` prefix; using `agent::...` is sufficient to get isolated `agentDir` + session transcripts without touching config. - Date/Author: 2026-01-25 / Codex - -- Decision: Never delete project folders or agent directories from disk as part of “delete” actions. - Rationale: Directory deletion is destructive and explicitly discouraged by the repo’s agent guidelines; the UI should only remove entries from its own store and leave cleanup as a separate, explicit operation. - Date/Author: 2026-01-25 / Codex - -- Decision: Generate client-side tile `agentId`s as `proj--` until server-side tile provisioning lands. - Rationale: The UI still creates tiles locally during Milestone 2; this keeps per-tile session keys unique without blocking on the new tile endpoint. - Date/Author: 2026-01-25 / Codex - -## Outcomes & Retrospective - -(Fill in once milestones land.) - -## Context and Orientation - -This repository is a local-only Next.js app that talks directly to a running Clawdbot Gateway over a WebSocket. - -Key concepts (define these because names are overloaded): - -- “Project”: In this app, a project is a record in a JSON store plus a real folder on disk in your home directory. After this change, creating a project also creates `~/` and initializes a Git repo there. -- “Tile”: A draggable UI panel representing an interactive chat session. Today, tiles are “agents” only in the UI sense; they all use `agent:main:...` session keys. -- “Agent” (Clawdbot agent): In Clawdbot, the agent is encoded in the session key prefix: `agent::`. The `` is used to choose the agent’s state directory (`~/.clawdbot/agents//agent`) and session transcript directory (`~/.clawdbot/agents//sessions`). When we say “true multi-agent” in this plan, we mean “each tile uses a different `` so Clawdbot’s state and transcripts are isolated per tile”. -- “Gateway”: The Clawdbot process that exposes a WebSocket RPC interface. This UI uses it via `src/lib/gateway/GatewayClient.ts` and calls methods like `chat.send` and `sessions.patch`. - -Current code layout (important files you must read before editing): - -- Project store (server side): - - `app/api/projects/store.ts` writes `~/.clawdbot/agent-canvas/projects.json` - - `app/api/projects/route.ts` implements `GET/POST/PUT /api/projects` - - `app/api/projects/[projectId]/route.ts` implements `DELETE /api/projects/:projectId` -- Client-side state: - - `src/state/store.tsx` holds `ProjectsStore` in React state and persists it back to the server via `PUT /api/projects` -- UI + gateway wiring: - - `app/page.tsx` sends chat via `client.call("chat.send", { sessionKey, ... })` - - `src/lib/gateway/GatewayClient.ts` is the WebSocket RPC client - -Important current behavior that must change: - -- Tile session keys are currently hard-coded under the main agent: - - `src/state/store.tsx` builds keys as `agent:main:proj-${projectId}-${tileId}` - - This causes all tiles to share the same agentId (“main”), which is not true multi-agent isolation. - -## Milestones - -### Milestone 1: Projects create real folders and Git repos - -At the end of this milestone, creating a project from the UI (by name) will create a new directory in `~/` (based on a slugified name), run `git init` inside it, and ensure `.gitignore` ignores `.env` files. The project record stored in `projects.json` will point at that directory via `repoPath`. - -Define “slugified name” for this repo so it is deterministic and testable: - -- Trim whitespace. -- Lowercase. -- Replace any run of characters that are not `a-z` or `0-9` with a single `-`. -- Trim leading/trailing `-`. -- If the result is empty, reject the request with HTTP 400 (“Project name produced an empty folder name”). - -Define collision behavior explicitly so the endpoint is safe to retry and safe around existing folders: - -- Preferred: if `~/` already exists, choose the first available suffix `~/-2`, `~/-3`, etc, and return a warning in the response that indicates the final path chosen. -- Never delete or overwrite existing directories as part of “create project”. - -Define the exact `.gitignore` rules this milestone must guarantee (append missing lines if the file exists; do not remove user lines): - - .env - .env.* - !.env.example - -You can prove it works by creating a project named `example-project` and running `ls -la ~/example-project` to see `.git/` and `.gitignore`, and `cat ~/example-project/.gitignore` to confirm `.env` is present. - -### Milestone 2: Persisted tile schema supports per-tile agent IDs - -At the end of this milestone, the persisted store schema moves from `ProjectsStore.version = 1` to `version = 2`. Each tile now has a required `agentId` and `role`, and the `sessionKey` becomes a derived value that always uses the agent id prefix form `agent::main`. - -Migration rules from v1 must be written down and implemented exactly: - -- If the stored file is missing or has `version: 1`, treat it as v1. -- For each v1 tile: - - Set `tile.agentId` by parsing `tile.sessionKey`: - - If `tile.sessionKey` matches `agent::`, then `agentId = `. - - Otherwise set `agentId = "main"`. - - Set `tile.role = "coding"` (legacy default). - - Keep `tile.sessionKey` as-is for legacy tiles so existing sessions/transcripts still match. -- For any new tile created under v2 rules: - - Always set `tile.sessionKey = agent::main`. - -You can prove it works by loading an existing v1 store and seeing it automatically hydrate tiles with `agentId = "main"` (legacy) while new tiles get unique `agentId`s. - -### Milestone 3: Creating a tile provisions a real Clawdbot agent workspace and auth copy - -At the end of this milestone, creating a tile is no longer a client-only operation. The client will call a new server endpoint, and the server will both update the `projects.json` store and run filesystem side effects that make the new tile usable as an independent Clawdbot agent. - -Add a new API route: - -- `POST /api/projects/:projectId/tiles` - -Request JSON: - - { "name": "Coder", "role": "coding" } - -Response JSON: - - { "store": , "tile": , "warnings": ["..."] } - -Server-side behavior for `POST /api/projects/:projectId/tiles`: - -- Load the current store via `loadStore()` and find the project by id. -- Generate: - - `tile.id = crypto.randomUUID()` - - `tile.agentId` using a deterministic, safe id generator (specified in the Interfaces section) based on: - - project folder slug (from `project.repoPath` basename) - - role - - a short random suffix (for uniqueness) -- Set `tile.sessionKey = agent::main`. -- Add the tile to the project, save the store, and then run side effects: - - Create the agent workspace folder at `~/clawd-` (create directories if missing). - - Attempt to create a symlink inside that workspace: `~/clawd-/repo -> `. - - If the symlink already exists, do nothing. - - If the symlink creation fails, do not create an alternate symlink/fallback; the bootstrap files below already contain the absolute repo path. - - Create these bootstrap files in the workspace if they do not exist: - - BOOTSTRAP.md - AGENTS.md - SOUL.md - - The minimum required content (write exactly this structure; customize role + repo path dynamically): - - # BOOTSTRAP.md - - Project repo: - Role: - - You are operating inside this project. Prefer working in ./repo (symlink) when it exists. - If ./repo does not exist, operate directly in: - - First action: run "ls" in the repo to confirm access. - -- Copy auth profiles from the default agent into the new agent if the new agent has no auth file yet: - - Resolve `stateDir = process.env.CLAWDBOT_STATE_DIR ?? "~/.clawdbot"`. - - Resolve `sourceAgentId = process.env.CLAWDBOT_DEFAULT_AGENT_ID ?? "main"`. - - Source: `/agents//agent/auth-profiles.json` - - Destination: `/agents//agent/auth-profiles.json` - - Never overwrite an existing destination file. - - If the source does not exist, return a warning like: `No auth profiles found at ; agent may need login`. - -You can prove it works by creating a tile and checking that `~/clawd-/BOOTSTRAP.md` exists and `~/.clawdbot/agents//agent/auth-profiles.json` exists (assuming the source exists). - -### Milestone 4: Chat uses per-tile session keys and produces isolated transcripts - -At the end of this milestone, sending a message from a tile uses that tile’s per-tile session key (`agent::main`). This must result in distinct transcript files under each agent’s session directory. - -You can prove it works by sending one message in two different tiles and then checking: - - ls ~/.clawdbot/agents//sessions - ls ~/.clawdbot/agents//sessions - -Both should contain JSONL transcript files and the files should differ. - -## Plan of Work - -Implement this as a sequence of small, verifiable changes. Avoid modifying the Clawdbot repo; all changes are contained within this repository. - -First, make project creation authoritative on the server: update `app/api/projects/route.ts` `POST` so it accepts a project name and performs filesystem creation (`~/`, `git init`, `.gitignore`). Update the UI form in `app/page.tsx` so the user provides only a project name; do not ask for a repo path in the UI anymore, since the server determines it. - -Second, introduce a v2 store schema that supports per-tile `agentId` and `role`. Implement the migration in `app/api/projects/store.ts` inside `loadStore()`. Stop silently resetting the store on parse errors; instead, throw an error that returns HTTP 500 with a message pointing at the store path so the user can fix or delete it intentionally. - -Third, add a dedicated server endpoint for creating tiles so you can run filesystem side effects at creation time. Implement: - -- `app/api/projects/[projectId]/tiles/route.ts` with `POST` as defined in Milestone 3. -- Optionally (recommended), implement `DELETE app/api/projects/[projectId]/tiles/[tileId]/route.ts` so tile deletion can also be server-authoritative (while still not deleting directories on disk). - -This endpoint will generate the tile id, compute a safe `agentId`, create the workspace and bootstrap files, and copy auth profiles if possible. It must return the updated store (and the created tile) so the client can update state without racing the debounced `PUT /api/projects` persistence. - -Fourth, update the client to use the per-tile `sessionKey` derived from `agentId` when calling `chat.send` and `sessions.patch`, and update the lookup helpers that match incoming `chat` events by `sessionKey`. - -This requires edits in: - -- `src/state/store.tsx` to remove client-only `createTile()` and replace it with a function that calls the new tile endpoint and dispatches `loadStore` with the returned store. -- `app/page.tsx` to ensure any “new agent” action calls the updated store function, and that any message send uses the tile’s current `sessionKey`. - -Finally, validate with a hands-on scenario and run `npm run lint` and `npm run build`. - -## Concrete Steps - -All commands in this section are run from the repository root unless otherwise stated. - -1. Install deps: - - npm install - -2. Start the Clawdbot gateway in a separate terminal (this plan assumes it is already installed and configured on your machine): - - clawdbot gateway run --bind loopback --port 18789 --force - -3. Start the Next.js app: - - npm run dev - -4. Open the UI: - - http://localhost:3000 - -5. Connect to the gateway (Connection panel): - - Gateway URL: ws://127.0.0.1:18789 - Token: (enter if your gateway requires it) - -6. Create a project named `example-project` and verify on disk: - - ls -la ~/example-project - cat ~/example-project/.gitignore - -7. Create two tiles (roles: “coding” and “research”), send one message from each, and verify on disk: - - ls ~/.clawdbot/agents - ls ~/.clawdbot/agents//sessions - ls ~/.clawdbot/agents//sessions - -Expected: both session directories exist and each contains at least one `.jsonl` transcript file after sending messages. - -## Validation and Acceptance - -Acceptance is user-visible behavior: - -- Creating a project named `example-project` results in a new folder at `~/example-project` with: - - a `.git/` directory (Git repo initialized) - - a `.gitignore` file containing rules that ignore `.env` files -- Creating two tiles in a project produces two different `agentId`s and their sessions do not collide: - - after sending messages, transcripts appear under two different directories in `~/.clawdbot/agents//sessions/` -- Existing UI behavior remains functional: - - The canvas still renders and tiles can be created/moved/resized. - - `npm run lint` succeeds. - - `npm run build` succeeds. - -## Idempotence and Recovery - -- Project creation must be safe to retry: - - If the target directory already exists, the API should not delete or overwrite existing files; it should return a clear error or create a unique suffixed directory (document which approach you choose in the Decision Log). - - If `git init` has already been run, do not reinitialize; treat it as success. - - If `.gitignore` exists, only add missing ignore lines; do not delete user content. - -- Tile creation must be safe to retry: - - Never overwrite an existing `auth-profiles.json` in a destination agent dir. - - Never overwrite existing workspace bootstrap files; only create missing ones. - -If the store becomes invalid JSON, prefer failing with an actionable error rather than silently resetting it (silent reset loses data). - -## Artifacts and Notes - -When you land the implementation, include a short “evidence bundle” here as indented snippets: - - - Output of: ls -la ~/example-project - - Contents of: ~/example-project/.gitignore (relevant lines only) - - Output of: ls ~/.clawdbot/agents//sessions - - Output of: npm run lint - - Output of: npm run build - -## Interfaces and Dependencies - -Avoid new dependencies unless they remove real complexity. - -Implement with: - -- Next.js App Router route handlers (`app/api/.../route.ts`) using `export const runtime = "nodejs";` -- Node built-ins: - - `fs` / `fs/promises` for filesystem operations - - `path` and `os` for path construction - - `child_process` (`spawnSync` or `execFile`) for running `git init` - -New/updated types: - -- In `src/lib/projects/types.ts`, update: - - export type ProjectTile = { - id: string; - name: string; - agentId: string; - role: "coding" | "research" | "marketing"; - sessionKey: string; - model?: string | null; - thinkingLevel?: string | null; - position: { x: number; y: number }; - size: { width: number; height: number }; - }; - -- In `src/lib/projects/types.ts`, update: - - export type ProjectsStore = { - version: 2; - activeProjectId: string | null; - projects: Project[]; - }; - -Create helper modules (names are suggestions; keep them small and focused): - -- `src/lib/ids/slugify.ts` for turning project names into safe folder names. -- `src/lib/ids/agentId.ts` for generating safe, <=64-char agent IDs. -- `src/lib/fs/git.ts` for `git init` and `.gitignore` management. - -Define these helpers precisely so two different implementers produce the same behavior: - -- `slugifyProjectName(name: string): string`: - - Trim whitespace. - - Lowercase. - - Replace any run of non-`[a-z0-9]` characters with `-`. - - Trim leading/trailing `-`. - - Return the result; if empty, throw an error that the caller converts into HTTP 400. - -- `generateAgentId(params: { projectSlug: string; role: "coding" | "research" | "marketing"; seed: string }): string`: - - Compute `base = "proj-" + projectSlug + "-" + role + "-" + seed`. - - Normalize: - - Lowercase. - - Replace any run of characters not in `[a-z0-9_-]` with `-`. - - Trim leading/trailing `-`. - - If empty, fall back to `"proj-unknown-" + role + "-" + seed` (this is the only acceptable fallback; it prevents crashes if projectSlug is weird). - - Enforce length: - - If longer than 64 chars, truncate from the left side by trimming `projectSlug` first, keeping the suffix `-" + role + "-" + seed` intact, and ensuring the final string is <= 64. - - The `seed` should be 6 chars derived from the tile UUID (for example `tileId.replaceAll("-", "").slice(0, 6)`), so collisions are extremely unlikely without needing a registry. - -For the Git helper, explicitly specify required operations: - -- `ensureGitRepo(dir: string): { warnings: string[] }`: - - Create `dir` recursively if missing. - - If `.git/` does not exist, run `git init` in that directory. - - Ensure `.gitignore` contains these exact lines (append missing): - - .env - .env.* - !.env.example - -Plan change notes: - -- (2026-01-25 18:30Z) Initial ExecPlan drafted based on current code audit. -- (2026-01-25 18:55Z) Updated progress and decision log after implementing Milestones 1–2 changes in code. -- (2026-01-25 19:31Z) Implemented Milestones 3–4 code paths and marked progress accordingly. diff --git a/.agent/EXECPLAN2.md b/.agent/EXECPLAN2.md deleted file mode 100644 index 12b09eb1..00000000 --- a/.agent/EXECPLAN2.md +++ /dev/null @@ -1,199 +0,0 @@ -# Refactor Clawdbot Agent UI to Create-Next-App Best Practices - -This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds. - -Maintain this document in accordance with `.agent/PLANS.md`. - -## Purpose / Big Picture - -After this change, the Clawdbot Agent UI will follow the create-next-app best practices end to end: a `src/`-rooted App Router layout, a feature-first structure, a shared component system using shadcn/ui, validated environment variables, consistent logging helpers, and a proper quality gate stack (lint, typecheck, unit tests, e2e tests, and OpenTelemetry instrumentation). A new contributor should be able to locate UI, domain logic, and shared primitives quickly, and the project should ship with predictable tooling and test coverage that proves the UI still loads and core utilities behave as expected. - -You can see it working by running `npm run dev` and loading the canvas UI as before, then running `npm run lint`, `npm run typecheck`, `npm run test`, and `npm run e2e` to confirm the new quality gates pass. - -## Progress - -- [x] (2026-01-27 00:00Z) Copied the ExecPlan requirements into `.agent/PLANS.md` and drafted this plan based on a repo audit. -- [x] (2026-01-27 19:58Z) Establish the new `src/`-based structure, move files, and update imports to the `@/*` alias. -- [x] (2026-01-27 20:09Z) Add shared infrastructure (shadcn/ui, env validation, logger, http helpers, instrumentation) and update code to use it. -- [x] (2026-01-27 20:13Z) Add quality gates (Prettier integration, Vitest, Playwright) and create initial tests. -- [x] (2026-01-27 20:23Z) Verify the refactor by running lint, typecheck, unit tests, e2e tests, and a dev-server smoke check. - -## Surprises & Discoveries - -- Observation: `tsc --noEmit` initially failed because `.next/types/validator.ts` referenced the old `/app` paths after the move. - Evidence: `Cannot find module '../../app/page.js'` errors until a fresh `next build` regenerated `.next/types`. - -- Observation: Playwright needed browser binaries installed before the e2e smoke test could launch. - Evidence: `browserType.launch: Executable doesn't exist ... Please run: npx playwright install`. - -- Observation: `next dev` failed when `src/app/globals.css` imported markdown styles from `../styles/markdown.css`. - Evidence: `CssSyntaxError: ... Can't resolve '../styles/markdown.css' in '/Users/.../src/app'` until the styles moved under `src/app/styles`. - -## Decision Log - -- Decision: Move the App Router tree from `/app` to `/src/app` and update the `@/*` alias to point at `src/*`. - Rationale: The create-next-app best practices expect a `src/` root and make internal imports consistent across server and client code. - Date/Author: 2026-01-27 / Codex - -- Decision: Introduce `src/features/canvas` for canvas UI and state, while keeping cross-cutting domain logic in `src/lib/*`. - Rationale: The canvas UI is a single feature used by one route, so colocating its components and state improves navigability without overhauling shared domain code. - Date/Author: 2026-01-27 / Codex - -- Decision: Use shadcn/ui with the default “new-york” style and zinc palette, and keep Tailwind v4 as-is. - Rationale: This aligns with the skill defaults and avoids introducing additional styling systems. - Date/Author: 2026-01-27 / Codex - -- Decision: Add minimal unit and e2e tests that exercise existing, stable behavior rather than inventing new UI flows. - Rationale: The goal is to validate the refactor without forcing new product decisions. - Date/Author: 2026-01-27 / Codex - -- Decision: Mock `/api/projects` in the Playwright smoke test to force an empty store response. - Rationale: The UI renders the empty-state copy only when there are no projects; mocking keeps the smoke test deterministic even if a developer has existing workspace data. - Date/Author: 2026-01-27 / Codex - -- Decision: Move markdown styles to `src/app/styles/markdown.css` and import them via `./styles/markdown.css` from `src/app/globals.css`. - Rationale: Turbopack failed to resolve the previous `../styles/markdown.css` import during `next dev`; keeping the file under `src/app` keeps dev builds stable. - Date/Author: 2026-01-27 / Codex - -## Outcomes & Retrospective - -(To be filled in once milestones are complete.) - -## Context and Orientation - -This repository is a Next.js 16 App Router UI with the router currently rooted at `/app` and shared code in `/src`. There is no `src/app` directory yet, imports often use deep relative paths (for example `../src/lib/...`), and there is no testing stack beyond ESLint. There is also no OpenTelemetry instrumentation and no shared UI primitives. - -Key files and directories today: - -The App Router lives in `app/` with `app/layout.tsx`, `app/page.tsx`, `app/globals.css`, and multiple API routes under `app/api/*` (for example `app/api/projects/route.ts` and `app/api/gateway/route.ts`). UI components live in `src/components/` and are tightly coupled to the canvas route. Client state and reducers are in `src/state/store.tsx`. Shared domain logic is already in `src/lib/` (for example `src/lib/gateway/`, `src/lib/projects/`, and `src/lib/clawdbot/`). - -There is an existing ExecPlan at `.agent/EXECPLAN.md` that addresses different functionality. This plan does not depend on it and stands alone. - -## Plan of Work - -First, move the App Router and UI code to a `src/`-rooted structure that matches the create-next-app layout. This includes relocating the `app/` tree to `src/app`, placing canvas-specific UI and state under `src/features/canvas`, and ensuring all internal imports use the `@/*` alias. After the file moves, update `tsconfig.json` and any import paths so the app builds cleanly without deep relative paths. - -Next, add the infrastructure expected by the skill: initialize shadcn/ui, add shared `src/components/ui` primitives, and introduce base `src/lib` helpers for environment validation, logging, HTTP helpers, and tracing. Update the existing code to use these helpers and centralize duplicated logic (notably the gateway config parsing used by `app/api/gateway/route.ts`). Add OpenTelemetry instrumentation via `src/instrumentation.ts` and ensure the service name matches the project. - -Finally, add quality gates and tests. Integrate Prettier into the ESLint flat config, add `typecheck`, `test`, and `e2e` scripts, and configure Vitest and Playwright. Write initial unit tests for stable, pure utilities (for example `slugifyProjectName`) and a lightweight e2e smoke test that verifies the canvas UI loads. Run lint, typecheck, unit tests, e2e tests, and a dev-server smoke check to validate the refactor. - -## Concrete Steps - -Work from the repo root `/Users/georgepickett/clawdbot-agent-ui`. - -1. Move the App Router to `src/app` and establish the feature structure using `git mv` so history is preserved. - Example commands: - git mv app src/app - git mv src/components src/features/canvas/components - git mv src/state src/features/canvas/state - mkdir -p src/components/shared src/components/ui src/features src/hooks src/styles tests/unit tests/e2e - -2. Update import paths to the new locations and to the `@/*` alias. Every import that currently starts with `../src/` or `../../src/` should be replaced with `@/` and point to the new structure. Make sure `src/app/page.tsx`, all API route files under `src/app/api/`, and all moved components and state modules compile cleanly. - -3. Update `tsconfig.json` so the `@/*` alias maps to `./src/*`. Ensure all TypeScript path imports align with the new structure. - -4. Initialize shadcn/ui and add a starter component. Run the CLI with the opinionated answers from the skill: style `new-york`, base color `zinc`, CSS variables `yes`, global CSS `src/app/globals.css`, components alias `@/components`, utils alias `@/lib/utils`, and UI alias `@/components/ui`. Then add the Button component. - Example commands: - npx shadcn@latest init - npx shadcn@latest add button - -5. Add shared `src/lib` helpers and wire them into the existing code. - - Create `src/lib/env.ts` and validate environment variables using Zod. Include optional server variables used in config resolution (`MOLTBOT_STATE_DIR`, `CLAWDBOT_STATE_DIR`, `MOLTBOT_CONFIG_PATH`, `CLAWDBOT_CONFIG_PATH`) and an optional client variable (`NEXT_PUBLIC_GATEWAY_URL`). Use this module in server-side config resolution and in the client hook to default the gateway URL. - - Create `src/lib/logger.ts` as a small wrapper over `console` that exposes `info`, `warn`, `error`, and `debug`, and replace direct `console.*` usage in the gateway client and API routes with the logger to keep logging consistent and easy to adjust. - - Create `src/lib/http.ts` with a `fetchJson(input, init)` helper that throws a useful error when responses are not ok. Update `src/lib/projects/client.ts` (and any other client fetchers) to use this helper. - - Create `src/lib/tracing.ts` as the shared tracing helper, and add `src/instrumentation.ts` that calls `registerOTel` from `@vercel/otel` with `serviceName: "clawdbot-agent-ui"`. - -6. Reduce duplicated gateway config logic by refactoring `src/app/api/gateway/route.ts` to rely on the existing `src/lib/clawdbot/config.ts` helpers (or introduce a small `src/lib/clawdbot/gateway.ts` helper if needed) so there is a single source of truth for state dir/config resolution. - -7. Split the markdown styling into `src/styles/markdown.css` and import it from `src/app/globals.css`, leaving the Tailwind v4 `@import "tailwindcss";` line intact. - -8. Add quality gates and testing configuration. - - Install dependencies (npm is used by this repo): - npm install zod @vercel/otel - npm install -D prettier eslint-config-prettier vitest @testing-library/react @testing-library/jest-dom jsdom @playwright/test - - Update `eslint.config.mjs` to include `eslint-config-prettier/flat` and keep the existing ignores. Update `package.json` scripts to: - lint: eslint . - typecheck: tsc --noEmit - test: vitest - e2e: playwright test - - Create `vitest.config.ts` with a JSDOM environment and a setup file that imports `@testing-library/jest-dom`. Add at least two unit tests in `tests/unit/`: - - - `tests/unit/slugifyProjectName.test.ts` should assert that `slugifyProjectName("My Project")` becomes `"my-project"` and that an all-symbol input throws with the current error message. - - `tests/unit/fetchJson.test.ts` should stub `fetch` and assert that non-ok responses throw and ok responses return parsed JSON. - - Create `playwright.config.ts` with a `webServer` that runs `npm run dev` on port 3000. Add `tests/e2e/canvas-smoke.spec.ts` that loads `/` and asserts the empty-state copy “Create a workspace to begin.” is visible. - -9. Run verification commands and record outputs in the Progress section as you go. - -## Validation and Acceptance - -The refactor is accepted when the app still runs and all quality gates pass. - -Run the following from `/Users/georgepickett/clawdbot-agent-ui`: - -- `npm run lint` and expect no ESLint errors. -- `npm run typecheck` and expect no TypeScript errors. -- `npm run test` and expect all unit tests to pass (the new tests must fail before the implementation and pass after). -- `npm run e2e` and expect the Playwright smoke test to pass. -- `npm run dev`, open `http://localhost:3000`, and confirm the canvas UI loads and the empty-state message appears when no workspace is selected. - -For each milestone, follow the verification workflow: - -1. Tests to write: create the unit tests described above and confirm they fail before the helper implementations or refactors are complete. -2. Implementation: perform the moves, refactors, and helper additions described in the Plan of Work. -3. Verification: re-run the relevant tests and commands until they pass. -4. Commit: after each milestone succeeds, commit the changes with a message like “Milestone 1: Move app to src and update imports”. - -## Idempotence and Recovery - -The file moves can be re-run safely with `git mv` and do not delete data. If a move goes to the wrong location, move it back and re-run the import updates. Dependency installs are safe to re-run; if conflicts occur, remove `node_modules` and run `npm install` again. If any tests or builds fail, revert to the last successful commit and re-apply the current milestone with smaller, verified steps. - -## Artifacts and Notes - -Include short transcripts of any failing test errors or build errors encountered during the refactor in this section so the next contributor can see what broke and why. Keep snippets concise and focused on the error and fix. - -`npm run test` initially failed with: - ReferenceError: expect is not defined - at tests/setup.ts:1 -Fix: swap `@testing-library/jest-dom` import to `@testing-library/jest-dom/vitest` and restrict Vitest to `tests/unit/**`. - -`npm run e2e` initially failed with: - Error: browserType.launch: Executable doesn't exist at .../chromium_headless_shell... -Fix: run `npx playwright install` to fetch browsers. - -`npm run e2e` then failed with: - Error: getByText('Create a workspace to begin.') ... element(s) not found -Fix: mock `/api/projects` in the Playwright test to return an empty store. - -`npm run dev` failed with: - CssSyntaxError: ... Can't resolve '../styles/markdown.css' in '/Users/.../src/app' -Fix: move markdown styles into `src/app/styles/markdown.css` and update the import to `./styles/markdown.css`. - -## Interfaces and Dependencies - -New dependencies to add include `zod` for environment validation, `@vercel/otel` for OpenTelemetry, `prettier` and `eslint-config-prettier` for formatting alignment, `vitest` plus React Testing Library and `jsdom` for unit tests, and `@playwright/test` for e2e testing. The shadcn/ui CLI will add its own dependencies (notably `class-variance-authority`, `tailwind-merge`, `clsx`, and `@radix-ui/react-slot`) when the Button component is installed. - -Define these modules explicitly: - -In `src/lib/env.ts`, export `env` as the parsed result of a Zod schema containing the optional server variables and the optional `NEXT_PUBLIC_GATEWAY_URL` string. - -In `src/lib/logger.ts`, export a `logger` object with `info`, `warn`, `error`, and `debug` methods; each should delegate to the matching `console` method. - -In `src/lib/http.ts`, export `fetchJson(input: RequestInfo | URL, init?: RequestInit): Promise` that throws an `Error` with the response body’s `error` field (if present) or a default message. - -In `src/lib/tracing.ts`, export a `registerTracing()` function that calls `registerOTel` or is invoked by `src/instrumentation.ts`. - -In `src/instrumentation.ts`, export a `register()` function that calls `registerOTel({ serviceName: "clawdbot-agent-ui" })`. - -Plan update note (2026-01-27 19:58Z): Marked milestone 1 complete after moving the App Router to `src/app`, relocating canvas components/state under `src/features/canvas`, and switching imports to the `@/*` alias with the updated TypeScript path mapping. -Plan update note (2026-01-27 20:09Z): Marked milestone 2 complete after adding shadcn/ui, env validation, logger/http/tracing helpers, gateway config reuse, and extracting markdown styles into `src/styles/markdown.css`. -Plan update note (2026-01-27 20:13Z): Marked milestone 3 complete after wiring Prettier into ESLint, adding Vitest + Playwright configs, and creating initial unit and e2e tests. -Plan update note (2026-01-27 20:23Z): Marked milestone 4 complete after running lint/typecheck/tests/e2e, installing Playwright browsers, and stabilizing the smoke test via an `/api/projects` mock. -Plan update note (2026-01-27 20:38Z): Moved markdown styles under `src/app/styles` and updated `globals.css` import to fix `next dev` resolution errors. diff --git a/.agent/EXECPLAN3.md b/.agent/EXECPLAN3.md deleted file mode 100644 index 462f9c0a..00000000 --- a/.agent/EXECPLAN3.md +++ /dev/null @@ -1,212 +0,0 @@ -# Refactor Canvas Zoom/Pan for a Figma-like Agent Workspace - -This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds. - -Maintain this document in accordance with `.agent/PLANS.md` (repository root). - -## Purpose / Big Picture - -After this change, the Agent UI canvas supports a “real canvas” interaction model that feels closer to Figma: zoom is anchored to the cursor (so the point under your mouse stays under your mouse while zooming), trackpad pinch zoom works, mouse wheel can zoom (without breaking trackpad two-finger pan), and canvas panning is smooth and predictable. Managing many agent tiles becomes practical because you can quickly zoom to inspect a tile, zoom out for overview, and use a minimap to navigate large layouts. - -You can see it working by starting the dev server, creating/opening a workspace with multiple agent tiles, then pinch-zooming on a trackpad (the point under the cursor stays pinned), using a mouse wheel to zoom, two-finger scrolling to pan, dragging empty canvas space to pan, and using a minimap overlay to jump around when tiles are spread out. - -## Progress - -- [x] (2026-01-27 00:00Z) Audited current canvas transform/zoom behavior and drafted ExecPlan. -- [x] (2026-01-27) Milestone 1: Add transform math utilities + unit tests that define cursor-anchored zoom behavior. -- [x] (2026-01-27) Milestone 2: Implement wheel/pinch zoom + trackpad pan with rAF throttling; keep existing buttons working. -- [x] (2026-01-27) Milestone 3: Add minimap (overview) and “zoom to fit” navigation helpers for large canvases. -- [x] (2026-01-27) Milestone 4: Add Playwright interaction coverage and polish (performance + edge cases). - -## Surprises & Discoveries - -(To be filled in during implementation.) - -## Decision Log - -- Decision: Implement the interaction model without introducing a third-party canvas/graph library (no React Flow / no D3 zoom). - Rationale: The current UI already has tile layout and drag/resize behavior; the biggest UX gap is transform math + input handling. Keeping it in-house minimizes dependency surface and keeps the behavior easy to tailor. - Date/Author: 2026-01-27 / Codex - -- Decision: Use “zoom to cursor” math and multiplicative zoom deltas (exponential scaling) rather than linear +/- 0.1 steps for wheel/pinch. - Rationale: Cursor-anchored zoom is the core Figma-like affordance; multiplicative zoom feels consistent across zoom levels and matches common canvas UX expectations. - Date/Author: 2026-01-27 / Codex - -- Decision: Default input mapping: trackpad two-finger scroll pans; trackpad pinch (wheel event with ctrlKey) zooms; mouse wheel zooms when it appears to be a wheel (line-based deltas) and otherwise pans. - Rationale: Browsers do not reliably distinguish “mouse wheel” vs “trackpad scroll” in a principled way. This heuristic keeps trackpad pan usable while still enabling mouse wheel zoom without requiring keyboard shortcuts. - Date/Author: 2026-01-27 / Codex - -- Decision: Trackpad pan subtracts wheel deltas from offsets (scroll down reveals lower world coordinates). - Rationale: Matches standard scroll direction expectations while keeping zoom anchor math unchanged. - Date/Author: 2026-01-27 / Codex - -- Decision: `zoomToFit` accepts the current transform to preserve state when there are no tiles. - Rationale: The helper needs a fallback transform; passing it in keeps the helper pure and avoids hidden defaults. - Date/Author: 2026-01-27 / Codex - -## Outcomes & Retrospective - -- Outcome: Canvas interactions now support cursor-anchored zoom (wheel/pinch), smooth pan, minimap navigation, and zoom-to-fit, with shared transform math and coverage in unit/e2e tests. - -## Context and Orientation - -This repo is a Next.js App Router UI. The “canvas” is implemented as a full-screen `CanvasViewport` that renders multiple draggable/resizable “agent tiles”. - -Relevant files include `src/features/canvas/components/CanvasViewport.tsx` (canvas surface + CSS transform), `src/features/canvas/components/AgentTile.tsx` (tile drag/resize; pointer deltas are divided by zoom), `src/features/canvas/components/HeaderBar.tsx` (zoom controls + zoom readout), `src/features/canvas/state/store.tsx` (the `CanvasTransform` state and `setCanvas` reducer action), and `src/app/page.tsx` (wires state to the viewport and header). - -Current behavior (as of 2026-01-27) is that zoom only changes via header +/- buttons and is applied as CSS `translate(offsetX, offsetY) scale(zoom)` with `transformOrigin: "0 0"`. Panning is pointer-drag on empty canvas space only (no trackpad two-finger pan and no wheel support). Zoom is not cursor-anchored; it effectively zooms around the top-left origin of the inner container. - -Definitions used in this plan: “world coordinates” are the coordinate space where tile positions and sizes are stored (tile `{ position: {x,y}, size: {width,height} }` in `AgentTile` data). “viewport/screen coordinates” are CSS pixels relative to the visible canvas viewport element. The transform maps world → screen as `screen = offset + zoom * world`, where `offset` is `{offsetX, offsetY}` in screen pixels. - -This plan also uses a few browser/DOM terms. `requestAnimationFrame` (abbreviated “rAF” below) is a browser API that runs a callback before the next repaint; we use it to coalesce many rapid pointer/wheel events into at most one state update per frame. A “passive” event listener is one that cannot call `preventDefault()`; we must use a non-passive `wheel` listener so we can prevent the browser’s own page zoom behavior during trackpad pinch. A `WheelEvent`’s `deltaMode` describes whether `deltaX/deltaY` are in pixels (typical trackpads) or lines (typical mouse wheels). - -## Plan of Work - -This change is mostly about (1) getting transform math correct and testable, and (2) implementing input handling that feels intentional and smooth under real device behavior (mouse wheels, trackpads, pinch gestures). The work proceeds in small steps so that the core math is locked down with tests before wiring up user input. - -Milestone 1 creates a small, pure “canvas transform math” module and unit tests that specify cursor-anchored zoom and viewport/world conversions. This is the foundation; everything else builds on it. - -Milestone 2 wires wheel/pinch/pan into `CanvasViewport` using non-passive event listeners (so we can `preventDefault()` to stop browser-page zoom during pinch) and rAF throttling (so transform updates do not cause jank). Existing header zoom buttons are updated to use the same transform math (zooming around viewport center). - -Milestone 3 adds an overview minimap that visualizes tile bounds and the current viewport rectangle, plus a “zoom to fit” action that frames all tiles with padding. This is the “manage many tiles” accelerator. - -Milestone 4 adds Playwright coverage for the interaction contract and cleans up edge cases (clamping, empty canvas, huge deltas, selection behavior), keeping the implementation simple but robust. - -## Concrete Steps - -All commands below run from: - - /Users/georgepickett/clawdbot-agent-ui - -### Milestone 1: Transform Math + Unit Tests - -Acceptance for this milestone is that a pure function can compute a new `CanvasTransform` that zooms at a given viewport point (cursor-anchored), preserving the world point under the cursor across zoom changes, and that conversions between screen and world coordinates are correct and covered by unit tests. - -1. Create `src/features/canvas/lib/transform.ts` (new). - - Implement these exported functions (keep them pure and small). `clampZoom(zoom: number): number` clamps to a chosen range (decide in this milestone; the tests encode the final decision). `screenToWorld(transform, screen)` computes `{ (screen.x - offsetX) / zoom, (screen.y - offsetY) / zoom }` and `worldToScreen(transform, world)` computes `{ offsetX + world.x * zoom, offsetY + world.y * zoom }`. `zoomAtScreenPoint(transform, nextZoomRaw, screenPoint)` computes the world point under `screenPoint` using `screenToWorld`, clamps `nextZoomRaw`, then sets offsets so the same world point maps back to `screenPoint` at the clamped zoom via `nextOffsetX = screenPoint.x - world.x * nextZoom` and `nextOffsetY = screenPoint.y - world.y * nextZoom`, returning `{ zoom: nextZoom, offsetX: nextOffsetX, offsetY: nextOffsetY }`. - - Keep `CanvasTransform` imported from `src/features/canvas/state/store.tsx` (do not redefine the type). Choose a zoom clamp range appropriate for reading tile content; a reasonable starting point is `minZoom=0.25` and `maxZoom=3.0`, but the tests should encode the final decision. - -2. Add unit tests in `tests/unit/canvasTransform.test.ts` (new). - - Write these tests first and confirm they fail until you implement the functions. Add a round-trip test that asserts `worldToScreen` then `screenToWorld` are inverses within float tolerance (for example using `{ zoom: 1.5, offsetX: 120, offsetY: -80 }`). Add a cursor-anchored zoom test that asserts `zoomAtScreenPoint` preserves the world point under the cursor within tolerance. Add a clamp test that asserts values below/above min/max are clamped appropriately. - -3. Run: - - npm run test -- tests/unit/canvasTransform.test.ts - - Expect all new tests to pass. - -4. Commit: - - git add -A - git commit -m "Milestone 1: Add cursor-anchored canvas transform math" - -### Milestone 2: Wheel/Pinch Zoom + Trackpad Pan (Smooth) - -Acceptance for this milestone is that trackpad pinch zoom works (browser page zoom does not trigger while the cursor is over the canvas), mouse wheel zoom works, trackpad two-finger scroll pans the canvas (so you can navigate without drag), dragging empty canvas space still pans (existing behavior), and panning/zooming feels smooth (no stutter from excessive state updates). - -1. Update `src/features/canvas/components/CanvasViewport.tsx` to handle wheel and pinch. - - Attach a native `wheel` event listener to the viewport element with `{ passive: false }` so `preventDefault()` reliably works. On wheel events, compute `screenPoint` relative to the viewport element (use `getBoundingClientRect()` and `event.clientX/Y`). Decide whether the wheel event is zoom vs pan: treat `event.ctrlKey === true` as zoom (common trackpad-pinch signal), otherwise treat line-based deltas (`event.deltaMode`) as zoom (typical mouse wheel), and treat remaining pixel-based deltas as pan (typical trackpad scroll). When zooming, use multiplicative scaling (for example `nextZoom = transform.zoom * Math.exp(-event.deltaY * ZOOM_SENSITIVITY)`) and apply `zoomAtScreenPoint(transform, nextZoom, screenPoint)` from Milestone 1. When panning, update offsets by wheel deltas; verify the sign feels like direct manipulation and record the final choice in the Decision Log. - - For smoothness, do not call `onUpdateTransform` on every raw wheel/pointer event. Throttle to animation frames by storing a pending transform update in a ref and scheduling a single `requestAnimationFrame` to apply the latest pending transform. Keep the existing pointer-drag pan, but apply the same rAF throttling to pointermove updates so panning stays smooth with many tiles. - -2. Update `src/app/page.tsx` zoom handlers to use the new math and feel consistent. - - Replace linear `zoom +/- 0.1` with multiplicative steps (for example `zoom *= 1.1` and `zoom /= 1.1`) using the same clamp. Anchor button-based zoom to the viewport center (not cursor) for predictability; ensure the handler computes a viewport-center screen point and calls `zoomAtScreenPoint` rather than directly patching zoom. Avoid creating a second transform path: all zoom changes (wheel/pinch/buttons) should go through the same math utility so behavior stays consistent. - -3. Manually verify this behavior and record brief observations under `Artifacts and Notes`. Start the dev server (`npm run dev`), open the app, create/open a workspace, create several agent tiles, then pinch-zoom with a trackpad (verify browser page zoom does not happen while over the canvas and the point under the cursor remains pinned), two-finger scroll (verify pan), and mouse wheel (verify zoom, using a mouse rather than a trackpad). - -4. Add or update Playwright coverage (keep it stable/deterministic). - - Extend `tests/e2e/canvas-smoke.spec.ts` or add `tests/e2e/canvas-zoom-pan.spec.ts` (preferred) by mocking `/api/projects` to return one workspace with one tile positioned away from the origin. - - Add one test that dispatches wheel events over the canvas surface and asserts the zoom percentage text changes. Add a second test that dispatches a trackpad-like wheel (pixel deltas, no ctrlKey) and verifies the tile’s screen position changes by comparing `boundingBox()` before and after; `boundingBox()` is Playwright’s API for reading an element’s rendered rectangle in CSS pixels. If Playwright wheel synthesis cannot reliably reproduce the intended wheel characteristics (especially `deltaMode`), focus on the zoom readout plus a clear bounding box change and document the limitation in `Artifacts and Notes`. If needed, add a stable `data-*` attribute on the canvas viewport element to query it reliably (for example `data-canvas-viewport`). - -5. Run: - - npm run test - npm run e2e - -6. Commit: - - git add -A - git commit -m "Milestone 2: Add wheel/pinch zoom and smooth pan" - -### Milestone 3: Minimap + Zoom to Fit - -Acceptance for this milestone is that a minimap appears when there is at least one tile, it shows tile rectangles and the current viewport rectangle, clicking (or dragging) in the minimap recenters the viewport to that location, and a “Zoom to Fit” action frames all tiles with padding. - -1. Add `src/features/canvas/components/CanvasMinimap.tsx` (new). - - Keep the minimap simple and SVG-based. It takes `tiles: AgentTile[]`, `transform: CanvasTransform`, `viewportSize: { width: number; height: number }` (measured from `CanvasViewport` via a `ResizeObserver`, a browser API that notifies you when an element’s size changes), and `onUpdateTransform(patch: Partial): void`. It computes the world bounds of all tiles using tile position and size, adds padding in world units, then computes the current viewport world rectangle using the transform (`worldLeft = -offsetX / zoom`, `worldTop = -offsetY / zoom`, `worldWidth = viewportWidth / zoom`, `worldHeight = viewportHeight / zoom`). Render an SVG with a `viewBox` matching the content bounds; `viewBox` is the SVG coordinate system used to map world units into the minimap panel. Draw tile rects and the viewport rect. On click/drag in the minimap, convert minimap coordinates back to a world point and update offsets so that world point becomes the viewport center. - -2. Wire minimap into `src/app/page.tsx` (or into `CanvasViewport` as an overlay). - - Place it as a floating panel in a corner (for example bottom-right) with `pointer-events-auto` and a small footprint so it doesn’t interfere with tiles. - -3. Add “Zoom to Fit” button to `src/features/canvas/components/HeaderBar.tsx`. - - Implement a helper in `src/features/canvas/lib/transform.ts` named `zoomToFit(tiles: AgentTile[], viewportSize: { width: number; height: number }, paddingPx: number): CanvasTransform`. If there are no tiles it should return the current transform unchanged. Otherwise it should compute a zoom that fits all tile bounds within the viewport (minus padding), clamp that zoom, and compute offsets so the bounds are centered. - -4. Tests: - - Add `tests/unit/canvasZoomToFit.test.ts` (new) to assert that `zoomToFit` produces a transform where the fitted bounds map within the viewport with padding. - -5. Run: - - npm run test - npm run e2e - -6. Commit: - - git add -A - git commit -m "Milestone 3: Add minimap and zoom-to-fit" - -### Milestone 4: Polish + Edge Cases - -Acceptance for this milestone is that there are no regressions in tile drag/resize under zoom, canvas interactions remain responsive with many tiles, and edge cases are handled without mystery behavior (empty canvas, extreme wheel deltas, zoom clamping). - -1. Verify tile interactions under zoom by dragging a tile while zoomed in and out (movement tracks cursor correctly) and resizing a tile while zoomed in and out (size changes are proportional and clamped). - -2. Keep performance guardrails in place: ensure wheel/pan uses rAF throttling (no synchronous state update loops), add `will-change: transform;` to the scaled inner container if needed, and consider adding `overscroll-behavior: none;` and `touch-action: none;` to the canvas surface so browser scroll/zoom gestures do not fight the canvas. - -3. Expand Playwright tests only as far as they remain deterministic. - -4. Run final verification: - - npm run lint - npm run typecheck - npm run test - npm run e2e - -5. Commit: - - git add -A - git commit -m "Milestone 4: Polish canvas interactions and add coverage" - -## Validation and Acceptance - -This work is accepted when, on a developer machine, the canvas supports cursor-anchored zoom (pinch or wheel) and the world point under the cursor remains stable while zooming; trackpad two-finger scroll pans (not zoom) while mouse wheel zoom works; existing header zoom controls still work and feel consistent with wheel/pinch zoom; a minimap provides an overview and navigation for canvases with many tiles; and `npm run test` and `npm run e2e` pass, with the new unit tests failing before implementation and passing after. - -## Idempotence and Recovery - -The transform refactor is safe to apply incrementally because it is additive first (pure math module + tests), then wiring changes. If input handling becomes confusing or flaky, revert to the last milestone commit and re-apply one interaction at a time (wheel zoom first, then trackpad pan, then minimap). Keep the old header zoom buttons functional throughout so the UI remains usable even while iterating. - -## Artifacts and Notes - -Record short evidence snippets here during implementation, such as unit test failure output that guided a math fix, a brief note on the final chosen zoom clamp range and why, and any device-specific observations (for example “Chrome pinch zoom sets ctrlKey=true on wheel events on macOS”). - -- 2026-01-27: Added transform math utils + tests. Clamp range set to 0.25–3.0 to keep tiles readable while allowing overview. `npm run test -- tests/unit/canvasTransform.test.ts` passes. -- 2026-01-27: Added wheel/pinch zoom + rAF throttling, updated header zoom anchoring, and added Playwright coverage. Playwright wheel events always surfaced as line-based deltas, so trackpad-pan simulation was unreliable; tests cover zoom readout changes and tile bounds updates instead. `npm run test` and `npm run e2e` pass. -- 2026-01-27: Added SVG minimap + zoom-to-fit helper/button, plus `zoomToFit` unit tests. `npm run test` and `npm run e2e` pass. -- 2026-01-27: Added overscroll/touch-action guardrails and `will-change: transform` on the scaled canvas content; lint/typecheck/test/e2e pass. -- 2026-01-27: Switched canvas scaling to use CSS `zoom` when supported (fallback to transform scale) to keep zoomed text crisp. - -## Interfaces and Dependencies - -No new third-party dependencies are required for this plan. The core interfaces are `CanvasTransform` from `src/features/canvas/state/store.tsx`, new pure transform helpers in `src/features/canvas/lib/transform.ts`, and a minimap component in `src/features/canvas/components/CanvasMinimap.tsx`. The implementation must keep a single source of truth for transform math by routing all zoom changes (wheel/pinch/buttons/zoom-to-fit) through `src/features/canvas/lib/transform.ts`. - -Plan creation note (2026-01-27 00:00Z): Created this ExecPlan after auditing current zoom/pan behavior in `CanvasViewport.tsx`, `AgentTile.tsx`, and `page.tsx`, and after researching common canvas UX conventions (cursor-anchored zoom, pinch zoom, scroll-to-pan, minimap + zoom-to-fit) to keep the plan self-contained and beginner-executable. diff --git a/.agent/EXECPLAN4.md b/.agent/EXECPLAN4.md deleted file mode 100644 index ea424216..00000000 --- a/.agent/EXECPLAN4.md +++ /dev/null @@ -1,174 +0,0 @@ -# Show Thinking Traces in Agent Chat Tiles - -This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds. - -Maintain this document in accordance with `.agent/PLANS.md` (repository root). - -## Purpose / Big Picture - -After this change, the Agent Canvas UI surfaces the model's "thinking" blocks alongside assistant replies. When a chat run streams, the tile shows a live thinking trace; when the run completes or when history is loaded, the thinking trace is preserved in the output timeline so the user can read it later. This makes the UI match the session log format (for example the `content[]` items with `type: "thinking"` in `~/.clawdbot/agents/.../sessions/*.jsonl`) and removes the current gap where reasoning is either hidden or truncated. - -You can see it working by starting the dev server, sending a message to an agent with thinking enabled, and watching the tile output show a "Thinking" block before the assistant response. Reloading the page should still show those thinking blocks in history for that session. - -## Progress - -- [x] (2026-01-27 23:46Z) Milestone 1: Added `src/lib/text/extractThinking.ts` with extraction/formatting helpers and `tests/unit/extractThinking.test.ts`; unit tests pass. -- [ ] (2026-01-27 23:55Z) Milestone 2: Wired thinking traces into live streaming, history loading, and tile rendering; traces collapse after completion with a toggleable summary; README updated (manual UI verification still pending). - -## Surprises & Discoveries - -- Observation: No surprises encountered while parsing thinking blocks or wiring UI rendering. - Evidence: `npm run test -- tests/unit/extractThinking.test.ts` passed on first run. - -## Decision Log - -- Decision: Treat "thinking traces" as first-class output lines, rendered before the assistant reply and persisted in history. - Rationale: The session logs include explicit thinking blocks; representing them as output lines preserves them after streaming ends and keeps the UI timeline coherent. - Date/Author: 2026-01-27 / Codex - -- Decision: Parse thinking from `message.content[]` items with `type: "thinking"`, with `...` tag parsing as back-compat. - Rationale: The provided session log uses `content[]` with `type: "thinking"`, but older logs can embed thinking tags; supporting both matches existing Clawdbot UI behavior. - Date/Author: 2026-01-27 / Codex - -- Decision: Render live thinking with the raw markdown content, but store completed thinking as a formatted trace block in output history. - Rationale: Live thinking should reflect the model output as-is, while history benefits from consistent styling and collapsible traces. - Date/Author: 2026-01-27 / Codex - -- Decision: Represent completed traces as a prefixed markdown string and render them as collapsed `
` blocks with a "Trace" summary. - Rationale: Output lines remain simple strings for persistence, while the UI can detect and render a toggleable collapsed view without adding a new data model. - Date/Author: 2026-01-27 / Codex - -## Outcomes & Retrospective - -- Outcome: Thinking extraction helpers and unit tests added; UI now surfaces thinking during streaming and persists it in history. Manual end-to-end verification remains to confirm runtime behavior. - -## Context and Orientation - -This repo is a Next.js App Router UI for the Clawdbot agent canvas. Chat traffic arrives over the gateway WebSocket and is handled in `src/app/page.tsx`, which updates tile state stored in `src/features/canvas/state/store.tsx`. Tiles render their outputs in `src/features/canvas/components/AgentTile.tsx` using `ReactMarkdown` and the `agent-markdown` styles in `src/app/styles/markdown.css`. - -Relevant paths for this change: -- `src/app/page.tsx`: Gateway event handlers for `chat` and `agent`, history loading (`chat.history`), and the current thinking trace extraction helpers. -- `src/features/canvas/components/AgentTile.tsx`: Renders the tile output area and the inline "thinking" block. -- `src/lib/text/extractText.ts`: Extracts assistant/user text and strips `` tags from assistant responses. -- `tests/unit/*`: Vitest unit tests; new parsing utilities should be tested here. - -The provided session log at `/Users/georgepickett/.clawdbot/agents/proj-clawdbot-agent-ui-agent-5aab/sessions/b9773235-f5b1-46b4-8eb6-86bbd312828b.jsonl` shows the exact thinking payload shape: assistant `message.content[]` includes `{ type: "thinking", thinking: "...", thinkingSignature: "..." }` ahead of tool calls and text. - -## Plan of Work - -The work has two milestones. First, create a small parsing utility that can extract thinking text from the same message shapes found in the session log and format it for display, backed by unit tests. Second, wire that utility into the live chat stream handler and the history loader so thinking traces show during streaming and remain visible in the output timeline after completion. The tile rendering should display the live thinking trace as markdown (not just a truncated first line) and the output timeline should include formatted thinking blocks before assistant replies. Update the README with a short note about thinking traces so the behavior is documented. - -## Concrete Steps - -All commands below run from: - - /Users/georgepickett/clawdbot-agent-ui - -### Milestone 1: Thinking extraction + tests - -Acceptance for this milestone is that we can extract thinking text from the message shapes in the session log (content arrays with `type: "thinking"`) and from embedded `` tags, and that the behavior is covered by unit tests. - -1. Add a new helper in `src/lib/text/extractThinking.ts`. - - Implement and export: - - `extractThinking(message: unknown): string | null` -- returns concatenated thinking text when `message.content` is an array of `{ type: "thinking", thinking: string }` entries, or when raw text contains `...` tags. Return `null` for empty/whitespace-only results. - - `formatThinkingMarkdown(text: string): string` -- returns a markdown block that visually separates thinking from normal output (for example: a "Thinking:" label followed by italicized non-empty lines). Keep it deterministic so tests can assert exact output. - - The helper should not mutate inputs and should not rely on DOM APIs. - -2. Add unit tests in `tests/unit/extractThinking.test.ts` (new). - - Write tests first and confirm they fail before implementation. Cover these cases: - - Extracts a single thinking block from `content[]` and returns trimmed text. - - Extracts multiple thinking blocks and joins them with `\n` in order. - - Extracts thinking from a string containing `...` tags. - - Returns `null` when no thinking exists or when the thinking text is only whitespace. - - `formatThinkingMarkdown` produces the expected labeled/italicized markdown for multi-line thinking. - -3. Run: - - npm run test -- tests/unit/extractThinking.test.ts - - Expect the new tests to pass. - -4. Commit: - - git add -A - git commit -m "Milestone 1: Add thinking extraction helpers" - -### Milestone 2: UI wiring + history + docs - -Acceptance for this milestone is that thinking traces are visible while a run streams, persisted in the output timeline after completion, and included when loading chat history. The output should show the thinking block before the assistant reply. - -1. Update `src/app/page.tsx` to use the new helpers. - - - Replace `formatThinkingTrace`/`extractThinkingTrace` with calls to `extractThinking` and `formatThinkingMarkdown`. - - In the chat event handler (`event.event === "chat"`): - - When `payload.state === "delta"`, if a thinking block is present, set `tile.thinkingTrace` to the raw thinking text (not truncated). Keep the tile status as `running`. - - When `payload.state === "final"`, extract thinking from the final message (or use any pending `tile.thinkingTrace`), format it with `formatThinkingMarkdown`, and `appendOutput` it before appending the assistant's final text. Then clear `thinkingTrace` and `streamText` as today. - - In `buildHistoryLines`, for each assistant message, extract thinking and insert the formatted thinking markdown line before the assistant response line in the returned `lines` array. Keep user lines unchanged. - -2. Update `src/features/canvas/components/AgentTile.tsx` to render live thinking as markdown. - - - Render the `thinkingTrace` block using `ReactMarkdown` so multi-line thinking and markdown formatting appear correctly. - - Keep the existing visual styling (amber block) but remove truncation logic; the content should be the full thinking trace as sent by the model. - -3. Update `README.md` with a short section explaining that thinking traces are displayed when the model sends `content[]` entries of type `thinking` or `` blocks, and that the thinking level selector controls whether those traces appear. - -4. Manual verification: - - - Run `npm run dev`. - - Open the UI, select an agent tile, set thinking to `low` or `medium`, and send a short message. - - Confirm that while the run is streaming, a "thinking" block appears in the tile output, and once the response completes the thinking block remains in the output history above the assistant reply. - - Reload the page and confirm the thinking block persists via history loading. - -5. Commit: - - git add -A - git commit -m "Milestone 2: Surface thinking traces in chat tiles" - -## Validation and Acceptance - -Run unit tests and verify an end-to-end chat: - -- Unit tests: `npm run test -- tests/unit/extractThinking.test.ts` should pass. -- Manual UI check: start the dev server and confirm thinking blocks appear live and persist after completion and reload. - -The change is accepted when an agent run shows the thinking trace as a distinct block before the assistant message, and history reloads show the same thinking traces from `chat.history`. - -## Idempotence and Recovery - -All steps are safe to rerun. If a test or manual check fails, revert the latest commit, adjust the helper or UI wiring, and rerun the same commands. No persistent data migrations are required; only UI rendering changes and parsing utilities are added. - -## Artifacts and Notes - -- Example thinking payload (from session log): - - { "type": "thinking", "thinking": "**Running initial repo listing**", "thinkingSignature": "..." } - -- Expected output ordering in a tile after completion: - - _Running initial repo listing_ - - - -- Test run: - - npm run test -- tests/unit/extractThinking.test.ts - PASS tests/unit/extractThinking.test.ts (6 tests) - -Plan update note: 2026-01-27 -- added wheel handling so selected tile output scrolls without page scroll; changed completed traces to render as a single collapsible "Thinking" block and updated README wording. - -## Interfaces and Dependencies - -- `src/lib/text/extractThinking.ts` - - `extractThinking(message: unknown): string | null` - - `formatThinkingMarkdown(text: string): string` - -- `src/app/page.tsx` - - Use `extractThinking` and `formatThinkingMarkdown` in `buildHistoryLines` and chat event handling. - -- `src/features/canvas/components/AgentTile.tsx` - - Render `thinkingTrace` via `ReactMarkdown` inside the existing styled block. - -No new external dependencies are required. diff --git a/.agent/EXECPLAN5.md b/.agent/EXECPLAN5.md deleted file mode 100644 index 7196a09b..00000000 --- a/.agent/EXECPLAN5.md +++ /dev/null @@ -1,97 +0,0 @@ -# Replace the custom canvas with the ReactFlow-based canvas used in crabwalk - -This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds. - -This plan is governed by `/Users/georgepickett/clawdbot-agent-ui/.agent/PLANS.md` and must be maintained in accordance with it. - -## Purpose / Big Picture - -The current canvas pan/zoom behavior feels inconsistent and “buggy” because it is custom, uses CSS zoom, and depends on device-specific wheel semantics. After this change, the canvas will behave like the crabwalk monitor canvas: smooth, predictable pan/zoom, reliable zoom-to-fit, and consistent minimap/controls interactions. A user will be able to zoom with the mouse wheel, drag the background to pan, resize tiles cleanly, and see the zoom readout update accurately. This can be verified by running the app, interacting with the canvas, and running the updated Playwright tests that simulate wheel zoom, drag pan, and tile resize. - -## Progress - -- [x] (2026-01-27 00:00Z) Drafted ExecPlan based on the crabwalk ReactFlow implementation. -- [x] (2026-01-28 00:00Z) Replace the custom canvas with ReactFlow and keep zoom readout, tile drag, and tile resize working. -- [x] (2026-01-28 00:00Z) Update automated tests to match the new canvas behavior and verify zoom, pan, and resize. - -## Surprises & Discoveries - -- Observation: crabwalk’s monitor canvas uses `@xyflow/react` (ReactFlow) with `Controls`, `MiniMap`, and `Background`, and relies on its built-in pan/zoom behavior rather than custom math. The package exports `NodeResizer`, which we can use for resize handles inside custom nodes. - Evidence: `/Users/georgepickett/crabwalk/src/components/monitor/ActionGraph.tsx` plus `/Users/georgepickett/crabwalk/node_modules/@xyflow/react/dist/esm/additional-components/NodeResizer/types.d.ts`. -- Observation: ReactFlow uses a named `ReactFlow` export (no default), and forcing selection via node props can cause update loops; letting ReactFlow own selection and syncing via callbacks avoided the runtime error. - Evidence: `/Users/georgepickett/clawdbot-agent-ui/src/features/canvas/components/CanvasFlow.tsx` runtime error overlay during early test runs. - -## Decision Log - -- Decision: Use `@xyflow/react` (same library and defaults as crabwalk) for the canvas engine, and wire our tiles into custom ReactFlow nodes with `NodeResizer` for resizing. - Rationale: This directly copies crabwalk’s proven pan/zoom behavior and removes our custom CSS-zoom transform path, which is the likely source of inconsistent UX. - Date/Author: 2026-01-27, Codex. - -## Outcomes & Retrospective - -ReactFlow now drives the canvas with built-in pan/zoom, minimap, and controls; tile drag and resize update project state, and the header zoom readout stays in sync. The e2e suite now covers wheel zoom, background pan, and NodeResizer-based resizing on the new canvas. - -## Context and Orientation - -The current canvas lives in `/Users/georgepickett/clawdbot-agent-ui/src/app/page.tsx`, with the rendering handled by `/Users/georgepickett/clawdbot-agent-ui/src/features/canvas/components/CanvasViewport.tsx` and `/Users/georgepickett/clawdbot-agent-ui/src/features/canvas/components/CanvasMinimap.tsx`. The transform math lives in `/Users/georgepickett/clawdbot-agent-ui/src/features/canvas/lib/transform.ts`, and the tile UI is in `/Users/georgepickett/clawdbot-agent-ui/src/features/canvas/components/AgentTile.tsx`. The canvas zoom/offset state is stored in `/Users/georgepickett/clawdbot-agent-ui/src/features/canvas/state/store.tsx` as `CanvasTransform` with `zoom`, `offsetX`, and `offsetY`. The crabwalk canvas we want to copy is implemented with ReactFlow in `/Users/georgepickett/crabwalk/src/components/monitor/ActionGraph.tsx` using `ReactFlow`, `Controls`, `MiniMap`, and `Background`, with pan/zoom handled by the library and no custom CSS zoom logic. - -In this repo, tile positions and sizes are stored in the project state and persisted via `/Users/georgepickett/clawdbot-agent-ui/src/lib/projects/client`. Any new canvas implementation must continue to update tile position and size in state so existing persistence and UI flows remain intact. The zoom readout in the header (`/Users/georgepickett/clawdbot-agent-ui/src/features/canvas/components/HeaderBar.tsx`) is currently derived from `state.canvas.zoom`, so the new canvas must keep that value updated as the user pans and zooms. - -## Plan of Work - -First, add ReactFlow (`@xyflow/react`) to the UI dependencies and import its base styles so the canvas engine renders and handles pointer interactions correctly. Next, replace the custom `CanvasViewport` and `CanvasMinimap` with a new `CanvasFlow` component that wraps a `ReactFlow` instance, matching crabwalk’s configuration (`Controls`, `MiniMap`, `Background`, `fitView`, `minZoom`, `maxZoom`). Then adapt the tile UI to render as a custom ReactFlow node: remove the absolute positioning from `AgentTile` and instead drive width and height via the ReactFlow node style, while adding a `NodeResizer` to keep tile resizing functional. Wire node drag and resize updates to dispatch tile position/size updates into the canvas store. Finally, update the zoom controls and readout to use the ReactFlow viewport state (x, y, zoom), and update or add Playwright tests to verify wheel zoom, drag pan, and resize behavior on the new canvas. - -## Concrete Steps - -Work in `/Users/georgepickett/clawdbot-agent-ui`. - -1) Add ReactFlow as a dependency and include its stylesheet. Update `package.json` to include `@xyflow/react` (use the same major version as crabwalk, `^12.10.0`), then add an import of `@xyflow/react/dist/style.css` in a global entry such as `/Users/georgepickett/clawdbot-agent-ui/src/app/globals.css` or a new canvas component that is guaranteed to be loaded on the client. - -2) Create a new canvas component, for example `/Users/georgepickett/clawdbot-agent-ui/src/features/canvas/components/CanvasFlow.tsx`, that wraps `ReactFlowProvider` and `ReactFlow` and exposes the same outward props as the current `CanvasViewport`, plus a new `onInit` callback to pass the `ReactFlowInstance` back up to `/Users/georgepickett/clawdbot-agent-ui/src/app/page.tsx`. Use `nodeTypes` to register a custom node component (see next step). Configure `ReactFlow` similarly to crabwalk: `fitView`, `fitViewOptions={{ padding: 0.2 }}`, `minZoom={0.1}`, `maxZoom={2}`, and include `Background`, `Controls`, and `MiniMap` so the core interaction model matches crabwalk. - -3) Add a custom node component for tiles, for example `/Users/georgepickett/clawdbot-agent-ui/src/features/canvas/components/AgentTileNode.tsx`. This component should render the existing `AgentTile` UI, but without absolute left/top positioning. Use the `NodeResizer` from `@xyflow/react` inside this component to provide resize handles. Configure `NodeResizer` with `minWidth` and `minHeight` matching the existing `MIN_SIZE` (560 x 440), and wire `onResizeEnd` to dispatch a tile size update into the store using the new width and height provided by `NodeResizer`. Ensure the root element still includes `data-tile` so existing tests can find it. - -4) Refactor `/Users/georgepickett/clawdbot-agent-ui/src/features/canvas/components/AgentTile.tsx` so it no longer sets `left` and `top` inline styles. It should instead rely on the ReactFlow node wrapper for position and only set `width` and `height` based on `tile.size`. Remove the custom drag handlers (`handleDragStart`) so ReactFlow is the only drag system, and add a stable drag handle element on the tile header (for example by adding a `data-drag-handle` attribute) so ReactFlow can be configured to drag only from the header without interfering with text inputs. - -5) Replace usage of `CanvasViewport` and `CanvasMinimap` in `/Users/georgepickett/clawdbot-agent-ui/src/app/page.tsx` with the new `CanvasFlow` component. Keep the existing `viewportRef` for size measurement but wire `CanvasFlow` to expose the ReactFlow instance via `onInit`. Update the zoom handlers (`handleZoomIn`, `handleZoomOut`, `handleZoomReset`, `handleZoomToFit`) to call ReactFlow’s `zoomIn`, `zoomOut`, `setViewport`, and `fitView` respectively, and then update the canvas store’s `zoom`, `offsetX`, and `offsetY` using `useOnViewportChange` (or `onMove`) so the header readout remains accurate. - -6) Update `CanvasFlow` to keep store state and ReactFlow in sync. Use `onNodesChange` (and/or `onNodeDragStop`) to dispatch tile position updates when a node finishes dragging, `onNodeClick` or `onSelectionChange` to update the selected tile, and `onPaneClick` to clear selection. Use `onMove` or `useOnViewportChange` to call `onUpdateTransform({ zoom, offsetX: x, offsetY: y })` whenever the viewport changes, so `state.canvas` continues to drive readouts and placement calculations. - -7) Update or add Playwright tests to reflect the new canvas behavior. The existing tests in `/Users/georgepickett/clawdbot-agent-ui/tests/e2e/canvas-zoom-pan.spec.ts` should continue to verify that a wheel event changes the zoom readout and affects tile bounds, but adjust event payloads or selectors if ReactFlow introduces different DOM structure. Add a new test that drags the canvas background to pan and asserts that a tile’s bounding box moves relative to the viewport. Add a resize test that drags a NodeResizer handle and asserts the tile bounding box width/height changes. Keep these tests focused on user-visible behavior rather than implementation details. - -## Validation and Acceptance - -Acceptance is met when the canvas behaves like crabwalk’s: wheel zoom is smooth and consistent, drag-to-pan works across devices, tiles drag and resize without jitter, the minimap and controls are functional, and the zoom readout updates as the viewport changes. - -For each milestone, follow this verification workflow. - -Milestone 1: ReactFlow canvas with draggable tiles and viewport syncing. - -1. Tests to write: Update `/Users/georgepickett/clawdbot-agent-ui/tests/e2e/canvas-zoom-pan.spec.ts` to assert the zoom readout changes after a wheel event on the canvas root, and add a new test `pan-drag-shifts-tiles` in the same file that drags the canvas background and verifies the tile’s bounding box moves relative to its previous position. -2. Implementation: Add `@xyflow/react`, create `CanvasFlow`, hook it into `page.tsx`, and refactor `AgentTile` to be usable in a ReactFlow node. Use `onMove` (or `useOnViewportChange`) to keep `state.canvas` in sync. -3. Verification: Run `npm run e2e -- tests/e2e/canvas-zoom-pan.spec.ts` and confirm the new tests fail before changes and pass after the ReactFlow integration is complete. -4. Commit: After tests pass, commit with the message `Milestone 1: ReactFlow canvas and viewport sync`. - -Milestone 2: Tile resizing via NodeResizer and persistence. - -1. Tests to write: Add `resize-handle-updates-tile-size` to `/Users/georgepickett/clawdbot-agent-ui/tests/e2e/canvas-zoom-pan.spec.ts` (or a new `canvas-resize.spec.ts`) that drags a resize handle on a tile and asserts its bounding box width and height change by at least a small threshold. -2. Implementation: Add `AgentTileNode` with `NodeResizer`, wire `onResizeEnd` to update tile size in the store, and ensure tile size persists on re-render by mapping node width/height from `tile.size`. -3. Verification: Run `npm run e2e -- tests/e2e/canvas-zoom-pan.spec.ts` (or the new file) and confirm the resize test fails before the change and passes after. -4. Commit: After tests pass, commit with the message `Milestone 2: Tile resizing with NodeResizer`. - -## Idempotence and Recovery - -These steps are safe to run multiple times because dependency changes are additive and all code edits are deterministic. If ReactFlow integration causes regressions, revert to the previous commit boundary at the end of each milestone, or temporarily re-enable `CanvasViewport` by restoring its usage in `page.tsx`. If a test becomes flaky due to interaction timing, increase Playwright’s drag step delays or wait conditions rather than disabling the test. - -## Artifacts and Notes - -When validating, capture short command outputs in this section (for example, the final lines of `npm run e2e` showing the pass/fail summary). Keep any terminal transcripts short and focused on confirming success. - -- `npm run e2e -- tests/e2e/canvas-zoom-pan.spec.ts` - - 4 passed (4.1s) - -## Interfaces and Dependencies - -Use `@xyflow/react` for the canvas engine, including `ReactFlow`, `ReactFlowProvider`, `Controls`, `MiniMap`, `Background`, `NodeResizer`, and `useOnViewportChange` or `onMove` events to observe viewport changes. The custom node component (`AgentTileNode`) should accept `data` containing the `AgentTile` plus callbacks for selection, move, resize, rename, and send actions. The `CanvasFlow` component should accept `tiles`, `selectedTileId`, and callback props mirroring the current `CanvasViewport` API, and should expose a `ReactFlowInstance` through `onInit` so the header can trigger `zoomIn`, `zoomOut`, `setViewport`, and `fitView`. - -Changes made: Initial plan drafted based on crabwalk’s ReactFlow canvas and the current clawdbot-agent-ui canvas structure. diff --git a/.agent/EXECPLAN6.md b/.agent/EXECPLAN6.md deleted file mode 100644 index 4e18bb80..00000000 --- a/.agent/EXECPLAN6.md +++ /dev/null @@ -1,141 +0,0 @@ -# Avatar-first agent tiles with options menu - -This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds. - -This plan must be maintained in accordance with the repository planning guide at `.agent/PLANS.md`. - -## Purpose / Big Picture - -After this change, creating a new agent shows a compact, avatar-first tile: the agent name appears above a circular avatar, and a single “Send a command” input sits below. The transcript window stays hidden until the first message is sent; then the conversation and thinking traces appear below the avatar. Model and thinking controls move into a small gear options panel, and the running/idle status plus delete action move out of the main view to reduce clutter. A user can start a new agent and immediately see a face and a single input box, and after sending a command they see the transcript panel appear beneath the avatar. - -## Progress - -- [x] (2026-01-28 00:00Z) Authored initial ExecPlan for avatar-first tiles and options menu. -- [x] (2026-01-28 17:35Z) Milestone 1: Add Multiavatar dependency, avatar helpers, and unit tests. -- [x] (2026-01-28 18:05Z) Milestone 2: Restructure AgentTile layout, add options menu, update default tile sizing, and add e2e coverage. - -## Surprises & Discoveries - -- Observation: Next dev server (Turbopack) could not resolve the symlinked `@multiavatar/multiavatar` package from `file:../Multiavatar`. - Evidence: Next dev overlay showed “Module not found: Can't resolve '@multiavatar/multiavatar'” when loading the canvas. - -## Decision Log - -- Decision: Use Multiavatar seeded by `agentId` and strip the built-in environment circle so the UI owns the circular container. - Rationale: `agentId` stays stable across renames, and a single UI circle avoids double backgrounds. - Date/Author: 2026-01-28 / Codex - -- Decision: Hide the transcript panel until there is output or streaming text. - Rationale: This matches the “face + input only” requirement for new agents and avoids a large empty chat window. - Date/Author: 2026-01-28 / Codex - -- Decision: Make model/thinking selectors available in a gear options panel and relocate status and delete actions there. - Rationale: The user requested an options menu and a rethink of the status/delete placement; there is no backend stop/run toggle today, so status stays read-only. - Date/Author: 2026-01-28 / Codex - -- Decision: Switch to the GitHub-hosted `@multiavatar/multiavatar` package and import from `@multiavatar/multiavatar/esm`. - Rationale: Eliminates the local file dependency while keeping a resolvable ESM entry for Next/Turbopack. - Date/Author: 2026-01-28 / Codex - -## Outcomes & Retrospective - -Implemented avatar-first tiles with a gear options panel, added Multiavatar helper/test coverage, and validated behavior with Playwright. Remaining follow-ups are optional UX tweaks (e.g., options panel behavior) based on user feedback. - -## Context and Orientation - -The agent canvas UI renders tiles via `src/features/canvas/components/AgentTile.tsx`, which is wrapped by `src/features/canvas/components/AgentTileNode.tsx` and placed on the canvas in `src/features/canvas/components/CanvasFlow.tsx`. Tile runtime state lives in `src/features/canvas/state/store.tsx` and is created from the persisted `ProjectTile` data defined in `src/lib/projects/types.ts`. New tiles are created by the API route `src/app/api/projects/[projectId]/tiles/route.ts`, which also defines the default tile size and name. The top-level page uses `src/app/page.tsx` to connect the canvas to gateway events and to send messages; it appends user and assistant output lines to each tile. - -Avatar generation code is sourced from the GitHub-hosted `@multiavatar/multiavatar` package and wrapped by `src/lib/avatars/multiavatar.ts` to produce an SVG data URL for an `` tag. - -Playwright e2e tests live under `tests/e2e/`, and Vitest unit tests live under `tests/unit/`. Existing e2e tests already mock `/api/projects` to supply tiles. - -## Plan of Work - -First, add the Multiavatar dependency via GitHub, then create a small avatar helper module that turns a seed string into an SVG string and a safe data URL. Add a unit test to validate that the helper returns valid SVG and a data URL prefix so the build breaks early if the dependency is not wired correctly. - -Next, refactor `AgentTile` into an avatar-first layout: name above avatar, input below, and a transcript window that only renders once there is output or streaming. Add a gear button that reveals model and thinking selectors plus the status indicator and delete action. Use simple, accessible HTML (button + popover or details/summary) and add minimal `aria-label`/`data-` hooks for Playwright. Update the minimum tile size and the API’s default tile size to match the compact layout. Finally, add a Playwright test that verifies the avatar, input, and options panel are present for a new tile and that the transcript panel is hidden initially. - -## Concrete Steps - -Work from the repo root `/Users/georgepickett/clawdbot-agent-ui`. - -1. Ensure the Multiavatar build exists by checking `../Multiavatar/dist/esm/index.js`. If the file is missing, run `npm install` and `npm run build` inside `/Users/georgepickett/Multiavatar` to generate `dist/`. - -2. Add the local dependency to `package.json` as `"@multiavatar/multiavatar": "file:../Multiavatar"`, then run `npm install` to update `package-lock.json`. - -3. Create `src/lib/avatars/multiavatar.ts` with two exported functions: `buildAvatarSvg(seed: string): string` and `buildAvatarDataUrl(seed: string): string`. Validate that `seed` is a non-empty string and throw a clear error if not. Call `multiavatar(seed, true)` (no environment circle) and wrap the SVG in a `data:image/svg+xml;utf8,` URL with `encodeURIComponent`. - -4. Add `tests/unit/multiavatar.test.ts` that asserts: - - `buildAvatarSvg("Agent A")` starts with ``. - - `buildAvatarDataUrl("Agent A")` starts with `data:image/svg+xml;utf8,` and includes an encoded `` inside a circular container (`rounded-full`, `overflow-hidden`). Use `alt` text like `Avatar for ${name}` for testability. - -6. Refactor `src/features/canvas/components/AgentTile.tsx`: - - Replace the current header row and transcript-first layout with a vertical stack: name input (centered), avatar, input row, then transcript panel. - - Keep the name editable with the same onBlur/onKeyDown logic, but style it as a centered label above the avatar. - - Introduce a gear button (using `lucide-react` settings icon) that opens an options panel containing the Model selector, Thinking selector, a read-only status indicator, and a “Delete agent” button. This panel should be accessible (keyboard focusable) and should not interfere with tile dragging. - - Remove the always-visible “No output yet.” message; instead, render the transcript panel only when there is output (`tile.outputLines.length > 0`), streaming text, or active thinking. - - Keep existing output rendering logic, including thinking traces and streamed text, but position it in the new panel below the input. - -7. Update `MIN_TILE_SIZE` in `AgentTile.tsx` and the default `size` in `src/app/api/projects/[projectId]/tiles/route.ts` to match the compact layout (for example, around 420x520). Ensure the new minimum still allows the transcript to be visible when present. - -8. Add a Playwright test `tests/e2e/agent-tile-avatar.spec.ts` that: - - Mocks `/api/projects` to return a store with one tile and no output lines. - - Navigates to `/` and asserts the avatar image with alt text is visible and the “Send a command” input exists. - - Opens the gear options panel and asserts the Model and Thinking selectors are visible. - - Asserts that the transcript panel is not present when output lines are empty (use a `data-testid` on the transcript container if needed). - -9. Run unit and e2e tests: `npm test` and `npm run e2e`. If Playwright requires the dev server, use the same workflow as existing e2e tests. - -## Validation and Acceptance - -The change is accepted when: - -- Creating a new agent renders a tile with a centered name above a circular avatar and an input labeled “Send a command,” and no transcript window is visible until a message is sent. -- After sending a command, the transcript window appears below the avatar and contains the user message and assistant output, with thinking traces formatted as before. -- A gear options panel exists on the tile, containing Model and Thinking controls, a status indicator, and a delete action. -- The avatar is generated from Multiavatar using a stable seed and displays consistently across reloads. - -Milestone 1 verification workflow: - -1. Tests to write: `tests/unit/multiavatar.test.ts` with assertions for `buildAvatarSvg` and `buildAvatarDataUrl`. -2. Implementation: add the local dependency, implement `src/lib/avatars/multiavatar.ts`. -3. Verification: run `npm test -- tests/unit/multiavatar.test.ts` and confirm it passes after failing before. -4. Commit: `git commit -m "Milestone 1: add avatar helper and tests"`. - -Milestone 2 verification workflow: - -1. Tests to write: `tests/e2e/agent-tile-avatar.spec.ts` to assert avatar, input, options panel, and hidden transcript. -2. Implementation: refactor `AgentTile`, add `AgentAvatar`, update tile sizes, and add necessary `aria-label`/`data-testid` hooks. -3. Verification: run `npm run e2e -- tests/e2e/agent-tile-avatar.spec.ts` and confirm it passes; run `npm test` to ensure unit tests still pass. -4. Commit: `git commit -m "Milestone 2: avatar-first tile layout and options menu"`. - -## Idempotence and Recovery - -All steps are safe to repeat. If `npm install` fails after adding the file dependency, remove the entry from `package.json`, run `npm install` to return to a clean state, then re-add the dependency once the Multiavatar build is confirmed. If UI changes cause layout regressions, revert the specific commit for the milestone and reapply the plan with adjusted sizes or layout choices. - -## Artifacts and Notes - -Expected unit test output excerpt: - - ✓ buildAvatarSvg returns svg - ✓ buildAvatarDataUrl returns data url - -Expected e2e assertions: - - - Avatar image is visible in the tile. - - “Send a command” input is visible. - - Options menu reveals Model and Thinking selectors. - - Transcript container is absent when there is no output. - -## Interfaces and Dependencies - -Use the local `@multiavatar/multiavatar` package from `../Multiavatar` via a file dependency. Define a small helper module at `src/lib/avatars/multiavatar.ts` with: - - export function buildAvatarSvg(seed: string): string - export function buildAvatarDataUrl(seed: string): string - -`buildAvatarSvg` must throw a descriptive error if `seed` is empty. `buildAvatarDataUrl` must return a `data:image/svg+xml;utf8,` URL that can be used in an `` tag. The `AgentAvatar` component must accept `seed` and `name` props and render an image with `alt="Avatar for ${name}"`. - -Plan update (2026-01-28): Documented the switch to the GitHub-hosted Multiavatar package and updated progress to reflect completed milestones. diff --git a/.agent/PLANS.md b/.agent/PLANS.md deleted file mode 100644 index fc181026..00000000 --- a/.agent/PLANS.md +++ /dev/null @@ -1,186 +0,0 @@ -# Codex Execution Plans (ExecPlans): - -This document describes the requirements for an execution plan ("ExecPlan"), a design document that a coding agent can follow to deliver a working feature or system change. Treat the reader as a complete beginner to this repository: they have only the current working tree and the single ExecPlan file you provide. There is no memory of prior plans and no external context. - -## How to use ExecPlans and PLANS.md - -When authoring an executable specification (ExecPlan), follow PLANS.md _to the letter_. If it is not in your context, refresh your memory by reading the entire PLANS.md file. Be thorough in reading (and re-reading) source material to produce an accurate specification. When creating a spec, start from the skeleton and flesh it out as you do your research. - -When implementing an executable specification (ExecPlan), do not prompt the user for "next steps"; simply proceed to the next milestone. Keep all sections up to date, add or split entries in the list at every stopping point to affirmatively state the progress made and next steps. Resolve ambiguities autonomously. For each milestone, write failing tests first (when tests are specified), implement until all tests pass, then commit the verified changes before proceeding to the next milestone. If the repo uses Beads, use `br ready` to select work and update issue status as you progress. - -When discussing an executable specification (ExecPlan), record decisions in a log in the spec for posterity; it should be unambiguously clear why any change to the specification was made. ExecPlans are living documents, and it should always be possible to restart from _only_ the ExecPlan and no other work. - -When researching a design with challenging requirements or significant unknowns, use milestones to implement proof of concepts, "toy implementations", etc., that allow validating whether the user's proposal is feasible. Read the source code of libraries by finding or acquiring them, research deeply, and include prototypes to guide a fuller implementation. - -## Requirements - -NON-NEGOTIABLE REQUIREMENTS: - -* Every ExecPlan must be fully self-contained. Self-contained means that in its current form it contains all knowledge and instructions needed for a novice to succeed. -* Every ExecPlan is a living document. Contributors are required to revise it as progress is made, as discoveries occur, and as design decisions are finalized. Each revision must remain fully self-contained. -* Every ExecPlan must enable a complete novice to implement the feature end-to-end without prior knowledge of this repo. -* Every ExecPlan must produce a demonstrably working behavior, not merely code changes to "meet a definition". -* Every ExecPlan must define every term of art in plain language or do not use it. - -Purpose and intent come first. Begin by explaining, in a few sentences, why the work matters from a user's perspective: what someone can do after this change that they could not do before, and how to see it working. Then guide the reader through the exact steps to achieve that outcome, including what to edit, what to run, and what they should observe. - -The agent executing your plan can list files, read files, search, run the project, and run tests. It does not know any prior context and cannot infer what you meant from earlier milestones. Repeat any assumption you rely on. Do not point to external blogs or docs; if knowledge is required, embed it in the plan itself in your own words. If an ExecPlan builds upon a prior ExecPlan and that file is checked in, incorporate it by reference. If it is not, you must include all relevant context from that plan. - -## Formatting - -Format and envelope are simple and strict. Each ExecPlan must be one single fenced code block labeled as `md` that begins and ends with triple backticks. Do not nest additional triple-backtick code fences inside; when you need to show commands, transcripts, diffs, or code, present them as indented blocks within that single fence. Use indentation for clarity rather than code fences inside an ExecPlan to avoid prematurely closing the ExecPlan's code fence. Use two newlines after every heading, use # and ## and so on, and correct syntax for ordered and unordered lists. - -When writing an ExecPlan to a Markdown (.md) file where the content of the file *is only* the single ExecPlan, you should omit the triple backticks. - -Write in plain prose. Prefer sentences over lists. Avoid checklists, tables, and long enumerations unless brevity would obscure meaning. Checklists are permitted only in the `Progress` section, where they are mandatory. Narrative sections must remain prose-first. - -## Guidelines - -Self-containment and plain language are paramount. If you introduce a phrase that is not ordinary English ("daemon", "middleware", "RPC gateway", "filter graph"), define it immediately and remind the reader how it manifests in this repository (for example, by naming the files or commands where it appears). Do not say "as defined previously" or "according to the architecture doc." Include the needed explanation here, even if you repeat yourself. - -Avoid common failure modes. Do not rely on undefined jargon. Do not describe "the letter of a feature" so narrowly that the resulting code compiles but does nothing meaningful. Do not outsource key decisions to the reader. When ambiguity exists, resolve it in the plan itself and explain why you chose that path. Err on the side of over-explaining user-visible effects and under-specifying incidental implementation details. - -Anchor the plan with observable outcomes. State what the user can do after implementation, the commands to run, and the outputs they should see. Acceptance should be phrased as behavior a human can verify ("after starting the server, navigating to [http://localhost:8080/health](http://localhost:8080/health) returns HTTP 200 with body OK") rather than internal attributes ("added a HealthCheck struct"). If a change is internal, explain how its impact can still be demonstrated (for example, by running tests that fail before and pass after, and by showing a scenario that uses the new behavior). - -Specify repository context explicitly. Name files with full repository-relative paths, name functions and modules precisely, and describe where new files should be created. If touching multiple areas, include a short orientation paragraph that explains how those parts fit together so a novice can navigate confidently. When running commands, show the working directory and exact command line. When outcomes depend on environment, state the assumptions and provide alternatives when reasonable. - -Be idempotent and safe. Write the steps so they can be run multiple times without causing damage or drift. If a step can fail halfway, include how to retry or adapt. If a migration or destructive operation is necessary, spell out backups or safe fallbacks. Prefer additive, testable changes that can be validated as you go. - -Validation is not optional. Include instructions to run tests, to start the system if applicable, and to observe it doing something useful. Describe comprehensive testing for any new features or capabilities. Include expected outputs and error messages so a novice can tell success from failure. Where possible, show how to prove that the change is effective beyond compilation (for example, through a small end-to-end scenario, a CLI invocation, or an HTTP request/response transcript). State the exact test commands appropriate to the project’s toolchain and how to interpret their results. - -When specifying tests, prefer a test-first approach: describe which tests to write and what they should assert before describing the implementation. This allows the implementing agent to write failing tests first, then implement until the tests pass. Specify the test file paths, test function names, and the exact assertions expected. If the project has an existing test structure, follow its conventions. - -Capture evidence. When your steps produce terminal output, short diffs, or logs, include them inside the single fenced block as indented examples. Keep them concise and focused on what proves success. If you need to include a patch, prefer file-scoped diffs or small excerpts that a reader can recreate by following your instructions rather than pasting large blobs. - -## Milestones - -Milestones are narrative, not bureaucracy. If you break the work into milestones, introduce each with a brief paragraph that describes the scope, what will exist at the end of the milestone that did not exist before, the commands to run, and the acceptance you expect to observe. Keep it readable as a story: goal, work, result, proof. Progress and milestones are distinct: milestones tell the story, progress tracks granular work. Both must exist. Never abbreviate a milestone merely for the sake of brevity, do not leave out details that could be crucial to a future implementation. - -Each milestone must be independently verifiable and incrementally implement the overall goal of the execution plan. - -## Verification and Test-Driven Milestones - -Every milestone must include built-in verification steps that allow the implementing agent to confirm correctness without human intervention. Prefer test-driven development: write failing tests that define the milestone's acceptance criteria before writing the implementation. The milestone is not complete until all tests pass. - -When designing a milestone, follow this verification pattern: - -1. Define the acceptance criteria as concrete, observable behaviors. -2. Write tests (unit, integration, or end-to-end as appropriate) that exercise these behaviors. Run them to confirm they fail for the expected reasons. -3. Implement the feature or change. -4. Run the tests again. The milestone is complete only when all tests pass and any other validation steps succeed. -5. After all tests pass, the implementing agent is permitted (and encouraged) to commit the changes with a clear commit message describing the milestone completed. - -If tests are not feasible for a particular milestone (e.g., infrastructure setup, configuration changes, or exploratory prototypes), specify alternative verification steps: commands to run, outputs to observe, or states to confirm. The key requirement is that the agent can autonomously verify success without asking for human confirmation. - -Commits should be frequent and atomic. Each milestone that passes verification should be committed before proceeding to the next. This creates a clean history of incremental progress and allows safe rollback if later milestones encounter issues. The commit message should reference the milestone and summarize what was achieved. - -## Issue Tracking with Beads - -ExecPlans integrate with Beads (`br`) for local issue tracking. When a repo has Beads initialized (`.beads/` directory exists), use it to track milestones as issues. - -When authoring an ExecPlan, create a Beads issue for each milestone: `br create "Milestone N: " --type task --priority <0-4> --description "<scope and acceptance criteria>"`. Use `br dep add <child> <parent>` to express milestone dependencies. Record the issue IDs in the Progress section. - -When implementing, use `br ready --json` to select the next unblocked milestone. Claim it with `br update <id> --status in_progress`. After verification passes, close it with `br close <id> --reason "Tests pass, committed"`. Run `br sync --flush-only` before committing to include the issue state in git history. - -If Beads is not initialized or the user has not requested issue tracking, skip these steps. - -## Living plans and design decisions - -* ExecPlans are living documents. As you make key design decisions, update the plan to record both the decision and the thinking behind it. Record all decisions in the `Decision Log` section. -* ExecPlans must contain and maintain a `Progress` section, a `Surprises & Discoveries` section, a `Decision Log`, and an `Outcomes & Retrospective` section. These are not optional. -* When you discover optimizer behavior, performance tradeoffs, unexpected bugs, or inverse/unapply semantics that shaped your approach, capture those observations in the `Surprises & Discoveries` section with short evidence snippets (test output is ideal). -* If you change course mid-implementation, document why in the `Decision Log` and reflect the implications in `Progress`. Plans are guides for the next contributor as much as checklists for you. -* At completion of a major task or the full plan, write an `Outcomes & Retrospective` entry summarizing what was achieved, what remains, and lessons learned. - -# Prototyping milestones and parallel implementations - -It is acceptable—-and often encouraged—-to include explicit prototyping milestones when they de-risk a larger change. Examples: adding a low-level operator to a dependency to validate feasibility, or exploring two composition orders while measuring optimizer effects. Keep prototypes additive and testable. Clearly label the scope as “prototyping”; describe how to run and observe results; and state the criteria for promoting or discarding the prototype. - -Prefer additive code changes followed by subtractions that keep tests passing. Parallel implementations (e.g., keeping an adapter alongside an older path during migration) are fine when they reduce risk or enable tests to continue passing during a large migration. Describe how to validate both paths and how to retire one safely with tests. When working with multiple new libraries or feature areas, consider creating spikes that evaluate the feasibility of these features _independently_ of one another, proving that the external library performs as expected and implements the features we need in isolation. - -## Skeleton of a Good ExecPlan - - # <Short, action-oriented description> - - This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds. - - If PLANS.md file is checked into the repo, reference the path to that file here from the repository root and note that this document must be maintained in accordance with PLANS.md. - - ## Purpose / Big Picture - - Explain in a few sentences what someone gains after this change and how they can see it working. State the user-visible behavior you will enable. - - ## Progress - - Use a list with checkboxes to summarize granular steps. Every stopping point must be documented here, even if it requires splitting a partially completed task into two (“done” vs. “remaining”). This section must always reflect the actual current state of the work. - - - [x] (2025-10-01 13:00Z) Example completed step. [BEAD-001] - - [ ] Example incomplete step. [BEAD-002] - - [ ] Example partially completed step (completed: X; remaining: Y). [BEAD-003] - - Use timestamps to measure rates of progress. If using Beads, include the issue ID in brackets after each step. - - ## Surprises & Discoveries - - Document unexpected behaviors, bugs, optimizations, or insights discovered during implementation. Provide concise evidence. - - - Observation: … - Evidence: … - - ## Decision Log - - Record every decision made while working on the plan in the format: - - - Decision: … - Rationale: … - Date/Author: … - - ## Outcomes & Retrospective - - Summarize outcomes, gaps, and lessons learned at major milestones or at completion. Compare the result against the original purpose. - - ## Context and Orientation - - Describe the current state relevant to this task as if the reader knows nothing. Name the key files and modules by full path. Define any non-obvious term you will use. Do not refer to prior plans. - - ## Plan of Work - - Describe, in prose, the sequence of edits and additions. For each edit, name the file and location (function, module) and what to insert or change. Keep it concrete and minimal. - - ## Concrete Steps - - State the exact commands to run and where to run them (working directory). When a command generates output, show a short expected transcript so the reader can compare. This section must be updated as work proceeds. - - ## Validation and Acceptance - - Describe how to start or exercise the system and what to observe. Phrase acceptance as behavior, with specific inputs and outputs. If tests are involved, say "run <project’s test command> and expect <N> passed; the new test <name> fails before the change and passes after>". - - For each milestone, specify the verification workflow: - 1. Tests to write: List the test file paths, test function names, and assertions. These tests should be written first and must fail before implementation. - 2. Implementation: Describe the changes to make. - 3. Verification: Run the tests. The milestone is complete only when all tests pass. - 4. Commit: After verification passes, commit the changes with a message referencing the milestone. - - Example: "Write test_user_creation in tests/test_users.py that asserts a 201 response with user ID. Run pytest tests/test_users.py -k test_user_creation and confirm it fails. Implement the endpoint. Run the test again and confirm it passes. Commit with message 'Milestone 1: Add user creation endpoint'." - - ## Idempotence and Recovery - - If steps can be repeated safely, say so. If a step is risky, provide a safe retry or rollback path. Keep the environment clean after completion. - - ## Artifacts and Notes - - Include the most important transcripts, diffs, or snippets as indented examples. Keep them concise and focused on what proves success. - - ## Interfaces and Dependencies - - Be prescriptive. Name the libraries, modules, and services to use and why. Specify the types, traits/interfaces, and function signatures that must exist at the end of the milestone. Prefer stable names and paths such as `crate::module::function` or `package.submodule.Interface`. E.g.: - - In crates/foo/planner.rs, define: - - pub trait Planner { - fn plan(&self, observed: &Observed) -> Vec<Action>; - } - -If you follow the guidance above, a single, stateless agent -- or a human novice -- can read your ExecPlan from top to bottom and produce a working, observable result. That is the bar: SELF-CONTAINED, SELF-SUFFICIENT, NOVICE-GUIDING, OUTCOME-FOCUSED. - -When you revise a plan, you must ensure your changes are comprehensively reflected across all sections, including the living document sections, and you must write a note at the bottom of the plan describing the change and the reason why. ExecPlans must describe not just the what but the why for almost everything. \ No newline at end of file diff --git a/.agent/future-plans/20260127-1228-gateway-resilience.md b/.agent/future-plans/20260127-1228-gateway-resilience.md deleted file mode 100644 index 4e5b6491..00000000 --- a/.agent/future-plans/20260127-1228-gateway-resilience.md +++ /dev/null @@ -1,5 +0,0 @@ -GatewayClient (src/lib/gateway/GatewayClient.ts) performs a one-shot connect and the hook in src/lib/gateway/useGatewayConnection.ts only auto-connects once, so any transient gateway failure requires a manual refresh. The UI in src/app/page.tsx only gates actions on the connection status and does not provide a structured retry UX. - -Implement a reconnect strategy with exponential backoff and jitter, add a retrying status and lastError fields, and respect GatewayResponseError.retryAfterMs when present. Update HeaderBar or page UI to show connection state, last error, and a retry or cancel action; optionally persist gatewayUrl/token edits in local storage or via a small API endpoint so users do not retype on reload. - -Acceptance criteria: on socket close, the client retries with backoff until connected or explicitly disconnected; the UI displays next retry timing and lets the user cancel; tests with fake timers validate backoff and stop behavior. Open question: should auto-reconnect be disabled after auth failures, and how do we detect that from gateway responses. diff --git a/.agent/future-plans/20260127-1228-onboarding-docs.md b/.agent/future-plans/20260127-1228-onboarding-docs.md deleted file mode 100644 index 871ce34e..00000000 --- a/.agent/future-plans/20260127-1228-onboarding-docs.md +++ /dev/null @@ -1,5 +0,0 @@ -README.md is empty and USER.md contains only a preference, while critical operational details live in code, including config search paths in src/lib/clawdbot/config.ts, gateway defaults in src/lib/clawdbot/gateway.ts, env variables in src/lib/env.ts, and local state in src/app/api/projects/store.ts. This makes onboarding and troubleshooting slow and error-prone. - -Write a concise README with setup steps, dev and test commands from package.json, required env/config, gateway expectations, and where workspace state is stored. Add a short troubleshooting section for missing config and gateway errors; optionally consolidate USER.md into README if it is not used elsewhere to reduce scattered docs. - -Acceptance criteria: README explains how to run the UI with a local gateway, lists required env/config paths, and documents common failure modes with fixes. Open question: should docs standardize on .moltbot vs .clawdbot naming and which path is preferred for new installs. diff --git a/.agent/future-plans/20260127-1228-project-store-hardening.md b/.agent/future-plans/20260127-1228-project-store-hardening.md deleted file mode 100644 index 9e535238..00000000 --- a/.agent/future-plans/20260127-1228-project-store-hardening.md +++ /dev/null @@ -1,5 +0,0 @@ -The projects store is persisted as JSON at ~/.clawdbot/agent-canvas/projects.json (src/app/api/projects/store.ts) with manual parsing and minimal validation, and the UI auto-saves every 250ms in src/features/canvas/state/store.tsx without handling save failures. This setup risks silent corruption or data loss when parsing fails or concurrent writes occur. - -Introduce a zod schema for ProjectsStore and tile structures, perform validation on load and save, and switch to atomic writes (write temp then rename) with a backup copy when parsing fails. Surface explicit error codes from src/app/api/projects/route.ts, and update the client store to pause autosave and show a blocking error with retry guidance. Touch src/app/api/projects/store.ts, src/app/api/projects/route.ts, src/lib/projects/types.ts, src/lib/http.ts, and src/features/canvas/state/store.tsx. - -Acceptance criteria: invalid store files produce actionable errors while preserving the last good file; saves are atomic and verified; the UI shows a clear failure state and does not silently overwrite data; tests cover migration and validation. Open question: how should concurrent writes from multiple tabs/processes be resolved, and do we need a version conflict policy. diff --git a/.agent/future-plans/20260127-1228-test-coverage.md b/.agent/future-plans/20260127-1228-test-coverage.md deleted file mode 100644 index c551702b..00000000 --- a/.agent/future-plans/20260127-1228-test-coverage.md +++ /dev/null @@ -1,5 +0,0 @@ -Test coverage is minimal: tests/unit only includes fetchJson and slugifyProjectName, and tests/e2e has a single canvas smoke test, while the main chat flow and polling logic live in src/app/page.tsx and the state machine in src/features/canvas/state/store.tsx. This leaves key behaviors (gateway errors, chat history reconciliation, and autosave) unverified. - -Add unit tests for the canvas reducer actions, store migration/validation, and GatewayClient request/response handling. Expand Playwright e2e to cover create workspace, add tile, send a message with a mocked gateway, and error states; reuse tests/setup.ts for common mocks and fixtures. - -Acceptance criteria: tests cover both success and failure cases for store load/save and gateway errors, and e2e runs deterministically with a mocked gateway. Open question: should the gateway be mocked via a local WebSocket test server or an in-browser stub to keep tests fast and reliable. diff --git a/.agent/future-plans/20260127-1248-model-capabilities.md b/.agent/future-plans/20260127-1248-model-capabilities.md deleted file mode 100644 index b7b02366..00000000 --- a/.agent/future-plans/20260127-1248-model-capabilities.md +++ /dev/null @@ -1,5 +0,0 @@ -Model options and thinking levels are hard-coded in the UI (src/features/canvas/components/AgentTile.tsx) while the runtime settings are pushed via sessions.patch in src/app/page.tsx without any capability validation. This creates a mismatch risk when the gateway or config supports different models or disallows thinking settings, leading to confusing failures that only surface as output lines. - -Add a gateway capabilities endpoint (for example /api/gateway/capabilities) that reports supported models and thinking levels, backed by config or a lightweight gateway handshake, and thread that data into the canvas UI. Update src/app/page.tsx to load capabilities once, store them in state, and make AgentTile render options dynamically with disabled or hidden unsupported options; add a fallback to show a "custom model" input if a tile already has a model not in the list. - -Acceptance criteria: the model/thinking dropdowns always reflect the gateway capability response; unsupported selections are blocked with a clear inline message; and the UI still renders existing tiles with custom model values. Open question: should capabilities come from config only or a live gateway handshake, and how do we cache them to avoid blocking initial render. diff --git a/.agent/future-plans/20260127-1248-state-dir-unification.md b/.agent/future-plans/20260127-1248-state-dir-unification.md deleted file mode 100644 index aec99733..00000000 --- a/.agent/future-plans/20260127-1248-state-dir-unification.md +++ /dev/null @@ -1,5 +0,0 @@ -State directory resolution is inconsistent: config lookup in src/lib/clawdbot/config.ts searches both ~/.moltbot and ~/.clawdbot, but agent workspace paths in src/lib/projects/agentWorkspace.ts and agent state paths in src/app/api/projects/[projectId]/tiles/route.ts and src/app/api/projects/[projectId]/tiles/[tileId]/route.ts are hard-coded to ~/.clawdbot. This can split state across directories depending on environment variables, which makes onboarding and cleanup confusing. - -Extract a single state-dir resolver (for example in src/lib/clawdbot/stateDir.ts) that applies the same env and fallback rules as loadClawdbotConfig, then update all filesystem paths that rely on ~/.clawdbot to use it. Touch src/lib/projects/agentWorkspace.ts, src/app/api/projects/[projectId]/tiles/route.ts, src/app/api/projects/[projectId]/tiles/[tileId]/route.ts, and any other locations that build agent paths from CLAWDBOT_STATE_DIR; add a small unit test to lock down path resolution ordering. - -Acceptance criteria: all agent workspace and state paths resolve from the same function and honor MOLTBOT_STATE_DIR/CLAWDBOT_STATE_DIR consistently; documentation reflects the single source of truth; and tests cover env override precedence. Open question: should MOLTBOT_STATE_DIR always win over CLAWDBOT_STATE_DIR, or do we need a migration path for existing users. diff --git a/.agent/future-plans/20260127-1248-tile-deletion-safety.md b/.agent/future-plans/20260127-1248-tile-deletion-safety.md deleted file mode 100644 index 96b45409..00000000 --- a/.agent/future-plans/20260127-1248-tile-deletion-safety.md +++ /dev/null @@ -1,5 +0,0 @@ -Deleting a tile triggers server-side directory removal in src/app/api/projects/[projectId]/tiles/[tileId]/route.ts via fs.rmSync, but the UI delete button in src/features/canvas/components/AgentTile.tsx does not confirm or summarize what will be removed. This is a high-risk action because it can delete both the agent workspace and agent state directories without any reversible path. - -Add a confirmation flow that shows the exact directories slated for deletion and require a typed confirmation for tiles with existing workspaces; consider adding a server-side archive mode that moves directories to a trash location under the state dir instead of deleting outright. Update the delete endpoint to support a mode flag (archive vs delete), return the resolved paths in its response, and update the client to opt into archive by default with an explicit "permanently delete" option. - -Acceptance criteria: tile deletion cannot proceed without an explicit confirmation, the API returns the paths that were archived/deleted, and the UI exposes a reversible archive workflow. Open question: should archives auto-expire or require a manual cleanup command, and where should that live in the UI. diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..9115c158 --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +# Optional overrides for local dev. +# Default behavior uses ~/.openclaw and falls back to ~/.moltbot or ~/.clawdbot. + +# Point to a specific state directory. +OPENCLAW_STATE_DIR=/Users/yourname/.openclaw +# MOLTBOT_STATE_DIR=/Users/yourname/.moltbot +# CLAWDBOT_STATE_DIR=/Users/yourname/.clawdbot + +# Point to a specific config file. +OPENCLAW_CONFIG_PATH=/Users/yourname/.openclaw/openclaw.json +# MOLTBOT_CONFIG_PATH=/Users/yourname/.moltbot/moltbot.json +# CLAWDBOT_CONFIG_PATH=/Users/yourname/.clawdbot/clawdbot.json + +# Override the gateway URL used by the browser. +# Defaults to ws://127.0.0.1:18789 +NEXT_PUBLIC_GATEWAY_URL=ws://127.0.0.1:18789 + +# Optional: which existing agent to copy auth profiles from when creating tiles. +CLAWDBOT_DEFAULT_AGENT_ID=main diff --git a/.github/ISSUE_TEMPLATE/issue.md b/.github/ISSUE_TEMPLATE/issue.md new file mode 100644 index 00000000..7269cb48 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/issue.md @@ -0,0 +1,21 @@ +--- +name: Issue +about: Report a bug or request a change +--- + +## Summary + +## Steps to reproduce +1. + +## Expected + +## Actual + +## Environment +- OS: +- Node: +- UI version/commit: +- Gateway running? (yes/no) + +## Logs/screenshots diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..d83a048c --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,12 @@ +## Summary +- + +## Testing +- [ ] Not run (explain why) +- [ ] `npm run lint` +- [ ] `npm run typecheck` +- [ ] `npm run test` +- [ ] `npm run e2e` + +## AI-assisted +- [ ] AI-assisted (briefly describe what and include prompts/logs if helpful) diff --git a/.gitignore b/.gitignore index 5ef6a520..0483af60 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +!.env.example # vercel .vercel @@ -39,3 +40,21 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# playwright +playwright-report +test-results +.playwright-home + +# agent state +/.agent +/.openclaw +/.clawdbot +/.moltbot +/agent-canvas +/worktrees + +AGENTS.md + +# local issue tracker +/.beads diff --git a/.playwright-home/Library/Preferences/nextjs-nodejs/config.json b/.playwright-home/Library/Preferences/nextjs-nodejs/config.json deleted file mode 100644 index 4ae15cc5..00000000 --- a/.playwright-home/Library/Preferences/nextjs-nodejs/config.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "telemetry": { - "notifiedAt": "1769545120941", - "anonymousId": "3f54b52ec8bbe85520879d44e931326f6fcd173b5a609a81139c9da573ca3142", - "salt": "22ea424b2c776e6b5015e872856b1ff8" - } -} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 763382eb..00000000 --- a/AGENTS.md +++ /dev/null @@ -1 +0,0 @@ -If you have any doubts about how clawdbot works or anything about that repo - take a look at ~/clawdbot (the source of truth for the backend of this app) \ No newline at end of file diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 00000000..210dba62 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,166 @@ +# Architecture + +## High-level overview & goals +OpenClaw Studio is a gateway-first, single-user Next.js App Router UI for managing OpenClaw agents. It provides: +- A focused UI with fleet list, primary agent panel, and inspect sidebar. +- Local persistence for gateway connection + focused-view preferences via a JSON settings file. +- Direct integration with the OpenClaw runtime via a WebSocket gateway. +- Gateway-backed edits for agent config and agent files. +- Optional Discord channel provisioning for local gateways. + +Primary goals: +- **Gateway-first**: agents, sessions, and config live in the gateway; Studio stores only UI settings. +- **Remote-friendly**: tailnet/remote gateways are first-class. +- **Clear boundaries**: client UI vs server routes vs external gateway/config. +- **Predictable state**: gateway is source of truth; local settings only for focused preferences + connection. +- **Maintainability**: feature-focused modules, minimal abstractions. + +Non-goals: +- Multi-tenant or multi-user concurrency. +- Server-side rendering of data from external services. + +## Architectural style +**Layered + vertical slice (feature-first)** within Next.js App Router: +- UI components + feature state in `src/features`. +- Shared utilities and adapters in `src/lib`. +- Server-side route handlers under `src/app/api`. + +This keeps feature cohesion high while preserving a clear client/server boundary. + +## Main modules / bounded contexts +- **Focused agent UI** (`src/features/agents`): focused agent panel, fleet sidebar, inspect panel, and local in-memory state + actions. Agents render a status-first summary and latest-update preview driven by gateway events + summary snapshots (`src/features/agents/state/summary.ts`). Runtime chat/agent event translation is centralized through bridge helpers (`src/features/agents/state/runtimeEventBridge.ts`) and consumed from a single runtime listener path in `src/app/page.tsx`. Full transcripts load only on explicit “Load history” actions. +- **Studio settings** (`src/lib/studio`, `src/app/api/studio`): local settings store for gateway URL/token and focused preferences (`src/lib/studio/settings.ts`, `src/lib/studio/settings.server.ts`, `src/app/api/studio/route.ts`). `src/lib/studio/client.ts` and `src/lib/studio/coordinator.ts` provide shared client-side load/patch scheduling for gateway, focused, and studio-session settings. +- **Gateway** (`src/lib/gateway`): WebSocket client for agent runtime (frames, connect, request/response). The OpenClaw control UI client is vendored in `src/lib/gateway/openclaw/GatewayBrowserClient.ts` with a sync script at `scripts/sync-openclaw-gateway-client.ts`. +- **Gateway-backed config + agent-file edits** (`src/lib/gateway/agentConfig.ts`, `src/lib/gateway/tools.ts`, `src/app/api/gateway/tools/route.ts`): agent rename/heartbeat via `config.get` + `config.patch`, agent file read/write via `/tools/invoke` proxy. +- **Local OpenClaw config + paths** (`src/lib/clawdbot`): state/config/.env path resolution with `OPENCLAW_*` env overrides (`src/lib/clawdbot/paths.ts`). Local config access is used for optional Discord provisioning and local path/config helpers; shared local config list helpers live in `src/lib/clawdbot/config.ts` and are reused by Discord provisioning. Gateway URL/token in Studio are sourced from studio settings. +- **Shared agent config-list helpers** (`src/lib/agents/configList.ts`): pure `agents.list` read/write/upsert helpers reused by both gateway config patching (`src/lib/gateway/agentConfig.ts`) and local config access (`src/lib/clawdbot/config.ts`) to keep list-shape semantics aligned. +- **Discord integration** (`src/lib/discord`, API route): channel provisioning and config binding (local gateway only). +- **Shared utilities** (`src/lib/*`): env, ids, names, avatars, text parsing, logging, filesystem helpers. + +## Directory layout (top-level) +- `src/app`: Next.js App Router pages, layouts, global styles, and API routes. +- `src/features`: feature-first UI modules (currently focused agent-management components under `features/agents`). +- `src/lib`: domain utilities, adapters, API clients, and shared logic. +- `src/components`: shared UI components (minimal use today). +- `src/styles`: shared styling assets. +- `public`: static assets. +- `tests`, `playwright.config.ts`, `vitest.config.ts`: automated testing. + +## Data flow & key boundaries +### 1) Studio settings + focused preferences +- **Source of truth**: JSON settings file at `~/.openclaw/openclaw-studio/settings.json` (resolved via `resolveStateDir`, with legacy fallbacks in `src/lib/clawdbot/paths.ts`). Settings store the gateway URL/token plus per-gateway focused preferences and studio session ids. +- **Server boundary**: `src/app/api/studio/route.ts` loads/saves settings via `src/lib/studio/settings.server.ts`. +- **Client boundary**: `useGatewayConnection` and focused/session flows in `src/app/page.tsx` use a shared `StudioSettingsCoordinator` to load settings, coalesce debounced `/api/studio` patch writes, and force immediate session-id writes when bootstrapping a new studio session. + +Flow: +1. UI loads settings from `/api/studio`. +2. Gateway URL/token seed the connection panel and auto-connect. +3. Focused filter + selected agent + studio session id are loaded for the current gateway. +4. UI schedules focused and gateway patches through the coordinator, while studio-session bootstrap uses an immediate coordinator patch; both paths converge on `/api/studio`. + +### 2) Agent runtime (gateway) +- **Client-side only**: `GatewayClient` uses WebSocket to connect to the gateway and wraps the vendored `GatewayBrowserClient`. +- **API is not in the middle**: UI speaks directly to the gateway for streaming and agent events. + +Flow: +1. UI loads gateway URL/token from `/api/studio` (defaulting to `NEXT_PUBLIC_GATEWAY_URL` if unset). +2. `GatewayClient` connects + sends `connect` request. +3. UI requests `agents.list` and builds session keys via `buildAgentMainSessionKey(agentId, mainKey)`. +4. UI sends requests (frames) and receives event streams. +5. A single runtime listener in `src/app/page.tsx` handles chat + agent frames and delegates lifecycle/stream/tool dedupe decisions to `src/features/agents/state/runtimeEventBridge.ts`. +6. Agent store updates agent output/state. + +### 3) Agent config + agent files +- **Agent files**: `AGENTS.md`, `SOUL.md`, `IDENTITY.md`, `USER.md`, `TOOLS.md`, `HEARTBEAT.md`, `MEMORY.md`. +- **Heartbeat + rename**: stored in the gateway config and updated via `config.get` + `config.patch`. + - **Tool policy**: the gateway build must expose coding tools on `/tools/invoke`, and any tool allowlists must permit `read`/`write`/`edit`/`apply_patch` for the target agent (otherwise `/tools/invoke` returns 404). + +Flow: +1. UI requests heartbeat data via gateway `config.get` (client WS) and applies overrides via `config.patch` (`src/lib/gateway/agentConfig.ts`). +2. Agent file edits call `/api/gateway/tools`, which proxies to the gateway `/tools/invoke` endpoint with `read`/`write` and a session key. +3. UI reflects persisted state returned by the gateway. + +### 4) Cron summaries + Discord provisioning +- **Cron**: `GET /api/cron` reads `~/.openclaw/cron/jobs.json` (local state dir) to display scheduled jobs. +- **Discord**: API route calls `createDiscordChannelForAgent`, uses `DISCORD_BOT_TOKEN` from the resolved state-dir `.env`, and updates local `openclaw.json` bindings. + +## Cross-cutting concerns +- **Configuration**: `src/lib/env` validates env via zod; `lib/clawdbot/paths.ts` resolves config path and state dirs, honoring `OPENCLAW_STATE_DIR`/`OPENCLAW_CONFIG_PATH` and legacy fallbacks. Studio settings live under `<state dir>/openclaw-studio/settings.json`. +- **Logging**: `src/lib/logger` (console wrappers) used in API routes and gateway client. +- **Error handling**: + - API routes return JSON `{ error }` with appropriate status. + - `fetchJson` throws when `!res.ok`, surfaces errors to UI state. + - `StudioSettingsCoordinator` logs failed async persistence writes (debounced flush or queued patch failures) so settings-save errors are observable. +- **Filesystem helpers**: server-only utilities live in `src/lib/fs.server.ts` (safe directory/file creation, home-scoped path autocomplete). These are used for local settings, cron summaries, and path suggestions, not for agent file edits. +- **Tracing**: `src/instrumentation.ts` registers `@vercel/otel` for telemetry. +- **Validation**: request payload validation in API routes and typed client/server helpers in `src/lib/*`. + +## Major design decisions & trade-offs +- **Local settings file over DB**: fast, local-first persistence for gateway connection + focused preferences; trade-off is no concurrency or multi-user support. +- **WebSocket gateway direct to client**: lowest latency for streaming; trade-off is tighter coupling to the gateway protocol in the UI. +- **Gateway-first agent records**: records map 1:1 to `agents.list` entries with main sessions; trade-off is no local-only agent concept. +- **Gateway-backed config + agent-file edits**: rename/heartbeat via `config.patch`, agent files via `/tools/invoke`; trade-off is reliance on gateway availability and tool allowlists. +- **Narrow local config mutation boundary**: local `openclaw.json` writes are limited to explicit local-only flows (currently Discord provisioning), and reuse shared list helpers instead of ad-hoc mutation paths; trade-off is less flexibility for local-only experimentation, but clearer ownership and lower drift risk. +- **Shared `agents.list` helper layer**: gateway and local config paths now consume one pure helper module for list parsing/writing/upsert behavior; trade-off is one more shared dependency, but it reduces semantic drift and duplicate bug surface. +- **Single gateway settings endpoint**: `/api/studio` is the sole Studio gateway URL/token source; trade-off is migration pressure on any older local-config-based callers, but it removes ambiguous ownership and dead paths. +- **Shared client settings coordinator**: one coordinator now owns `/api/studio` load/schedule/flush behavior for gateway + focused + session state; trade-off is introducing a central client singleton, but it removes duplicate timers/fetch paths and reduces persistence races. +- **Vendored gateway client + sync script**: reduces drift from upstream OpenClaw UI; trade-off is maintaining a sync path and local copies of upstream helpers. +- **Feature-first organization**: increases cohesion in UI; trade-off is more discipline to keep shared logic in `lib`. +- **Node runtime for API routes**: required for filesystem access and tool proxying; trade-off is Node-only server runtime. +- **Event-driven summaries + on-demand history**: keeps the dashboard lightweight; trade-off is history not being available until requested. +- **Single runtime event bridge for chat+agent streams**: one listener path in `src/app/page.tsx` now routes runtime frames through pure bridge helpers (`src/features/agents/state/runtimeEventBridge.ts`); trade-off is slightly denser bridge contract, but lower divergence risk across lifecycle cleanup/state transitions. + +## Mermaid diagrams +### C4 Level 1 (System Context) +```mermaid +C4Context + title OpenClaw Studio - System Context + Person(user, "User", "Operates agents locally") + System(ui, "OpenClaw Studio", "Next.js App Router UI") + System_Ext(gateway, "OpenClaw Gateway", "WebSocket runtime") + System_Ext(fs, "Local Filesystem", "settings.json, cron/jobs.json, optional openclaw.json") + System_Ext(discord, "Discord API", "Optional channel provisioning") + + Rel(user, ui, "Uses") + Rel(ui, gateway, "WebSocket frames + HTTP tools") + Rel(ui, fs, "HTTP to API routes -> fs read/write") + Rel(ui, discord, "HTTP via API route") +``` + +### C4 Level 2 (Containers/Components) +```mermaid +C4Container + title OpenClaw Studio - Containers + Person(user, "User") + + Container_Boundary(app, "Next.js App") { + Container(client, "Client UI", "React", "Focused agent-management UI, state, gateway client") + Container(api, "API Routes", "Next.js route handlers", "Studio settings, gateway tools, cron, Discord") + } + + Container_Ext(gateway, "Gateway", "WebSocket", "Agent runtime") + Container_Ext(fs, "Filesystem", "Local", "settings.json, cron/jobs.json, optional openclaw.json") + Container_Ext(discord, "Discord API", "REST", "Channel provisioning") + + Rel(user, client, "Uses") + Rel(client, api, "HTTP JSON") + Rel(client, gateway, "WebSocket") + Rel(api, fs, "Read/Write") + Rel(api, discord, "REST") +``` + +## Explicit forbidden patterns +- Do not read/write local files directly from client components. +- Do not reintroduce local projects/workspaces as a source of truth for agent records. +- Do not write agent rename/heartbeat data directly to `openclaw.json`; use gateway `config.patch`. +- Do not read/write agent files on the local filesystem; use the gateway tools proxy. +- Do not add parallel gateway settings endpoints; `/api/studio` is the only supported Studio gateway URL/token path. +- Do not add new generic local `openclaw.json` mutation wrappers for runtime agent-management flows; keep local writes constrained to explicit local-only integrations (for example, Discord provisioning). +- Do not store gateway tokens or secrets in client-side persistent storage. +- Do not add new global mutable state outside `AgentStoreProvider` for agent UI data. +- Do not silently swallow errors in API routes; always return actionable errors. +- Do not add heavy abstractions or frameworks unless there is clear evidence of need. + +## Future-proofing notes +- If multi-user support becomes a goal, replace the settings file with a DB-backed service and introduce authentication at the API boundary. +- If gateway protocol evolves, isolate changes within `src/lib/gateway` and keep UI call sites stable. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..a8a9aa9d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,93 @@ +# Contributing + +Thanks for helping improve OpenClaw Studio. + +- For external bugs and feature requests: please use GitHub Issues. +- For repo work tracked by our on-host agent squad: we use **repo-scoped Beads** (`br`) (details below). + +## Before you start +- Install OpenClaw and confirm the gateway runs locally. +- This repo is UI-only and reads config from `~/.openclaw` with legacy fallback to `~/.moltbot` or `~/.clawdbot`. +- It does not run or build the gateway from source. + +## Local setup +```bash +git clone https://github.com/grp06/openclaw-studio.git +cd openclaw-studio +npm install +cp .env.example .env +npm run dev +``` + +## Testing +- `npm run lint` +- `npm run typecheck` +- `npm run test` +- `npm run e2e` (requires `npx playwright install`) + +## Task tracking (Beads) + +We track implementation work in this repo using **Beads** (`br`). The backlog lives *with the artifact being changed*: + +- Beads live under this repo’s `.beads/` folder (repo-scoped backlog). +- The SQLite DB is local-only; the portable/committed artifact is `.beads/issues.jsonl`. + +Common commands (run from the repo root): + +```bash +br init # one-time, if .beads/ doesn't exist yet +br create "Title" --type task --priority 1 +br list --status open +br show <id> +br sync --flush-only # export .beads/issues.jsonl before committing +``` + +## Pull requests +- Keep PRs focused and small. +- Prefer **one Bead → one PR**. +- Include the tests you ran. +- Link to the relevant issue/Bead. +- Before committing, run: `br sync --flush-only`. +- If you changed gateway behavior, call it out explicitly. + +## Reporting issues +When filing an issue, please include: +- Reproduction steps +- OS and Node version +- Any relevant logs or screenshots + +## Minimal PR template +```md +## Summary +- + +## Testing +- [ ] Not run (explain why) +- [ ] `npm run lint` +- [ ] `npm run typecheck` +- [ ] `npm run test` +- [ ] `npm run e2e` + +## AI-assisted +- [ ] AI-assisted (briefly describe what and include prompts/logs if helpful) +``` + +## Minimal issue template +```md +## Summary + +## Steps to reproduce +1. + +## Expected + +## Actual + +## Environment +- OS: +- Node: +- UI version/commit: +- Gateway running? (yes/no) + +## Logs/screenshots +``` diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..e5d87d92 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 George Pickett + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 5663f29d..6049ce73 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,102 @@ -# clawdbot-agent-ui +# OpenClaw Studio -Agent Canvas UI for Clawdbot. +![Read Me Image](readme-image.png) +[![Discord](https://img.shields.io/badge/Discord-Join-5865F2?logo=discord&logoColor=white)](https://discord.gg/GAr9Qfem) -## Thinking traces +When you run multiple agents, you need a place to see what's happening. -When a model sends `message.content[]` entries with `type: "thinking"` (or embeds -`<thinking>...</thinking>` tags), the UI renders those traces before the assistant -response. After the response completes, traces collapse into a single toggleable -"Thinking" block so they can be reopened on demand. The Thinking selector on each -tile controls whether the model emits these traces. +OpenClaw Studio is that place. It's the visual interface for the OpenClaw ecosystem—designed for people who coordinate agents, track long-running tasks, and need to stay oriented when the work gets complex. + +Join the Discord: [https://discord.gg/GAr9Qfem](https://discord.gg/GAr9Qfem). I'm also looking for contributors who want to help shape OpenClaw Studio. + +The terminal is good for single commands. But agents don't work in single commands. They work in threads. They share context. They produce files that evolve. They run in parallel, and you need to know what's running where. + +OpenClaw Studio solves this. It's a Next.js app that connects to your OpenClaw gateway, streams everything live, and edits agent files through the gateway tool API. The interface is simple enough to feel obvious, powerful enough to handle real work. + +## What it does + +- Shows you every agent at a glance +- Runs a focused agent-management UI (fleet list + primary agent + inspect sidebar) +- Reads and edits agent files (AGENTS.md, MEMORY.md, etc.) via the gateway +- Streams tool output in real time +- Provisions Discord channels when you need them +- Stores only UI settings locally—no external database + +This is where multi-agent work happens. + +## Requirements + +- Node.js (LTS recommended) +- OpenClaw installed with gateway running +- git in PATH +- macOS or Linux; Windows via WSL2 + +## Quick start +```bash +git clone https://github.com/grp06/openclaw-studio.git +cd openclaw-studio +npm install +npm run dev +``` + +Open http://localhost:3000 + +The UI reads config from `~/.openclaw` by default (falls back to `~/.moltbot` or `~/.clawdbot` if you're migrating). +Only create a `.env` if you need to override those defaults: +```bash +cp .env.example .env +``` + +## Agent files + +Agent files live on the **gateway** and are accessed through `POST /tools/invoke`. +The gateway build must expose the coding tools (`read`, `write`, `edit`, `apply_patch`) on that endpoint. +If you see `Tool not available: read`, you are running a gateway build that does **not** include coding tools for `/tools/invoke`. + +If you have restrictive tool allowlists configured, ensure the agent/tool policy permits: +`read`, `write`, `edit`, and `apply_patch`. + +## Configuration + +Your gateway config lives in `openclaw.json` in your state directory. Defaults: +- State dir: `~/.openclaw` +- Config: `~/.openclaw/openclaw.json` +- Gateway URL: `ws://127.0.0.1:18789` + +Studio stores its own settings locally at `~/.openclaw/openclaw-studio/settings.json` (gateway URL/token + focused preferences). + +Optional overrides: +- `OPENCLAW_STATE_DIR` +- `OPENCLAW_CONFIG_PATH` +- `NEXT_PUBLIC_GATEWAY_URL` +- `CLAWDBOT_DEFAULT_AGENT_ID` + +To use a dedicated state dir during development: +```bash +OPENCLAW_STATE_DIR=~/openclaw-dev npm run dev +``` + +## Windows (WSL2) + +Run both OpenClaw Studio and OpenClaw inside the same WSL2 distro. Use the WSL shell for Node, the gateway, and the UI. Access it from Windows at http://localhost:3000. + +## Scripts + +- `npm run dev` +- `npm run build` +- `npm run start` +- `npm run lint` +- `npm run typecheck` +- `npm run test` +- `npm run e2e` (requires `npx playwright install`) + +## Troubleshooting + +- **Missing config**: Run `openclaw onboard` or set `OPENCLAW_CONFIG_PATH` +- **Gateway unreachable**: Confirm the gateway is running and `NEXT_PUBLIC_GATEWAY_URL` matches +- **Auth errors**: Check `gateway.auth.token` in `openclaw.json` +- **Inspect returns 404**: Your gateway build does not expose coding tools on `/tools/invoke`, or a tool allowlist is blocking them. Update the gateway build and ensure `read`/`write`/`edit`/`apply_patch` are allowed. + +## Architecture + +See `ARCHITECTURE.md` for details on modules and data flow. diff --git a/USER.md b/USER.md deleted file mode 100644 index 302f11f2..00000000 --- a/USER.md +++ /dev/null @@ -1,4 +0,0 @@ -# USER.md - -## Preferences -- Favorite color: green diff --git a/eslint.config.mjs b/eslint.config.mjs index 2deced19..06b401b5 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -13,6 +13,9 @@ const eslintConfig = defineConfig([ "out/**", "build/**", "next-env.d.ts", + + // Vendored third-party code (kept as-is; linting it adds noise). + "src/lib/avatars/vendor/**", ]), prettier, ]); diff --git a/package-lock.json b/package-lock.json index d1a9b3cd..6d384037 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,17 @@ { - "name": "clawdbot-agent-ui", + "name": "openclaw-studio", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "clawdbot-agent-ui", + "name": "openclaw-studio", "version": "0.1.0", "dependencies": { "@multiavatar/multiavatar": "github:multiavatar/Multiavatar", + "@noble/ed25519": "^3.0.0", "@radix-ui/react-slot": "^1.2.4", "@vercel/otel": "^2.1.0", - "@xyflow/react": "^12.10.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.563.0", @@ -19,6 +19,7 @@ "react": "19.2.3", "react-dom": "19.2.3", "react-markdown": "^10.1.0", + "react-mentions-ts": "^5.4.7", "remark-gfm": "^4.0.1", "tailwind-merge": "^3.4.0", "zod": "^4.3.6" @@ -1873,6 +1874,15 @@ "node": ">= 10" } }, + "node_modules/@noble/ed25519": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-3.0.0.tgz", + "integrity": "sha512-QyteqMNm0GLqfa5SoYbSC3+Pvykwpn95Zgth4MFVSMKBB75ELl9tX1LAVsN4c3HXOrakHsF2gL4zWDAYCcsnzg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2868,55 +2878,6 @@ "assertion-error": "^2.0.1" } }, - "node_modules/@types/d3-color": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", - "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", - "license": "MIT" - }, - "node_modules/@types/d3-drag": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", - "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", - "license": "MIT", - "dependencies": { - "@types/d3-selection": "*" - } - }, - "node_modules/@types/d3-interpolate": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", - "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", - "license": "MIT", - "dependencies": { - "@types/d3-color": "*" - } - }, - "node_modules/@types/d3-selection": { - "version": "3.0.11", - "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", - "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", - "license": "MIT" - }, - "node_modules/@types/d3-transition": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", - "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", - "license": "MIT", - "dependencies": { - "@types/d3-selection": "*" - } - }, - "node_modules/@types/d3-zoom": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", - "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", - "license": "MIT", - "dependencies": { - "@types/d3-interpolate": "*", - "@types/d3-selection": "*" - } - }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -3694,38 +3655,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@xyflow/react": { - "version": "12.10.0", - "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.0.tgz", - "integrity": "sha512-eOtz3whDMWrB4KWVatIBrKuxECHqip6PfA8fTpaS2RUGVpiEAe+nqDKsLqkViVWxDGreq0lWX71Xth/SPAzXiw==", - "license": "MIT", - "dependencies": { - "@xyflow/system": "0.0.74", - "classcat": "^5.0.3", - "zustand": "^4.4.0" - }, - "peerDependencies": { - "react": ">=17", - "react-dom": ">=17" - } - }, - "node_modules/@xyflow/system": { - "version": "0.0.74", - "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.74.tgz", - "integrity": "sha512-7v7B/PkiVrkdZzSbL+inGAo6tkR/WQHHG0/jhSvLQToCsfa8YubOGmBYd1s08tpKpihdHDZFwzQZeR69QSBb4Q==", - "license": "MIT", - "dependencies": { - "@types/d3-drag": "^3.0.7", - "@types/d3-interpolate": "^3.0.4", - "@types/d3-selection": "^3.0.10", - "@types/d3-transition": "^3.0.8", - "@types/d3-zoom": "^3.0.8", - "d3-drag": "^3.0.0", - "d3-interpolate": "^3.0.1", - "d3-selection": "^3.0.0", - "d3-zoom": "^3.0.0" - } - }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -4322,12 +4251,6 @@ "url": "https://polar.sh/cva" } }, - "node_modules/classcat": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", - "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", - "license": "MIT" - }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -4455,111 +4378,6 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, - "node_modules/d3-color": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", - "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-dispatch": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", - "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-drag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", - "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", - "license": "ISC", - "dependencies": { - "d3-dispatch": "1 - 3", - "d3-selection": "3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-ease": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", - "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-interpolate": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", - "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", - "license": "ISC", - "dependencies": { - "d3-color": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-selection": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", - "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-timer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", - "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-transition": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", - "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", - "license": "ISC", - "dependencies": { - "d3-color": "1 - 3", - "d3-dispatch": "1 - 3", - "d3-ease": "1 - 3", - "d3-interpolate": "1 - 3", - "d3-timer": "1 - 3" - }, - "engines": { - "node": ">=12" - }, - "peerDependencies": { - "d3-selection": "2 - 3" - } - }, - "node_modules/d3-zoom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", - "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", - "license": "ISC", - "dependencies": { - "d3-dispatch": "1 - 3", - "d3-drag": "2 - 3", - "d3-interpolate": "1 - 3", - "d3-selection": "2 - 3", - "d3-transition": "2 - 3" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -8846,6 +8664,22 @@ "react": ">=18" } }, + "node_modules/react-mentions-ts": { + "version": "5.4.7", + "resolved": "https://registry.npmjs.org/react-mentions-ts/-/react-mentions-ts-5.4.7.tgz", + "integrity": "sha512-bTK6joPmyvLckVf1v7vE2xSSeqvL4ZwuzFvGZpt+IrtdDOdFZjiwTUBo5920kiE6WbH/v1PP81xAo6pQ0NQ0Pg==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "class-variance-authority": ">=0.6.0", + "clsx": ">=2.0.0", + "react": ">=19.0.0", + "react-dom": ">=19.0.0", + "tailwind-merge": ">=3.0.0" + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -10261,15 +10095,6 @@ "punycode": "^2.1.0" } }, - "node_modules/use-sync-external-store": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", - "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", - "license": "MIT", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", @@ -10770,34 +10595,6 @@ "zod": "^3.25.0 || ^4.0.0" } }, - "node_modules/zustand": { - "version": "4.5.7", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", - "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", - "license": "MIT", - "dependencies": { - "use-sync-external-store": "^1.2.2" - }, - "engines": { - "node": ">=12.7.0" - }, - "peerDependencies": { - "@types/react": ">=16.8", - "immer": ">=9.0.6", - "react": ">=16.8" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "immer": { - "optional": true - }, - "react": { - "optional": true - } - } - }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/package.json b/package.json index aa5c4c9e..795ff3aa 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "clawdbot-agent-ui", + "name": "openclaw-studio", "version": "0.1.0", "private": true, "scripts": { @@ -7,15 +7,17 @@ "build": "next build", "start": "next start", "lint": "eslint .", + "sync:gateway-client": "node scripts/sync-openclaw-gateway-client.ts", + "migrate:architecture": "node scripts/migrate-architecture.ts", "typecheck": "tsc --noEmit", "test": "vitest", "e2e": "playwright test" }, "dependencies": { "@multiavatar/multiavatar": "github:multiavatar/Multiavatar", + "@noble/ed25519": "^3.0.0", "@radix-ui/react-slot": "^1.2.4", "@vercel/otel": "^2.1.0", - "@xyflow/react": "^12.10.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.563.0", @@ -23,6 +25,7 @@ "react": "19.2.3", "react-dom": "19.2.3", "react-markdown": "^10.1.0", + "react-mentions-ts": "^5.4.7", "remark-gfm": "^4.0.1", "tailwind-merge": "^3.4.0", "zod": "^4.3.6" diff --git a/playwright.config.ts b/playwright.config.ts index e3accfa8..64588f75 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -5,7 +5,7 @@ export default defineConfig({ baseURL: "http://127.0.0.1:3000", }, webServer: { - command: "npm run dev", + command: "NEXT_PUBLIC_GATEWAY_URL= npm run dev", port: 3000, reuseExistingServer: !process.env.CI, }, diff --git a/readme-image.png b/readme-image.png new file mode 100644 index 00000000..89f6ae0b Binary files /dev/null and b/readme-image.png differ diff --git a/scripts/migrate-architecture.ts b/scripts/migrate-architecture.ts new file mode 100644 index 00000000..c388deab --- /dev/null +++ b/scripts/migrate-architecture.ts @@ -0,0 +1,468 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; + +const WORKSPACE_FILE_NAMES = [ + "AGENTS.md", + "SOUL.md", + "IDENTITY.md", + "USER.md", + "HEARTBEAT.md", + "TOOLS.md", + "MEMORY.md", +]; +const WORKSPACE_IGNORE_ENTRIES = [...WORKSPACE_FILE_NAMES, "memory/"]; +const STORE_VERSION = 3; +const LEGACY_STATE_DIRNAMES = [".clawdbot", ".moltbot"]; +const NEW_STATE_DIRNAME = ".openclaw"; +const CONFIG_FILENAME = "openclaw.json"; +const LEGACY_CONFIG_FILENAMES = ["clawdbot.json", "moltbot.json"]; + +const resolveUserPath = (input: string, homedir: () => string = os.homedir) => { + const trimmed = input.trim(); + if (!trimmed) return trimmed; + if (trimmed.startsWith("~")) { + const expanded = trimmed.replace(/^~(?=$|[\\/])/, homedir()); + return path.resolve(expanded); + } + return path.resolve(trimmed); +}; + +const resolveStateDir = (env = process.env, homedir = os.homedir) => { + const override = + env.OPENCLAW_STATE_DIR?.trim() || + env.MOLTBOT_STATE_DIR?.trim() || + env.CLAWDBOT_STATE_DIR?.trim(); + if (override) return resolveUserPath(override, homedir); + const newDir = path.join(homedir(), NEW_STATE_DIRNAME); + const legacyDirs = LEGACY_STATE_DIRNAMES.map((dir) => path.join(homedir(), dir)); + if (fs.existsSync(newDir)) return newDir; + const existingLegacy = legacyDirs.find((dir) => { + try { + return fs.existsSync(dir); + } catch { + return false; + } + }); + return existingLegacy ?? newDir; +}; + +const resolveConfigPathCandidates = (env = process.env, homedir = os.homedir) => { + const explicit = + env.OPENCLAW_CONFIG_PATH?.trim() || + env.MOLTBOT_CONFIG_PATH?.trim() || + env.CLAWDBOT_CONFIG_PATH?.trim(); + if (explicit) return [resolveUserPath(explicit, homedir)]; + + const candidates = []; + const stateDir = + env.OPENCLAW_STATE_DIR?.trim() || + env.MOLTBOT_STATE_DIR?.trim() || + env.CLAWDBOT_STATE_DIR?.trim(); + if (stateDir) { + const resolved = resolveUserPath(stateDir, homedir); + candidates.push(path.join(resolved, CONFIG_FILENAME)); + candidates.push(...LEGACY_CONFIG_FILENAMES.map((name) => path.join(resolved, name))); + } + + const defaultDirs = [ + path.join(homedir(), NEW_STATE_DIRNAME), + ...LEGACY_STATE_DIRNAMES.map((dir) => path.join(homedir(), dir)), + ]; + for (const dir of defaultDirs) { + candidates.push(path.join(dir, CONFIG_FILENAME)); + candidates.push(...LEGACY_CONFIG_FILENAMES.map((name) => path.join(dir, name))); + } + return candidates; +}; + +const resolveAgentCanvasDir = () => path.join(resolveStateDir(), "openclaw-studio"); + +const resolveAgentWorktreeDir = (projectId: string, agentId: string) => + path.join(resolveAgentCanvasDir(), "worktrees", projectId, agentId); + +const parseAgentIdFromSessionKey = (sessionKey: string, fallback = "main") => { + const match = sessionKey.match(/^agent:([^:]+):/); + return match ? match[1] : fallback; +}; + +const parseJsonLoose = (raw: string) => { + try { + return JSON.parse(raw); + } catch { + const cleaned = raw.replace(/,(\s*[}\]])/g, "$1"); + return JSON.parse(cleaned); + } +}; + +const loadStore = (storePath: string) => { + const raw = fs.readFileSync(storePath, "utf8"); + const parsed = JSON.parse(raw); + if (!parsed || !Array.isArray(parsed.projects)) { + throw new Error(`Workspaces store is invalid at ${storePath}.`); + } + return parsed; +}; + +const normalizeStore = (store: { projects?: unknown[]; activeProjectId?: unknown }) => { + const projects = Array.isArray(store.projects) + ? (store.projects as Array<Record<string, unknown>>) + : []; + const normalizedProjects = projects.map((project) => { + const projectId = typeof project.id === "string" ? project.id : ""; + const tiles = Array.isArray(project.tiles) ? project.tiles : []; + return { + id: projectId, + name: typeof project.name === "string" ? project.name : "", + repoPath: typeof project.repoPath === "string" ? project.repoPath : "", + createdAt: typeof project.createdAt === "number" ? project.createdAt : Date.now(), + updatedAt: typeof project.updatedAt === "number" ? project.updatedAt : Date.now(), + archivedAt: typeof project.archivedAt === "number" ? project.archivedAt : null, + tiles: tiles.map((tile) => { + const agentId = + typeof tile.agentId === "string" && tile.agentId.trim() + ? tile.agentId.trim() + : parseAgentIdFromSessionKey( + typeof tile.sessionKey === "string" ? tile.sessionKey : "" + ); + return { + ...tile, + agentId, + role: typeof tile.role === "string" ? tile.role : "coding", + workspacePath: + typeof tile.workspacePath === "string" && tile.workspacePath.trim() + ? tile.workspacePath + : resolveAgentWorktreeDir(projectId, agentId), + archivedAt: typeof tile.archivedAt === "number" ? tile.archivedAt : null, + }; + }), + }; + }); + const activeProjectId = + typeof store.activeProjectId === "string" && + normalizedProjects.some( + (project) => project.id === store.activeProjectId && !project.archivedAt + ) + ? store.activeProjectId + : normalizedProjects.find((project) => !project.archivedAt)?.id ?? null; + return { + version: STORE_VERSION, + activeProjectId, + projects: normalizedProjects, + }; +}; + +const resolveGitDir = (worktreeDir: string) => { + const gitPath = path.join(worktreeDir, ".git"); + const stat = fs.statSync(gitPath); + if (stat.isDirectory()) { + return gitPath; + } + if (!stat.isFile()) { + throw new Error(`.git is not a file or directory at ${gitPath}`); + } + const raw = fs.readFileSync(gitPath, "utf8"); + const match = raw.trim().match(/^gitdir:\s*(.+)$/i); + if (!match || !match[1]) { + throw new Error(`Unable to resolve gitdir from ${gitPath}`); + } + return path.resolve(worktreeDir, match[1].trim()); +}; + +const ensureWorktreeIgnores = (worktreeDir: string, files: string[]) => { + if (files.length === 0) return; + const gitDir = resolveGitDir(worktreeDir); + const infoDir = path.join(gitDir, "info"); + fs.mkdirSync(infoDir, { recursive: true }); + const excludePath = path.join(infoDir, "exclude"); + const existing = fs.existsSync(excludePath) ? fs.readFileSync(excludePath, "utf8") : ""; + const lines = existing.split(/\r?\n/); + const additions = files.filter((entry) => !lines.includes(entry)); + if (additions.length === 0) return; + let next = existing; + if (next.length > 0 && !next.endsWith("\n")) { + next += "\n"; + } + next += `${additions.join("\n")}\n`; + fs.writeFileSync(excludePath, next, "utf8"); +}; + +const ensureAgentWorktree = (repoPath: string, worktreeDir: string, branchName: string) => { + const trimmedRepo = repoPath.trim(); + if (!trimmedRepo) { + throw new Error("Repository path is required."); + } + if (!fs.existsSync(trimmedRepo)) { + throw new Error(`Repository path does not exist: ${trimmedRepo}`); + } + const repoStat = fs.statSync(trimmedRepo); + if (!repoStat.isDirectory()) { + throw new Error(`Repository path is not a directory: ${trimmedRepo}`); + } + if (!fs.existsSync(path.join(trimmedRepo, ".git"))) { + throw new Error(`Repository is missing a .git directory: ${trimmedRepo}`); + } + + if (fs.existsSync(worktreeDir)) { + const stat = fs.statSync(worktreeDir); + if (!stat.isDirectory()) { + throw new Error(`Worktree path is not a directory: ${worktreeDir}`); + } + if (!fs.existsSync(path.join(worktreeDir, ".git"))) { + throw new Error(`Existing worktree is missing .git at ${worktreeDir}`); + } + return; + } + + fs.mkdirSync(path.dirname(worktreeDir), { recursive: true }); + const branchCheck = spawnSync("git", ["rev-parse", "--verify", branchName], { + cwd: trimmedRepo, + encoding: "utf8", + }); + const args = + branchCheck.status === 0 + ? ["worktree", "add", worktreeDir, branchName] + : ["worktree", "add", "-b", branchName, worktreeDir]; + const result = spawnSync("git", args, { cwd: trimmedRepo, encoding: "utf8" }); + if (result.status !== 0) { + const stderr = result.stderr?.trim(); + throw new Error( + stderr + ? `git worktree add failed for ${worktreeDir}: ${stderr}` + : `git worktree add failed for ${worktreeDir}.` + ); + } +}; + +const ensureWorkspaceFiles = (workspaceDir: string) => { + fs.mkdirSync(workspaceDir, { recursive: true }); + for (const name of WORKSPACE_FILE_NAMES) { + const filePath = path.join(workspaceDir, name); + if (!fs.existsSync(filePath)) { + fs.writeFileSync(filePath, "", "utf8"); + } + } + fs.mkdirSync(path.join(workspaceDir, "memory"), { recursive: true }); +}; + +const copyWorkspaceFile = (fromPath: string, toPath: string) => { + if (!fs.existsSync(fromPath)) return false; + if (fs.existsSync(toPath)) { + const current = fs.readFileSync(toPath, "utf8"); + if (current.trim()) return false; + } + fs.copyFileSync(fromPath, toPath); + return true; +}; + +const copyWorkspaceMemory = (fromDir: string, toDir: string) => { + if (!fs.existsSync(fromDir)) return 0; + fs.mkdirSync(toDir, { recursive: true }); + let copied = 0; + for (const entry of fs.readdirSync(fromDir, { withFileTypes: true })) { + const fromPath = path.join(fromDir, entry.name); + const toPath = path.join(toDir, entry.name); + if (entry.isDirectory()) { + copied += copyWorkspaceMemory(fromPath, toPath); + } else if (!fs.existsSync(toPath)) { + fs.copyFileSync(fromPath, toPath); + copied += 1; + } + } + return copied; +}; + +const reserveLegacyPath = (targetPath: string) => { + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + let candidate = `${targetPath}.legacy-${stamp}`; + let suffix = 1; + while (fs.existsSync(candidate)) { + candidate = `${targetPath}.legacy-${stamp}-${suffix}`; + suffix += 1; + } + return candidate; +}; + +const ensureLegacyWorktreeSlot = (worktreeDir: string) => { + if (!fs.existsSync(worktreeDir)) return null; + if (fs.existsSync(path.join(worktreeDir, ".git"))) return null; + const legacyPath = reserveLegacyPath(worktreeDir); + fs.renameSync(worktreeDir, legacyPath); + return legacyPath; +}; + +const readAgentList = (config: { + agents?: { list?: Array<{ id?: string; name?: string; workspace?: string }> }; +}) => { + const agents = config.agents ?? {}; + const list = Array.isArray(agents.list) ? agents.list : []; + return list.filter((entry) => Boolean(entry && typeof entry === "object")); +}; + +const writeAgentList = ( + config: { agents?: { list?: Array<{ id?: string; name?: string; workspace?: string }> } }, + list: Array<{ id?: string; name?: string; workspace?: string }> +) => { + const agents = config.agents ?? {}; + agents.list = list; + config.agents = agents; +}; + +const upsertAgentEntry = ( + config: { agents?: { list?: Array<{ id?: string; name?: string; workspace?: string }> } }, + entry: { agentId: string; agentName: string; workspaceDir: string } +) => { + const list = readAgentList(config); + let changed = false; + let found = false; + const next = list.map((item) => { + if (item.id !== entry.agentId) return item; + found = true; + const nextItem = { ...item }; + if (entry.agentName && entry.agentName !== item.name) { + nextItem.name = entry.agentName; + changed = true; + } + if (entry.workspaceDir !== item.workspace) { + nextItem.workspace = entry.workspaceDir; + changed = true; + } + return nextItem; + }); + if (!found) { + next.push({ id: entry.agentId, name: entry.agentName, workspace: entry.workspaceDir }); + changed = true; + } + if (changed) { + writeAgentList(config, next); + } + return changed; +}; + +const loadClawdbotConfig = () => { + const candidates = resolveConfigPathCandidates(); + const fallbackPath = path.join(resolveStateDir(), CONFIG_FILENAME); + const configPath = candidates.find((candidate) => fs.existsSync(candidate)) ?? fallbackPath; + if (!fs.existsSync(configPath)) { + throw new Error(`Missing config at ${configPath}.`); + } + const raw = fs.readFileSync(configPath, "utf8"); + return { config: parseJsonLoose(raw), configPath }; +}; + +const saveClawdbotConfig = (configPath: string, config: unknown) => { + fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf8"); +}; + +const migrate = () => { + const stateDir = resolveStateDir(); + const storePath = path.join(stateDir, "openclaw-studio", "projects.json"); + if (!fs.existsSync(storePath)) { + console.error(`Missing projects store at ${storePath}.`); + process.exit(1); + } + + const rawStore = loadStore(storePath); + const store = normalizeStore(rawStore); + + const backupPath = `${storePath}.backup-${Date.now()}`; + fs.copyFileSync(storePath, backupPath); + + const warnings = []; + const errors = []; + const legacyMoves = []; + + let config = null; + let configPath = ""; + let configLoaded = false; + try { + const loaded = loadClawdbotConfig(); + config = loaded.config; + configPath = loaded.configPath; + configLoaded = true; + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to load config."; + warnings.push(`Agent config not updated: ${message}`); + } + + for (const project of store.projects) { + const repoPath = typeof project.repoPath === "string" ? project.repoPath : ""; + for (const tile of project.tiles) { + const agentId = + typeof tile.agentId === "string" && tile.agentId.trim() + ? tile.agentId.trim() + : parseAgentIdFromSessionKey(tile.sessionKey ?? ""); + const worktreeDir = resolveAgentWorktreeDir(project.id, agentId); + if (tile.workspacePath !== worktreeDir) { + tile.workspacePath = worktreeDir; + } + const branchName = `agent/${agentId}`; + let legacyPath = null; + try { + legacyPath = ensureLegacyWorktreeSlot(worktreeDir); + if (legacyPath) { + legacyMoves.push({ from: legacyPath, to: worktreeDir }); + } + ensureAgentWorktree(repoPath, worktreeDir, branchName); + ensureWorktreeIgnores(worktreeDir, WORKSPACE_IGNORE_ENTRIES); + ensureWorkspaceFiles(worktreeDir); + if (legacyPath) { + for (const name of WORKSPACE_FILE_NAMES) { + const fromPath = path.join(legacyPath, name); + const toPath = path.join(worktreeDir, name); + copyWorkspaceFile(fromPath, toPath); + } + copyWorkspaceMemory(path.join(legacyPath, "memory"), path.join(worktreeDir, "memory")); + } + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error."; + errors.push(`Worktree migration failed for ${project.id}/${agentId}: ${message}`); + continue; + } + + if (configLoaded && config) { + try { + upsertAgentEntry(config, { + agentId, + agentName: tile.name ?? agentId, + workspaceDir: worktreeDir, + }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error."; + warnings.push(`Failed to update agent config for ${agentId}: ${message}`); + } + } + } + } + + fs.writeFileSync(storePath, JSON.stringify(store, null, 2), "utf8"); + + if (configLoaded && config && configPath) { + saveClawdbotConfig(configPath, config); + } + + if (legacyMoves.length > 0) { + console.log("Legacy workspace directories renamed:"); + for (const move of legacyMoves) { + console.log(` ${move.from} -> ${move.to}`); + } + } + if (warnings.length > 0) { + console.log("Warnings:"); + for (const warning of warnings) { + console.log(` - ${warning}`); + } + } + if (errors.length > 0) { + console.error("Migration errors:"); + for (const error of errors) { + console.error(` - ${error}`); + } + process.exitCode = 1; + } + + console.log(`Migration complete. Store backup: ${backupPath}`); +}; + +migrate(); diff --git a/scripts/sync-openclaw-gateway-client.ts b/scripts/sync-openclaw-gateway-client.ts new file mode 100644 index 00000000..93de866c --- /dev/null +++ b/scripts/sync-openclaw-gateway-client.ts @@ -0,0 +1,34 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const repoRoot = process.cwd(); +const sourcePath = path.join(os.homedir(), "clawdbot", "ui", "src", "ui", "gateway.ts"); +const destPath = path.join( + repoRoot, + "src", + "lib", + "gateway", + "openclaw", + "GatewayBrowserClient.ts" +); + +if (!fs.existsSync(sourcePath)) { + console.error(`Missing upstream gateway client at ${sourcePath}.`); + process.exit(1); +} + +let contents = fs.readFileSync(sourcePath, "utf8"); +contents = contents + .replace( + /from "\.\.\/\.\.\/\.\.\/src\/gateway\/protocol\/client-info\.js";/g, + 'from "./client-info";' + ) + .replace( + /from "\.\.\/\.\.\/\.\.\/src\/gateway\/device-auth\.js";/g, + 'from "./device-auth-payload";' + ); + +fs.mkdirSync(path.dirname(destPath), { recursive: true }); +fs.writeFileSync(destPath, contents, "utf8"); +console.log(`Synced gateway client to ${destPath}.`); diff --git a/src/app/api/cron/route.ts b/src/app/api/cron/route.ts new file mode 100644 index 00000000..b4c98120 --- /dev/null +++ b/src/app/api/cron/route.ts @@ -0,0 +1,107 @@ +import { NextResponse } from "next/server"; + +import fs from "node:fs"; +import path from "node:path"; + +import { resolveStateDir } from "@/lib/clawdbot/paths"; +import { logger } from "@/lib/logger"; +import type { CronJobsResult, CronJobSummary, CronPayload, CronSchedule } from "@/lib/cron/types"; + +export const runtime = "nodejs"; + +type RawCronStore = { + jobs?: unknown; +}; + +const coerceString = (value: unknown) => (typeof value === "string" ? value : ""); +const coerceNumber = (value: unknown) => + typeof value === "number" && Number.isFinite(value) ? value : null; +const coerceBoolean = (value: unknown) => (typeof value === "boolean" ? value : null); + +const coerceSchedule = (value: unknown): CronSchedule | null => { + if (!value || typeof value !== "object") return null; + const record = value as Record<string, unknown>; + const kind = coerceString(record.kind); + if (kind === "every") { + const everyMs = coerceNumber(record.everyMs); + if (everyMs === null) return null; + const anchorMs = coerceNumber(record.anchorMs ?? null) ?? undefined; + return anchorMs ? { kind, everyMs, anchorMs } : { kind, everyMs }; + } + if (kind === "cron") { + const expr = coerceString(record.expr); + if (!expr) return null; + const tz = coerceString(record.tz); + return tz ? { kind, expr, tz } : { kind, expr }; + } + if (kind === "at") { + const atMs = coerceNumber(record.atMs); + if (atMs === null) return null; + return { kind, atMs }; + } + return null; +}; + +const coercePayload = (value: unknown): CronPayload | null => { + if (!value || typeof value !== "object") return null; + const record = value as Record<string, unknown>; + const kind = coerceString(record.kind); + if (kind === "systemEvent") { + const text = coerceString(record.text); + if (!text) return null; + return { kind, text }; + } + if (kind === "agentTurn") { + const message = coerceString(record.message); + if (!message) return null; + return { kind, message }; + } + return null; +}; + +const coerceJob = (value: unknown): CronJobSummary | null => { + if (!value || typeof value !== "object") return null; + const record = value as Record<string, unknown>; + const id = coerceString(record.id); + const name = coerceString(record.name); + const enabled = coerceBoolean(record.enabled); + const updatedAtMs = coerceNumber(record.updatedAtMs); + const schedule = coerceSchedule(record.schedule); + const payload = coercePayload(record.payload); + if (!id || !name || enabled === null || updatedAtMs === null || !schedule || !payload) { + return null; + } + const agentId = coerceString(record.agentId); + const sessionTarget = coerceString(record.sessionTarget); + return { + id, + name, + enabled, + updatedAtMs, + schedule, + payload, + ...(agentId ? { agentId } : {}), + ...(sessionTarget ? { sessionTarget } : {}), + }; +}; + +export async function GET() { + try { + const cronPath = path.join(resolveStateDir(), "cron", "jobs.json"); + if (!fs.existsSync(cronPath)) { + const result: CronJobsResult = { jobs: [] }; + return NextResponse.json(result); + } + const raw = fs.readFileSync(cronPath, "utf8"); + const parsed = JSON.parse(raw) as RawCronStore; + const jobs = Array.isArray(parsed?.jobs) + ? parsed.jobs.map(coerceJob).filter((job): job is CronJobSummary => Boolean(job)) + : []; + const result: CronJobsResult = { jobs }; + return NextResponse.json(result); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to load cron jobs."; + logger.error(message); + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/gateway/route.ts b/src/app/api/gateway/route.ts deleted file mode 100644 index bee4f81f..00000000 --- a/src/app/api/gateway/route.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { NextResponse } from "next/server"; - -import { loadClawdbotConfig } from "@/lib/clawdbot/config"; -import { resolveGatewayConfig } from "@/lib/clawdbot/gateway"; -import { logger } from "@/lib/logger"; - -export const runtime = "nodejs"; - -export async function GET() { - try { - const { config } = loadClawdbotConfig(); - const { gatewayUrl, token } = resolveGatewayConfig(config); - return NextResponse.json({ gatewayUrl, token }); - } catch (err) { - const message = err instanceof Error ? err.message : "Failed to load gateway config."; - if (message.startsWith("Missing config at")) { - return NextResponse.json({ error: message }, { status: 404 }); - } - logger.error(message); - return NextResponse.json({ error: message }, { status: 500 }); - } -} diff --git a/src/app/api/gateway/tools/route.ts b/src/app/api/gateway/tools/route.ts new file mode 100644 index 00000000..7024c353 --- /dev/null +++ b/src/app/api/gateway/tools/route.ts @@ -0,0 +1,90 @@ +import { NextResponse } from "next/server"; + +import { toGatewayHttpUrl } from "@/lib/gateway/url"; +import { logger } from "@/lib/logger"; +import { loadStudioSettings } from "@/lib/studio/settings.server"; + +export const runtime = "nodejs"; + +type ToolsInvokePayload = { + tool?: unknown; + action?: unknown; + args?: unknown; + sessionKey?: unknown; + dryRun?: unknown; +}; + +const isRecord = (value: unknown): value is Record<string, unknown> => + Boolean(value && typeof value === "object" && !Array.isArray(value)); + +const coerceString = (value: unknown) => + typeof value === "string" ? value.trim() : ""; + +export async function POST(request: Request) { + try { + const body = (await request.json()) as ToolsInvokePayload; + if (!isRecord(body)) { + return NextResponse.json({ error: "Invalid tool payload." }, { status: 400 }); + } + const tool = coerceString(body.tool); + if (!tool) { + return NextResponse.json({ error: "Tool name is required." }, { status: 400 }); + } + const settings = loadStudioSettings(); + const gatewayUrl = settings.gateway?.url?.trim() ?? ""; + if (!gatewayUrl) { + return NextResponse.json({ error: "Gateway URL is not configured." }, { status: 400 }); + } + const httpBase = toGatewayHttpUrl(gatewayUrl).replace(/\/$/, ""); + const target = `${httpBase}/tools/invoke`; + const payload: Record<string, unknown> = { tool }; + const action = coerceString(body.action); + if (action) payload.action = action; + if (isRecord(body.args)) payload.args = body.args; + const sessionKey = coerceString(body.sessionKey); + if (sessionKey) payload.sessionKey = sessionKey; + if (typeof body.dryRun === "boolean") payload.dryRun = body.dryRun; + + const headers: HeadersInit = { "Content-Type": "application/json" }; + const token = settings.gateway?.token?.trim(); + if (token) headers.Authorization = `Bearer ${token}`; + + const res = await fetch(target, { + method: "POST", + headers, + body: JSON.stringify(payload), + }); + const text = await res.text(); + let data: unknown = null; + if (text) { + try { + data = JSON.parse(text); + } catch { + data = null; + } + } + if (!res.ok) { + const error = + data && typeof data === "object" && "error" in data + ? (data as Record<string, unknown>).error + : null; + const message = + error && typeof error === "object" && "message" in error + ? String((error as Record<string, unknown>).message) + : `Gateway tool invoke failed with status ${res.status}.`; + logger.error(message); + return NextResponse.json({ error: message }, { status: res.status }); + } + if (!data || typeof data !== "object") { + return NextResponse.json( + { error: "Gateway tool response was invalid." }, + { status: 502 } + ); + } + return NextResponse.json(data); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to invoke gateway tool."; + logger.error(message); + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/path-suggestions/route.ts b/src/app/api/path-suggestions/route.ts new file mode 100644 index 00000000..39fc68fd --- /dev/null +++ b/src/app/api/path-suggestions/route.ts @@ -0,0 +1,22 @@ +import { NextResponse } from "next/server"; + +import { logger } from "@/lib/logger"; +import { listPathAutocompleteEntries } from "@/lib/fs.server"; + +export const runtime = "nodejs"; + +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url); + const rawQuery = searchParams.get("q"); + const query = rawQuery && rawQuery.trim() ? rawQuery.trim() : "~/"; + const result = listPathAutocompleteEntries({ query, maxResults: 10 }); + return NextResponse.json(result); + } catch (err) { + const message = + err instanceof Error ? err.message : "Failed to list path suggestions."; + logger.error(message); + const status = message.includes("does not exist") ? 404 : 400; + return NextResponse.json({ error: message }, { status }); + } +} diff --git a/src/app/api/projects/[projectId]/discord/route.ts b/src/app/api/projects/[projectId]/discord/route.ts deleted file mode 100644 index 6eba158a..00000000 --- a/src/app/api/projects/[projectId]/discord/route.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { NextResponse } from "next/server"; - -import { logger } from "@/lib/logger"; -import { createDiscordChannelForAgent } from "@/lib/discord/discordChannel"; -import { resolveAgentWorkspaceDir } from "@/lib/projects/agentWorkspace"; -import { loadStore } from "../../store"; - -export const runtime = "nodejs"; - -type DiscordChannelRequest = { - guildId?: string; - agentId: string; - agentName: string; -}; - -export async function POST( - request: Request, - context: { params: Promise<{ projectId: string }> } -) { - try { - const { projectId } = await context.params; - const trimmedProjectId = projectId.trim(); - if (!trimmedProjectId) { - return NextResponse.json({ error: "Workspace id is required." }, { status: 400 }); - } - const body = (await request.json()) as DiscordChannelRequest; - const guildId = typeof body?.guildId === "string" ? body.guildId.trim() : undefined; - const agentId = typeof body?.agentId === "string" ? body.agentId.trim() : ""; - const agentName = typeof body?.agentName === "string" ? body.agentName.trim() : ""; - if (!agentId || !agentName) { - return NextResponse.json( - { error: "Agent id and name are required." }, - { status: 400 } - ); - } - - const store = loadStore(); - const project = store.projects.find((entry) => entry.id === trimmedProjectId); - if (!project) { - return NextResponse.json({ error: "Workspace not found." }, { status: 404 }); - } - - const workspaceDir = resolveAgentWorkspaceDir(trimmedProjectId, agentId); - const result = await createDiscordChannelForAgent({ - agentId, - agentName, - guildId: guildId || undefined, - workspaceDir, - }); - - return NextResponse.json(result); - } catch (err) { - const message = err instanceof Error ? err.message : "Failed to create Discord channel."; - logger.error(message); - return NextResponse.json({ error: message }, { status: 500 }); - } -} diff --git a/src/app/api/projects/[projectId]/route.ts b/src/app/api/projects/[projectId]/route.ts deleted file mode 100644 index accc6c40..00000000 --- a/src/app/api/projects/[projectId]/route.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { NextResponse } from "next/server"; - -import { logger } from "@/lib/logger"; -import { - loadClawdbotConfig, - removeAgentEntry, - saveClawdbotConfig, -} from "@/lib/clawdbot/config"; -import { deleteAgentArtifacts } from "@/lib/projects/fs.server"; -import { loadStore, saveStore } from "../store"; - -export const runtime = "nodejs"; - -export async function DELETE( - _request: Request, - context: { params: Promise<{ projectId: string }> } -) { - try { - const { projectId } = await context.params; - const trimmedProjectId = projectId.trim(); - if (!trimmedProjectId) { - return NextResponse.json({ error: "Workspace id is required." }, { status: 400 }); - } - const store = loadStore(); - const project = store.projects.find((entry) => entry.id === trimmedProjectId); - if (!project) { - return NextResponse.json({ error: "Workspace not found." }, { status: 404 }); - } - - const warnings: string[] = []; - let configInfo: { config: Record<string, unknown>; configPath: string } | null = null; - try { - configInfo = loadClawdbotConfig(); - } catch (err) { - const message = - err instanceof Error ? err.message : "Failed to update clawdbot.json."; - warnings.push(`Agent config not updated: ${message}`); - } - for (const tile of project.tiles) { - if (!tile.agentId?.trim()) { - warnings.push(`Missing agentId for tile ${tile.id}; skipped agent cleanup.`); - continue; - } - deleteAgentArtifacts(trimmedProjectId, tile.agentId, warnings); - if (configInfo) { - removeAgentEntry(configInfo.config, tile.agentId); - } - } - if (configInfo) { - saveClawdbotConfig(configInfo.configPath, configInfo.config); - } - - const projects = store.projects.filter((project) => project.id !== trimmedProjectId); - const activeProjectId = - store.activeProjectId === trimmedProjectId - ? projects[0]?.id ?? null - : store.activeProjectId; - const nextStore = { - version: 2 as const, - activeProjectId, - projects, - }; - saveStore(nextStore); - return NextResponse.json({ store: nextStore, warnings }); - } catch (err) { - const message = err instanceof Error ? err.message : "Failed to delete workspace."; - logger.error(message); - return NextResponse.json({ error: message }, { status: 500 }); - } -} diff --git a/src/app/api/projects/[projectId]/tiles/[tileId]/route.ts b/src/app/api/projects/[projectId]/tiles/[tileId]/route.ts deleted file mode 100644 index e26b498c..00000000 --- a/src/app/api/projects/[projectId]/tiles/[tileId]/route.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { NextResponse } from "next/server"; - -import { logger } from "@/lib/logger"; -import type { ProjectTileUpdatePayload } from "@/lib/projects/types"; -import { deleteAgentArtifacts } from "@/lib/projects/fs.server"; -import { - loadClawdbotConfig, - removeAgentEntry, - saveClawdbotConfig, - upsertAgentEntry, -} from "@/lib/clawdbot/config"; -import { loadStore, saveStore } from "../../../store"; - -export const runtime = "nodejs"; - -export async function DELETE( - _request: Request, - context: { params: Promise<{ projectId: string; tileId: string }> } -) { - try { - const { projectId, tileId } = await context.params; - const trimmedProjectId = projectId.trim(); - const trimmedTileId = tileId.trim(); - if (!trimmedProjectId || !trimmedTileId) { - return NextResponse.json( - { error: "Workspace id and tile id are required." }, - { status: 400 } - ); - } - const store = loadStore(); - const project = store.projects.find((entry) => entry.id === trimmedProjectId); - if (!project) { - return NextResponse.json({ error: "Workspace not found." }, { status: 404 }); - } - const tile = project.tiles.find((entry) => entry.id === trimmedTileId); - if (!tile) { - return NextResponse.json({ error: "Tile not found." }, { status: 404 }); - } - - const warnings: string[] = []; - if (!tile.agentId?.trim()) { - warnings.push(`Missing agentId for tile ${tile.id}; skipped agent cleanup.`); - } else { - deleteAgentArtifacts(trimmedProjectId, tile.agentId, warnings); - try { - const { config, configPath } = loadClawdbotConfig(); - const changed = removeAgentEntry(config, tile.agentId); - if (changed) { - saveClawdbotConfig(configPath, config); - } - } catch (err) { - const message = - err instanceof Error ? err.message : "Failed to update clawdbot.json."; - warnings.push(`Agent config not updated: ${message}`); - } - } - - const nextTiles = project.tiles.filter((entry) => entry.id !== trimmedTileId); - if (nextTiles.length === project.tiles.length) { - return NextResponse.json({ error: "Tile not found." }, { status: 404 }); - } - const nextStore = { - ...store, - version: 2 as const, - projects: store.projects.map((entry) => - entry.id === trimmedProjectId - ? { ...entry, tiles: nextTiles, updatedAt: Date.now() } - : entry - ), - }; - saveStore(nextStore); - return NextResponse.json({ store: nextStore, warnings }); - } catch (err) { - const message = err instanceof Error ? err.message : "Failed to delete tile."; - logger.error(message); - return NextResponse.json({ error: message }, { status: 500 }); - } -} - -export async function PATCH( - request: Request, - context: { params: Promise<{ projectId: string; tileId: string }> } -) { - try { - const { projectId, tileId } = await context.params; - const trimmedProjectId = projectId.trim(); - const trimmedTileId = tileId.trim(); - if (!trimmedProjectId || !trimmedTileId) { - return NextResponse.json( - { error: "Workspace id and tile id are required." }, - { status: 400 } - ); - } - const body = (await request.json()) as ProjectTileUpdatePayload; - const name = typeof body?.name === "string" ? body.name.trim() : ""; - const avatarSeed = - typeof body?.avatarSeed === "string" ? body.avatarSeed.trim() : ""; - if (!name && !avatarSeed) { - return NextResponse.json( - { error: "Tile update requires a name or avatar seed." }, - { status: 400 } - ); - } - if (body?.avatarSeed !== undefined && !avatarSeed) { - return NextResponse.json({ error: "Avatar seed is invalid." }, { status: 400 }); - } - - const store = loadStore(); - const project = store.projects.find((entry) => entry.id === trimmedProjectId); - if (!project) { - return NextResponse.json({ error: "Workspace not found." }, { status: 404 }); - } - const tile = project.tiles.find((entry) => entry.id === trimmedTileId); - if (!tile) { - return NextResponse.json({ error: "Tile not found." }, { status: 404 }); - } - - const warnings: string[] = []; - if (name) { - const nextWorkspaceDir = resolveAgentWorkspaceDir(trimmedProjectId, tile.agentId); - try { - const { config, configPath } = loadClawdbotConfig(); - const changed = upsertAgentEntry(config, { - agentId: tile.agentId, - agentName: name, - workspaceDir: nextWorkspaceDir, - }); - if (changed) { - saveClawdbotConfig(configPath, config); - } - } catch (err) { - const message = - err instanceof Error ? err.message : "Failed to update clawdbot.json."; - warnings.push(`Agent config not updated: ${message}`); - } - } - - const nextTiles = project.tiles.map((entry) => - entry.id === trimmedTileId - ? { - ...entry, - name: name || entry.name, - avatarSeed: avatarSeed || entry.avatarSeed, - } - : entry - ); - const nextStore = { - ...store, - version: 2 as const, - projects: store.projects.map((entry) => - entry.id === trimmedProjectId - ? { ...entry, tiles: nextTiles, updatedAt: Date.now() } - : entry - ), - }; - saveStore(nextStore); - return NextResponse.json({ store: nextStore, warnings }); - } catch (err) { - const message = err instanceof Error ? err.message : "Failed to rename tile."; - logger.error(message); - return NextResponse.json({ error: message }, { status: 500 }); - } -} diff --git a/src/app/api/projects/[projectId]/tiles/[tileId]/workspace-files/route.ts b/src/app/api/projects/[projectId]/tiles/[tileId]/workspace-files/route.ts deleted file mode 100644 index 5fd3d9ce..00000000 --- a/src/app/api/projects/[projectId]/tiles/[tileId]/workspace-files/route.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { NextResponse } from "next/server"; - -import fs from "node:fs"; -import path from "node:path"; - -import { logger } from "@/lib/logger"; -import { resolveAgentWorkspaceDir } from "@/lib/projects/agentWorkspace"; -import { WORKSPACE_FILE_NAMES, type WorkspaceFileName } from "@/lib/projects/workspaceFiles"; -import type { ProjectTileWorkspaceFilesUpdatePayload } from "@/lib/projects/types"; -import { loadStore } from "../../../../store"; - -export const runtime = "nodejs"; - -const isWorkspaceFileName = (value: string): value is WorkspaceFileName => - WORKSPACE_FILE_NAMES.includes(value as WorkspaceFileName); - -const readWorkspaceFile = (workspaceDir: string, name: WorkspaceFileName) => { - const filePath = path.join(workspaceDir, name); - if (!fs.existsSync(filePath)) { - return { name, content: "", exists: false }; - } - const stat = fs.statSync(filePath); - if (!stat.isFile()) { - throw new Error(`${name} exists but is not a file.`); - } - return { name, content: fs.readFileSync(filePath, "utf8"), exists: true }; -}; - -const resolveTile = async ( - params: Promise<{ projectId: string; tileId: string }> -) => { - const { projectId, tileId } = await params; - const trimmedProjectId = projectId.trim(); - const trimmedTileId = tileId.trim(); - if (!trimmedProjectId || !trimmedTileId) { - return { - error: NextResponse.json( - { error: "Workspace id and tile id are required." }, - { status: 400 } - ), - }; - } - const store = loadStore(); - const project = store.projects.find((entry) => entry.id === trimmedProjectId); - if (!project) { - return { error: NextResponse.json({ error: "Workspace not found." }, { status: 404 }) }; - } - const tile = project.tiles.find((entry) => entry.id === trimmedTileId); - if (!tile) { - return { error: NextResponse.json({ error: "Tile not found." }, { status: 404 }) }; - } - return { projectId: trimmedProjectId, tileId: trimmedTileId, tile }; -}; - -export async function GET( - _request: Request, - context: { params: Promise<{ projectId: string; tileId: string }> } -) { - try { - const resolved = await resolveTile(context.params); - if ("error" in resolved) { - return resolved.error; - } - const { projectId, tile } = resolved; - const workspaceDir = resolveAgentWorkspaceDir(projectId, tile.agentId); - if (!fs.existsSync(workspaceDir)) { - return NextResponse.json({ error: "Agent workspace not found." }, { status: 404 }); - } - const files = WORKSPACE_FILE_NAMES.map((name) => - readWorkspaceFile(workspaceDir, name) - ); - return NextResponse.json({ files }); - } catch (err) { - const message = err instanceof Error ? err.message : "Failed to load workspace files."; - logger.error(message); - return NextResponse.json({ error: message }, { status: 500 }); - } -} - -export async function PUT( - request: Request, - context: { params: Promise<{ projectId: string; tileId: string }> } -) { - try { - const resolved = await resolveTile(context.params); - if ("error" in resolved) { - return resolved.error; - } - const { projectId, tile } = resolved; - const workspaceDir = resolveAgentWorkspaceDir(projectId, tile.agentId); - if (!fs.existsSync(workspaceDir)) { - return NextResponse.json({ error: "Agent workspace not found." }, { status: 404 }); - } - - const body = (await request.json()) as ProjectTileWorkspaceFilesUpdatePayload; - if (!body || !Array.isArray(body.files)) { - return NextResponse.json({ error: "Files payload is invalid." }, { status: 400 }); - } - - for (const entry of body.files) { - const name = typeof entry?.name === "string" ? entry.name.trim() : ""; - if (!name || !isWorkspaceFileName(name)) { - return NextResponse.json( - { error: `Invalid file name: ${entry?.name ?? ""}` }, - { status: 400 } - ); - } - if (typeof entry.content !== "string") { - return NextResponse.json({ error: `Invalid content for ${name}.` }, { status: 400 }); - } - } - - for (const entry of body.files) { - const name = entry.name as WorkspaceFileName; - const filePath = path.join(workspaceDir, name); - fs.writeFileSync(filePath, entry.content, "utf8"); - } - - const files = WORKSPACE_FILE_NAMES.map((name) => - readWorkspaceFile(workspaceDir, name) - ); - return NextResponse.json({ files }); - } catch (err) { - const message = err instanceof Error ? err.message : "Failed to save workspace files."; - logger.error(message); - return NextResponse.json({ error: message }, { status: 500 }); - } -} diff --git a/src/app/api/projects/[projectId]/tiles/route.ts b/src/app/api/projects/[projectId]/tiles/route.ts deleted file mode 100644 index 5d87457a..00000000 --- a/src/app/api/projects/[projectId]/tiles/route.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { NextResponse } from "next/server"; - -import { randomUUID } from "node:crypto"; -import fs from "node:fs"; -import path from "node:path"; - -import { logger } from "@/lib/logger"; -import type { - ProjectTile, - ProjectTileCreatePayload, - ProjectTileCreateResult, - ProjectTileRole, - ProjectsStore, -} from "@/lib/projects/types"; -import { resolveAgentWorkspaceDir } from "@/lib/projects/agentWorkspace"; -import { resolveClawdbotStateDir } from "@/lib/projects/fs.server"; -import { - loadClawdbotConfig, - saveClawdbotConfig, - upsertAgentEntry, -} from "@/lib/clawdbot/config"; -import { generateAgentId } from "@/lib/ids/agentId"; -import { provisionWorkspaceFiles } from "@/lib/projects/workspaceFiles.server"; -import { loadStore, saveStore } from "../../store"; - -export const runtime = "nodejs"; - -const ROLE_VALUES: ProjectTileRole[] = ["coding", "research", "marketing"]; - -const copyAuthProfiles = (agentId: string): string[] => { - const warnings: string[] = []; - const stateDir = resolveClawdbotStateDir(); - const sourceAgentId = process.env.CLAWDBOT_DEFAULT_AGENT_ID ?? "main"; - const source = path.join(stateDir, "agents", sourceAgentId, "agent", "auth-profiles.json"); - const destination = path.join(stateDir, "agents", agentId, "agent", "auth-profiles.json"); - - if (fs.existsSync(destination)) { - return warnings; - } - if (!fs.existsSync(source)) { - warnings.push(`No auth profiles found at ${source}; agent may need login.`); - return warnings; - } - fs.mkdirSync(path.dirname(destination), { recursive: true }); - fs.copyFileSync(source, destination); - return warnings; -}; - -const updateStoreProject = ( - store: ProjectsStore, - projectId: string, - tile: ProjectTile -) => { - return { - ...store, - version: 2 as const, - projects: store.projects.map((project) => - project.id === projectId - ? { - ...project, - tiles: [...project.tiles, tile], - updatedAt: Date.now(), - } - : project - ), - }; -}; - -export async function POST( - request: Request, - context: { params: Promise<{ projectId: string }> } -) { - try { - const { projectId } = await context.params; - const trimmedProjectId = projectId.trim(); - if (!trimmedProjectId) { - return NextResponse.json({ error: "Workspace id is required." }, { status: 400 }); - } - - const body = (await request.json()) as ProjectTileCreatePayload; - const name = typeof body?.name === "string" ? body.name.trim() : ""; - const role = body?.role; - if (!name) { - return NextResponse.json({ error: "Tile name is required." }, { status: 400 }); - } - if (!role || !ROLE_VALUES.includes(role)) { - return NextResponse.json({ error: "Tile role is invalid." }, { status: 400 }); - } - - const store = loadStore(); - const project = store.projects.find((entry) => entry.id === trimmedProjectId); - if (!project) { - return NextResponse.json({ error: "Workspace not found." }, { status: 404 }); - } - - const tileId = randomUUID(); - const projectSlug = path.basename(project.repoPath); - let agentId = ""; - try { - agentId = generateAgentId({ projectSlug, tileName: name }); - } catch (err) { - const message = err instanceof Error ? err.message : "Invalid agent name."; - return NextResponse.json({ error: message }, { status: 400 }); - } - if (project.tiles.some((entry) => entry.agentId === agentId)) { - return NextResponse.json( - { error: `Agent id already exists: ${agentId}` }, - { status: 409 } - ); - } - const sessionKey = `agent:${agentId}:main`; - const offset = project.tiles.length * 36; - const workspaceDir = resolveAgentWorkspaceDir(trimmedProjectId, agentId); - const tile: ProjectTile = { - id: tileId, - name, - agentId, - role, - sessionKey, - model: "openai-codex/gpt-5.2-codex", - thinkingLevel: null, - avatarSeed: agentId, - position: { x: 80 + offset, y: 200 + offset }, - size: { width: 420, height: 520 }, - }; - - const nextStore = updateStoreProject(store, trimmedProjectId, tile); - saveStore(nextStore); - - const { warnings: workspaceWarnings } = provisionWorkspaceFiles(workspaceDir); - const warnings = [...workspaceWarnings, ...copyAuthProfiles(agentId)]; - try { - const { config, configPath } = loadClawdbotConfig(); - const changed = upsertAgentEntry(config, { - agentId, - agentName: name, - workspaceDir, - }); - if (changed) { - saveClawdbotConfig(configPath, config); - } - } catch (err) { - const message = - err instanceof Error ? err.message : "Failed to update clawdbot.json."; - warnings.push(`Agent config not updated: ${message}`); - } - if (warnings.length > 0) { - logger.warn(`Tile created with warnings: ${warnings.join(" ")}`); - } - - const result: ProjectTileCreateResult = { - store: nextStore, - tile, - warnings, - }; - return NextResponse.json(result); - } catch (err) { - const message = err instanceof Error ? err.message : "Failed to create tile."; - logger.error(message); - return NextResponse.json({ error: message }, { status: 500 }); - } -} diff --git a/src/app/api/projects/open/route.ts b/src/app/api/projects/open/route.ts deleted file mode 100644 index 9ad1d33c..00000000 --- a/src/app/api/projects/open/route.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { NextResponse } from "next/server"; - -import { randomUUID } from "node:crypto"; -import fs from "node:fs"; -import path from "node:path"; - -import { logger } from "@/lib/logger"; -import { resolveHomePath } from "@/lib/projects/fs.server"; -import type { - Project, - ProjectOpenPayload, - ProjectOpenResult, - ProjectsStore, -} from "@/lib/projects/types"; -import { loadStore, saveStore } from "../store"; - -export const runtime = "nodejs"; - -const normalizeProjectsStore = (store: ProjectsStore): ProjectsStore => { - const projects = Array.isArray(store.projects) ? store.projects : []; - const activeProjectId = - typeof store.activeProjectId === "string" && - projects.some((project) => project.id === store.activeProjectId) - ? store.activeProjectId - : projects[0]?.id ?? null; - return { - version: 2, - activeProjectId, - projects, - }; -}; - -export async function POST(request: Request) { - try { - const body = (await request.json()) as ProjectOpenPayload; - const rawPath = typeof body?.path === "string" ? body.path.trim() : ""; - if (!rawPath) { - return NextResponse.json({ error: "Workspace path is required." }, { status: 400 }); - } - - const resolvedPath = resolveHomePath(rawPath); - if (!path.isAbsolute(resolvedPath)) { - return NextResponse.json( - { error: "Workspace path must be an absolute path." }, - { status: 400 } - ); - } - if (!fs.existsSync(resolvedPath)) { - return NextResponse.json( - { error: `Workspace path does not exist: ${resolvedPath}` }, - { status: 404 } - ); - } - const stat = fs.statSync(resolvedPath); - if (!stat.isDirectory()) { - return NextResponse.json( - { error: `Workspace path is not a directory: ${resolvedPath}` }, - { status: 400 } - ); - } - - const repoPath = fs.realpathSync(resolvedPath); - const name = path.basename(repoPath); - if (!name || name === path.parse(repoPath).root) { - return NextResponse.json( - { error: "Workspace path must point to a directory with a name." }, - { status: 400 } - ); - } - - const store = loadStore(); - if (store.projects.some((project) => project.repoPath === repoPath)) { - return NextResponse.json( - { error: "Workspace already exists for this path." }, - { status: 409 } - ); - } - - const warnings: string[] = []; - if (!fs.existsSync(path.join(repoPath, ".git"))) { - warnings.push("No .git directory found for this workspace path."); - } - - const now = Date.now(); - const project: Project = { - id: randomUUID(), - name, - repoPath, - createdAt: now, - updatedAt: now, - tiles: [], - }; - - const nextStore = normalizeProjectsStore({ - version: 2, - activeProjectId: project.id, - projects: [...store.projects, project], - }); - - saveStore(nextStore); - - if (warnings.length > 0) { - logger.warn(`Workspace opened with warnings: ${warnings.join(" ")}`); - } - - const result: ProjectOpenResult = { - store: nextStore, - warnings, - }; - - return NextResponse.json(result); - } catch (err) { - const message = err instanceof Error ? err.message : "Failed to open workspace."; - logger.error(message); - return NextResponse.json({ error: message }, { status: 500 }); - } -} diff --git a/src/app/api/projects/route.ts b/src/app/api/projects/route.ts deleted file mode 100644 index fe3df57c..00000000 --- a/src/app/api/projects/route.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { NextResponse } from "next/server"; - -import { randomUUID } from "node:crypto"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -import { logger } from "@/lib/logger"; -import type { - Project, - ProjectCreatePayload, - ProjectCreateResult, - ProjectsStore, -} from "@/lib/projects/types"; -import { ensureGitRepo } from "@/lib/fs/git"; -import { slugifyProjectName } from "@/lib/ids/slugify"; -import { loadStore, saveStore } from "./store"; - -export const runtime = "nodejs"; - -const normalizeProjectsStore = (store: ProjectsStore): ProjectsStore => { - const projects = Array.isArray(store.projects) ? store.projects : []; - const activeProjectId = - typeof store.activeProjectId === "string" && - projects.some((project) => project.id === store.activeProjectId) - ? store.activeProjectId - : projects[0]?.id ?? null; - return { - version: 2, - activeProjectId, - projects, - }; -}; - -export async function GET() { - try { - const store = normalizeProjectsStore(loadStore()); - return NextResponse.json(store); - } catch (err) { - const message = err instanceof Error ? err.message : "Failed to load workspaces."; - logger.error(message); - return NextResponse.json({ error: message }, { status: 500 }); - } -} - -export async function POST(request: Request) { - try { - const body = (await request.json()) as ProjectCreatePayload; - const name = typeof body?.name === "string" ? body.name.trim() : ""; - - if (!name) { - return NextResponse.json({ error: "Workspace name is required." }, { status: 400 }); - } - - let slug = ""; - try { - slug = slugifyProjectName(name); - } catch (err) { - const message = - err instanceof Error ? err.message : "Workspace name produced an empty folder name."; - return NextResponse.json({ error: message }, { status: 400 }); - } - - const store = loadStore(); - const { repoPath, warnings: pathWarnings } = resolveProjectPath(slug); - const gitResult = ensureGitRepo(repoPath); - const warnings = [...pathWarnings, ...gitResult.warnings]; - - const now = Date.now(); - const project: Project = { - id: randomUUID(), - name, - repoPath, - createdAt: now, - updatedAt: now, - tiles: [], - }; - - const nextStore = normalizeProjectsStore({ - version: 2, - activeProjectId: project.id, - projects: [...store.projects, project], - }); - - saveStore(nextStore); - - if (warnings.length > 0) { - logger.warn(`Workspace created with warnings: ${warnings.join(" ")}`); - } - - const result: ProjectCreateResult = { - store: nextStore, - warnings, - }; - - return NextResponse.json(result); - } catch (err) { - const message = err instanceof Error ? err.message : "Failed to create workspace."; - logger.error(message); - return NextResponse.json({ error: message }, { status: 500 }); - } -} - -export async function PUT(request: Request) { - try { - const body = (await request.json()) as ProjectsStore; - if (!body || !Array.isArray(body.projects)) { - return NextResponse.json({ error: "Invalid workspaces payload." }, { status: 400 }); - } - const normalized = normalizeProjectsStore(body); - saveStore(normalized); - return NextResponse.json(normalized); - } catch (err) { - const message = err instanceof Error ? err.message : "Failed to save workspaces."; - logger.error(message); - return NextResponse.json({ error: message }, { status: 500 }); - } -} - -const resolveProjectPath = (slug: string): { repoPath: string; warnings: string[] } => { - const warnings: string[] = []; - const basePath = path.join(os.homedir(), slug); - if (!fs.existsSync(basePath)) { - return { repoPath: basePath, warnings }; - } - let suffix = 2; - let candidate = basePath; - while (fs.existsSync(candidate)) { - candidate = path.join(os.homedir(), `${slug}-${suffix}`); - suffix += 1; - } - warnings.push(`Workspace folder already exists. Created ${candidate} instead.`); - return { repoPath: candidate, warnings }; -}; diff --git a/src/app/api/projects/store.ts b/src/app/api/projects/store.ts deleted file mode 100644 index fd603b12..00000000 --- a/src/app/api/projects/store.ts +++ /dev/null @@ -1,103 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import os from "node:os"; - -import type { Project, ProjectsStore } from "@/lib/projects/types"; - -const STORE_VERSION: ProjectsStore["version"] = 2; -const STORE_DIR = path.join(os.homedir(), ".clawdbot", "agent-canvas"); -const STORE_PATH = path.join(STORE_DIR, "projects.json"); - -export type ProjectsStorePayload = ProjectsStore; - -export const ensureStoreDir = () => { - if (!fs.existsSync(STORE_DIR)) { - fs.mkdirSync(STORE_DIR, { recursive: true }); - } -}; - -export const defaultStore = (): ProjectsStore => ({ - version: STORE_VERSION, - activeProjectId: null, - projects: [], -}); - -const parseAgentId = (sessionKey: string): string => { - const match = sessionKey.match(/^agent:([^:]+):/); - return match ? match[1] : "main"; -}; - -type RawTile = { - id: string; - name: string; - sessionKey: string; - model?: string | null; - thinkingLevel?: string | null; - position: { x: number; y: number }; - size: { width: number; height: number }; - agentId?: string; - role?: "coding" | "research" | "marketing"; -}; - -type RawProject = Omit<Project, "tiles"> & { tiles: RawTile[] }; - -type RawStore = { - version?: number; - activeProjectId?: string | null; - projects?: RawProject[]; -}; - -const migrateV1Store = (store: { activeProjectId?: string | null; projects: RawProject[] }) => { - const projects = store.projects.map((project) => ({ - ...project, - tiles: project.tiles.map((tile) => ({ - ...tile, - agentId: parseAgentId(typeof tile.sessionKey === "string" ? tile.sessionKey : ""), - role: "coding" as const, - })), - })); - return { - version: STORE_VERSION, - activeProjectId: store.activeProjectId ?? null, - projects, - }; -}; - -export const loadStore = (): ProjectsStore => { - ensureStoreDir(); - if (!fs.existsSync(STORE_PATH)) { - const seed = defaultStore(); - fs.writeFileSync(STORE_PATH, JSON.stringify(seed, null, 2), "utf8"); - return seed; - } - const raw = fs.readFileSync(STORE_PATH, "utf8"); - try { - const parsed = JSON.parse(raw) as RawStore; - if (!parsed || !Array.isArray(parsed.projects)) { - throw new Error(`Workspaces store is invalid at ${STORE_PATH}.`); - } - if (!parsed.projects.every((project) => Array.isArray(project.tiles))) { - throw new Error(`Workspaces store is invalid at ${STORE_PATH}.`); - } - if (parsed.version === 2) { - return parsed as ProjectsStore; - } - const migrated = migrateV1Store({ - activeProjectId: parsed.activeProjectId ?? null, - projects: parsed.projects, - }); - saveStore(migrated); - return migrated; - } catch (err) { - const details = err instanceof Error ? err.message : "Unknown error."; - if (details.includes(STORE_PATH)) { - throw new Error(details); - } - throw new Error(`Failed to parse workspaces store at ${STORE_PATH}: ${details}`); - } -}; - -export const saveStore = (store: ProjectsStore) => { - ensureStoreDir(); - fs.writeFileSync(STORE_PATH, JSON.stringify(store, null, 2), "utf8"); -}; diff --git a/src/app/api/studio/route.ts b/src/app/api/studio/route.ts new file mode 100644 index 00000000..8925700a --- /dev/null +++ b/src/app/api/studio/route.ts @@ -0,0 +1,39 @@ +import { NextResponse } from "next/server"; + +import { logger } from "@/lib/logger"; +import { + applyStudioSettingsPatch, + loadStudioSettings, +} from "@/lib/studio/settings.server"; +import { type StudioSettingsPatch } from "@/lib/studio/settings"; + +export const runtime = "nodejs"; + +const isPatch = (value: unknown): value is StudioSettingsPatch => + Boolean(value && typeof value === "object"); + +export async function GET() { + try { + const settings = loadStudioSettings(); + return NextResponse.json({ settings }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to load studio settings."; + logger.error(message); + return NextResponse.json({ error: message }, { status: 500 }); + } +} + +export async function PUT(request: Request) { + try { + const body = (await request.json()) as unknown; + if (!isPatch(body)) { + return NextResponse.json({ error: "Invalid settings payload." }, { status: 400 }); + } + const settings = applyStudioSettingsPatch(body); + return NextResponse.json({ settings }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to save studio settings."; + logger.error(message); + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/globals.css b/src/app/globals.css index 7b1b14b2..06d92a65 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,82 +1,133 @@ @import "tailwindcss"; @import "tw-animate-css"; -@import "@xyflow/react/dist/style.css"; +@import "../../node_modules/react-mentions-ts/styles/tailwind.css"; @import "./styles/markdown.css"; @custom-variant dark (&:is(.dark *)); :root { - --panel: rgba(255, 255, 255, 0.82); - --panel-border: rgba(25, 20, 16, 0.12); - --accent: oklch(0.967 0.001 286.375); - --accent-strong: #2563eb; - --muted: oklch(0.967 0.001 286.375); - --radius: 0.625rem; - --background: oklch(1 0 0); - --foreground: oklch(0.141 0.005 285.823); - --card: oklch(1 0 0); - --card-foreground: oklch(0.141 0.005 285.823); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.141 0.005 285.823); - --primary: oklch(0.21 0.006 285.885); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.967 0.001 286.375); - --secondary-foreground: oklch(0.21 0.006 285.885); - --muted-foreground: oklch(0.552 0.016 285.938); - --accent-foreground: oklch(0.21 0.006 285.885); - --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.92 0.004 286.32); - --input: oklch(0.92 0.004 286.32); - --ring: oklch(0.705 0.015 286.067); - --chart-1: oklch(0.646 0.222 41.116); - --chart-2: oklch(0.6 0.118 184.704); - --chart-3: oklch(0.398 0.07 227.392); - --chart-4: oklch(0.828 0.189 84.429); - --chart-5: oklch(0.769 0.188 70.08); - --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.141 0.005 285.823); - --sidebar-primary: oklch(0.21 0.006 285.885); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.967 0.001 286.375); - --sidebar-accent-foreground: oklch(0.21 0.006 285.885); - --sidebar-border: oklch(0.92 0.004 286.32); - --sidebar-ring: oklch(0.705 0.015 286.067); + --background: oklch(0.955 0.006 225); + --foreground: oklch(0.29 0.018 245); + --card: oklch(0.992 0.003 220 / 0.9); + --card-foreground: oklch(0.29 0.018 245); + --popover: oklch(0.996 0.003 220); + --popover-foreground: oklch(0.27 0.018 245); + --primary: oklch(0.58 0.07 220); + --primary-foreground: oklch(0.98 0.004 220); + --secondary: oklch(0.91 0.01 225); + --secondary-foreground: oklch(0.3 0.018 245); + --muted: oklch(0.94 0.008 225); + --muted-foreground: oklch(0.46 0.018 245); + --accent: oklch(0.9 0.012 225); + --accent-foreground: oklch(0.31 0.018 245); + --destructive: oklch(0.58 0.18 28); + --destructive-foreground: oklch(0.99 0.004 90); + --border: oklch(0.8 0.012 230 / 0.56); + --input: oklch(0.97 0.005 225); + --ring: oklch(0.58 0.07 220); + --chart-1: oklch(0.58 0.07 220); + --chart-2: oklch(0.66 0.08 170); + --chart-3: oklch(0.62 0.07 250); + --chart-4: oklch(0.71 0.06 205); + --chart-5: oklch(0.5 0.06 240); + --sidebar: oklch(0.962 0.006 225 / 0.9); + --sidebar-foreground: oklch(0.29 0.018 245); + --sidebar-primary: oklch(0.58 0.07 220); + --sidebar-primary-foreground: oklch(0.98 0.004 220); + --sidebar-accent: oklch(0.9 0.012 225); + --sidebar-accent-foreground: oklch(0.31 0.018 245); + --sidebar-border: oklch(0.81 0.012 230 / 0.6); + --sidebar-ring: oklch(0.58 0.07 220); + --radius: 0.88rem; + --font-display: "Bebas Neue", sans-serif; + --font-sans: "IBM Plex Sans", sans-serif; + --font-mono: "IBM Plex Mono", monospace; + --shadow-color: oklch(0.18 0.012 250); + --shadow-opacity: 0.16; + --shadow-blur: 28px; + --shadow-spread: -6px; + --shadow-offset-x: 0px; + --shadow-offset-y: 10px; + --letter-spacing: 0em; + --spacing: 0.25rem; + --tracking-normal: 0.005em; + --panel: oklch(0.99 0.004 225 / 0.82); + --panel-border: oklch(0.77 0.014 232 / 0.45); +} + +.dark { + --background: oklch(0.19 0.014 245); + --foreground: oklch(0.92 0.008 92); + --card: oklch(0.24 0.012 245 / 0.84); + --card-foreground: oklch(0.92 0.008 92); + --popover: oklch(0.23 0.012 245); + --popover-foreground: oklch(0.92 0.008 92); + --primary: oklch(0.69 0.09 218); + --primary-foreground: oklch(0.15 0.014 245); + --secondary: oklch(0.3 0.012 245); + --secondary-foreground: oklch(0.9 0.008 95); + --muted: oklch(0.28 0.012 245); + --muted-foreground: oklch(0.75 0.01 96); + --accent: oklch(0.33 0.016 245); + --accent-foreground: oklch(0.9 0.008 95); + --destructive: oklch(0.64 0.19 30); + --destructive-foreground: oklch(0.14 0.014 245); + --border: oklch(0.43 0.016 245 / 0.7); + --input: oklch(0.27 0.012 245); + --ring: oklch(0.69 0.09 218); + --chart-1: oklch(0.69 0.09 218); + --chart-2: oklch(0.7 0.08 170); + --chart-3: oklch(0.68 0.08 255); + --chart-4: oklch(0.7 0.07 205); + --chart-5: oklch(0.62 0.07 240); + --sidebar: oklch(0.22 0.012 245 / 0.84); + --sidebar-foreground: oklch(0.9 0.008 95); + --sidebar-primary: oklch(0.69 0.09 218); + --sidebar-primary-foreground: oklch(0.15 0.014 245); + --sidebar-accent: oklch(0.31 0.014 245); + --sidebar-accent-foreground: oklch(0.9 0.008 95); + --sidebar-border: oklch(0.44 0.017 245 / 0.72); + --sidebar-ring: oklch(0.69 0.09 218); + --panel: oklch(0.25 0.012 245 / 0.74); + --panel-border: oklch(0.51 0.018 218 / 0.58); + --shadow-color: oklch(0.08 0.008 245); + --shadow-opacity: 0.45; } @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); - --font-sans: var(--font-display); - --font-mono: var(--font-code); - --color-sidebar-ring: var(--sidebar-ring); - --color-sidebar-border: var(--sidebar-border); - --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); - --color-sidebar-accent: var(--sidebar-accent); - --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); - --color-sidebar-primary: var(--sidebar-primary); - --color-sidebar-foreground: var(--sidebar-foreground); - --color-sidebar: var(--sidebar); - --color-chart-5: var(--chart-5); - --color-chart-4: var(--chart-4); - --color-chart-3: var(--chart-3); - --color-chart-2: var(--chart-2); - --color-chart-1: var(--chart-1); - --color-ring: var(--ring); - --color-input: var(--input); - --color-border: var(--border); - --color-destructive: var(--destructive); - --color-accent-foreground: var(--accent-foreground); - --color-accent: var(--accent); - --color-muted-foreground: var(--muted-foreground); - --color-muted: var(--muted); - --color-secondary-foreground: var(--secondary-foreground); - --color-secondary: var(--secondary); - --color-primary-foreground: var(--primary-foreground); - --color-primary: var(--primary); - --color-popover-foreground: var(--popover-foreground); - --color-popover: var(--popover); - --color-card-foreground: var(--card-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: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --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); + --color-shadow-color: var(--shadow-color); --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); @@ -84,173 +135,190 @@ --radius-2xl: calc(var(--radius) + 8px); --radius-3xl: calc(var(--radius) + 12px); --radius-4xl: calc(var(--radius) + 16px); + --font-sans: var(--font-sans); + --font-mono: var(--font-mono); + --font-serif: "Baskervville", serif; + --tracking-tighter: calc(var(--tracking-normal) - 0.05em); + --tracking-tight: calc(var(--tracking-normal) - 0.025em); + --tracking-wide: calc(var(--tracking-normal) + 0.025em); + --tracking-wider: calc(var(--tracking-normal) + 0.05em); + --tracking-widest: calc(var(--tracking-normal) + 0.1em); + --tracking-normal: var(--tracking-normal); + --shadow-2xs: 0 1px 1px color-mix(in oklch, var(--shadow-color) 10%, transparent); + --shadow-xs: 0 2px 6px color-mix(in oklch, var(--shadow-color) 16%, transparent); + --shadow-sm: 0 7px 18px -9px color-mix(in oklch, var(--shadow-color) 35%, transparent); + --shadow: 0 10px 24px -14px color-mix(in oklch, var(--shadow-color) 42%, transparent); + --shadow-md: 0 14px 36px -17px color-mix(in oklch, var(--shadow-color) 48%, transparent); + --shadow-lg: 0 24px 60px -26px color-mix(in oklch, var(--shadow-color) 58%, transparent); + --shadow-xl: 0 36px 90px -38px color-mix(in oklch, var(--shadow-color) 66%, transparent); + --shadow-2xl: 0 44px 120px -45px color-mix(in oklch, var(--shadow-color) 78%, transparent); } -body { - min-height: 100vh; - background: radial-gradient(1000px circle at 10% -10%, #f7dac1 0%, transparent 55%), - radial-gradient(900px circle at 90% -20%, #cfe2f5 0%, transparent 48%), - #f4efe7; - font-family: var(--font-display), sans-serif; -} +@layer base { + * { + @apply border-border outline-ring/50; + box-sizing: border-box; + } -* { - box-sizing: border-box; + body { + @apply bg-background text-foreground; + min-height: 100vh; + margin: 0; + letter-spacing: var(--tracking-normal); + font-family: var(--font-sans), sans-serif; + position: relative; + overflow: hidden; + } + + body::before { + content: ""; + position: fixed; + inset: -30%; + z-index: -2; + background: + radial-gradient(circle at 16% 16%, color-mix(in oklch, var(--primary) 10%, transparent), transparent 44%), + radial-gradient(circle at 84% 18%, color-mix(in oklch, var(--accent) 10%, transparent), transparent 40%), + linear-gradient(140deg, color-mix(in oklch, var(--background) 94%, white) 0%, var(--background) 62%, color-mix(in oklch, var(--background) 88%, black) 100%); + transform: rotate(-2deg) scale(1.04); + } + + body::after { + content: ""; + position: fixed; + inset: 0; + z-index: -1; + background-image: + linear-gradient(transparent 95%, color-mix(in oklch, var(--border) 58%, transparent) 96%), + linear-gradient(90deg, transparent 95%, color-mix(in oklch, var(--border) 52%, transparent) 96%); + background-size: 30px 30px; + opacity: 0.14; + pointer-events: none; + } } -.glass-panel { - background: var(--panel); - border: 1px solid var(--panel-border); - border-radius: 24px; - box-shadow: 0 24px 60px rgba(31, 26, 21, 0.15); - backdrop-filter: blur(18px); +button:not(:disabled), +input[type="button"]:not(:disabled), +input[type="submit"]:not(:disabled), +input[type="reset"]:not(:disabled), +summary, +[role="button"]:not([aria-disabled="true"]) { + cursor: pointer; } -.canvas-surface { - background: radial-gradient(circle at 1px 1px, rgba(25, 20, 16, 0.08) 1px, transparent 0) - 0 0 / 32px 32px, - rgba(255, 255, 255, 0.6); - border: 1px solid var(--panel-border); - border-radius: 28px; - box-shadow: 0 30px 80px rgba(31, 26, 21, 0.18); - overscroll-behavior: none; - touch-action: none; +* { + scrollbar-width: thin; + scrollbar-color: color-mix(in oklch, var(--foreground) 18%, var(--border)) transparent; } -.canvas-content { - will-change: transform; +*::-webkit-scrollbar { + width: 10px; + height: 10px; } -.react-flow__node.selectable.selected, -.react-flow__node.selectable:focus, -.react-flow__node.selectable:focus-visible, -.react-flow__node-default.selectable.selected, -.react-flow__node-default.selectable:focus, -.react-flow__node-default.selectable:focus-visible { - outline: none; - box-shadow: none; +*::-webkit-scrollbar-track { + background: transparent; } -.react-flow { - --xy-node-border: 1px solid transparent; - --xy-node-border-selected: 1px solid transparent; - --xy-selection-border: 1px solid transparent; - --xy-resize-background-color: transparent; +*::-webkit-scrollbar-thumb { + background: color-mix(in oklch, var(--foreground) 18%, var(--border)); + border-radius: 999px; + border: 2px solid transparent; } -.react-flow__resize-control.line { - border-color: transparent; +*::-webkit-scrollbar-thumb:hover { + background: color-mix(in oklch, var(--foreground) 28%, var(--border)); } -.react-flow__resize-control.handle { - background-color: transparent; - border-color: transparent; +textarea { + scrollbar-gutter: auto; } -.tile-resize-handle { - height: 8px; - width: 112px; - border-radius: 999px; - border: 1px solid rgba(148, 163, 184, 0.7); - background-color: rgba(255, 255, 255, 0.9); - box-shadow: 0 6px 12px rgba(15, 23, 42, 0.18); - opacity: 0; - transition: opacity 160ms ease; - z-index: 2; +textarea::-webkit-scrollbar { + width: 0; + height: 0; } -.react-flow__node:hover .tile-resize-handle, -.react-flow__node.selected .tile-resize-handle { - opacity: 1; +textarea:hover::-webkit-scrollbar, +textarea:focus-visible::-webkit-scrollbar, +textarea:focus::-webkit-scrollbar { + width: 10px; + height: 10px; } -.tile-resize-handle.react-flow__resize-control.bottom { - transform: translate(-50%, -100%); +.glass-panel { + border: 1px solid var(--panel-border); + background: + linear-gradient( + 145deg, + color-mix(in oklch, var(--panel) 95%, transparent) 0%, + color-mix(in oklch, var(--panel) 88%, var(--primary) 3%) 45%, + color-mix(in oklch, var(--panel) 92%, var(--accent) 2%) 100% + ); + backdrop-filter: blur(16px) saturate(1.04); + border-radius: var(--radius); + box-shadow: + inset 0 1px 0 color-mix(in oklch, white 65%, transparent), + 0 16px 40px -20px color-mix(in oklch, var(--shadow-color) 60%, transparent); } .fade-up { - animation: fadeUp 600ms ease-out both; + animation: fadeUp 540ms cubic-bezier(0.2, 0.74, 0.2, 1) both; } .fade-up-delay { - animation: fadeUp 600ms ease-out 120ms both; + animation: fadeUp 540ms cubic-bezier(0.2, 0.74, 0.2, 1) 120ms both; } .agent-avatar-selected { - animation: agentAvatarPulse 2.6s ease-in-out infinite; box-shadow: - 0 0 0 0 rgba(59, 130, 246, 0.22), - 0 8px 18px rgba(15, 23, 42, 0.12); + 0 0 0 2px color-mix(in oklch, var(--primary) 26%, transparent), + 0 12px 24px -14px color-mix(in oklch, var(--shadow-color) 58%, transparent); } -@keyframes agentAvatarPulse { - 0% { - box-shadow: - 0 0 0 0 rgba(59, 130, 246, 0.22), - 0 8px 18px rgba(15, 23, 42, 0.12); - } - 50% { - box-shadow: - 0 0 0 6px rgba(59, 130, 246, 0.14), - 0 10px 22px rgba(15, 23, 42, 0.16); - } - 100% { - box-shadow: - 0 0 0 0 rgba(59, 130, 246, 0.22), - 0 8px 18px rgba(15, 23, 42, 0.12); - } +.agent-name-selected { + border-color: color-mix(in oklch, var(--primary) 40%, var(--border)); + box-shadow: + inset 0 0 0 1px color-mix(in oklch, var(--primary) 16%, transparent), + 0 8px 20px -15px color-mix(in oklch, var(--shadow-color) 68%, transparent); +} + +.agent-inspect-panel { + position: relative; + height: 100%; + width: 100%; + border-radius: var(--radius); + background: + linear-gradient( + 180deg, + color-mix(in oklch, var(--sidebar) 90%, transparent) 0%, + color-mix(in oklch, var(--sidebar) 88%, var(--primary) 4%) 56%, + color-mix(in oklch, var(--sidebar) 84%, var(--accent) 3%) 100% + ); + color: var(--sidebar-foreground); + border: 1px solid var(--sidebar-border); + box-shadow: + inset 0 1px 0 color-mix(in oklch, white 40%, transparent), + var(--shadow-lg); + overflow-y: auto; +} + +.console-title { + font-family: var(--font-display), sans-serif; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.status-ping { + opacity: 0.9; } @keyframes fadeUp { from { opacity: 0; - transform: translateY(12px); + transform: translateY(14px); } to { opacity: 1; transform: translateY(0); } } - -.dark { - --background: oklch(0.141 0.005 285.823); - --foreground: oklch(0.985 0 0); - --card: oklch(0.21 0.006 285.885); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.21 0.006 285.885); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.92 0.004 286.32); - --primary-foreground: oklch(0.21 0.006 285.885); - --secondary: oklch(0.274 0.006 286.033); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.274 0.006 286.033); - --muted-foreground: oklch(0.705 0.015 286.067); - --accent: oklch(0.274 0.006 286.033); - --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.552 0.016 285.938); - --chart-1: oklch(0.488 0.243 264.376); - --chart-2: oklch(0.696 0.17 162.48); - --chart-3: oklch(0.769 0.188 70.08); - --chart-4: oklch(0.627 0.265 303.9); - --chart-5: oklch(0.645 0.246 16.439); - --sidebar: oklch(0.21 0.006 285.885); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.274 0.006 286.033); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.552 0.016 285.938); -} - -@layer base { - * { - @apply border-border outline-ring/50; - } - body { - @apply bg-background text-foreground; - } -} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 42c6d1f6..cd85ffb7 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,32 +1,46 @@ import type { Metadata } from "next"; -import { IBM_Plex_Mono, Space_Grotesk } from "next/font/google"; +import { Bebas_Neue, IBM_Plex_Mono, IBM_Plex_Sans } from "next/font/google"; import "./globals.css"; -const display = Space_Grotesk({ +export const metadata: Metadata = { + title: "OpenClaw Studio", + description: "Focused operator studio for the OpenClaw gateway.", +}; + +const display = Bebas_Neue({ variable: "--font-display", + weight: "400", subsets: ["latin"], +}); + +const sans = IBM_Plex_Sans({ + variable: "--font-sans", weight: ["400", "500", "600", "700"], + subsets: ["latin"], }); const mono = IBM_Plex_Mono({ - variable: "--font-code", + variable: "--font-mono", + weight: ["400", "500", "600"], subsets: ["latin"], - weight: ["400", "500"], }); -export const metadata: Metadata = { - title: "Clawdbot Agent Canvas", - description: "Standalone operator canvas for the Clawdbot gateway.", -}; - export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { return ( - <html lang="en"> - <body className={`${display.variable} ${mono.variable} antialiased`}> + <html lang="en" suppressHydrationWarning> + <head> + <script + dangerouslySetInnerHTML={{ + __html: + "(function(){try{var t=localStorage.getItem('theme');var m=window.matchMedia('(prefers-color-scheme: dark)').matches;var d=t?t==='dark':m;document.documentElement.classList.toggle('dark',d);}catch(e){}})();", + }} + /> + </head> + <body className={`${display.variable} ${sans.variable} ${mono.variable} antialiased`}> {children} </body> </html> diff --git a/src/app/page.tsx b/src/app/page.tsx index e732302d..ff39a6df 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,96 +1,221 @@ "use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -// (ReactFlowInstance import removed) -import { CanvasFlow } from "@/features/canvas/components/CanvasFlow"; -import { HeaderBar } from "@/features/canvas/components/HeaderBar"; -import { MIN_TILE_SIZE } from "@/features/canvas/components/AgentTile"; -import { screenToWorld, worldToScreen } from "@/features/canvas/lib/transform"; -import { extractText } from "@/lib/text/extractText"; -import { extractThinking, formatThinkingMarkdown } from "@/lib/text/extractThinking"; +import { AgentChatPanel } from "@/features/agents/components/AgentChatPanel"; +import { AgentInspectPanel } from "@/features/agents/components/AgentInspectPanel"; +import { FleetSidebar } from "@/features/agents/components/FleetSidebar"; +import { HeaderBar } from "@/features/agents/components/HeaderBar"; +import { ConnectionPanel } from "@/features/agents/components/ConnectionPanel"; +import { + extractText, + extractThinking, + extractThinkingFromTaggedStream, + formatThinkingMarkdown, + isTraceMarkdown, + extractToolLines, + formatToolCallMarkdown, +} from "@/lib/text/message-extract"; +import { + buildAgentInstruction, + isHeartbeatPrompt, + isUiMetadataPrefix, + stripUiMetadata, +} from "@/lib/text/message-metadata"; import { useGatewayConnection } from "@/lib/gateway/useGatewayConnection"; import type { EventFrame } from "@/lib/gateway/frames"; +import type { GatewayModelChoice } from "@/lib/gateway/models"; +import { + AgentStoreProvider, + getFilteredAgents, + getSelectedAgent, + type FocusFilter, + useAgentStore, +} from "@/features/agents/state/store"; +import { + type AgentEventPayload, + type ChatEventPayload, + getAgentSummaryPatch, + getChatSummaryPatch, +} from "@/features/agents/state/summary"; import { - AgentCanvasProvider, - getActiveProject, - useAgentCanvasStore, -} from "@/features/canvas/state/store"; -import { createProjectDiscordChannel } from "@/lib/projects/client"; + dedupeRunLines, + mergeRuntimeStream, + resolveLifecyclePatch, + shouldPublishAssistantStream, +} from "@/features/agents/state/runtimeEventBridge"; +import { fetchCronJobs } from "@/lib/cron/client"; import { createRandomAgentName, normalizeAgentName } from "@/lib/names/agentNames"; -import type { AgentTile, ProjectRuntime } from "@/features/canvas/state/store"; -// (CANVAS_BASE_ZOOM import removed) +import type { AgentStoreSeed, AgentState } from "@/features/agents/state/store"; +import type { CronJobSummary } from "@/lib/cron/types"; +import { logger } from "@/lib/logger"; +import { renameGatewayAgent, deleteGatewayAgent } from "@/lib/gateway/agentConfig"; +import { + parseAgentIdFromSessionKey, + buildAgentStudioSessionKey, + isSameSessionKey, +} from "@/lib/gateway/sessionKeys"; +import { buildAvatarDataUrl } from "@/lib/avatars/multiavatar"; +import { getStudioSettingsCoordinator } from "@/lib/studio/client"; +import { resolveFocusedPreference, resolveStudioSessionId } from "@/lib/studio/settings"; +import { generateUUID } from "@/lib/gateway/openclaw/uuid"; + +type ChatHistoryMessage = Record<string, unknown>; -type ChatEventPayload = { - runId: string; +type ChatHistoryResult = { sessionKey: string; - state: "delta" | "final" | "aborted" | "error"; - message?: unknown; - errorMessage?: string; + sessionId?: string; + messages: ChatHistoryMessage[]; + thinkingLevel?: string; }; -type AgentEventPayload = { - runId: string; - seq?: number; - stream?: string; - data?: Record<string, unknown>; - sessionKey?: string; +type GatewayConfigSnapshot = { + config?: { + agents?: { + defaults?: { + model?: string | { primary?: string; fallbacks?: string[] }; + models?: Record<string, { alias?: string }>; + }; + }; + }; }; -const PROJECT_PROMPT_BLOCK_RE = /^(?:Project|Workspace) path:[\s\S]*?\n\s*\n/i; -const PROJECT_PROMPT_INLINE_RE = /^(?:Project|Workspace) path:[\s\S]*?memory_search\.\s*/i; -const RESET_PROMPT_RE = - /^A new session was started via \/new or \/reset[\s\S]*?reasoning\.\s*/i; -const MESSAGE_ID_RE = /\s*\[message_id:[^\]]+\]\s*/gi; -const UI_METADATA_PREFIX_RE = - /^(?:Project path:|Workspace path:|A new session was started via \/new or \/reset)/i; - -const stripUiMetadata = (text: string) => { - if (!text) return text; - let cleaned = text.replace(RESET_PROMPT_RE, ""); - const beforeProjectStrip = cleaned; - cleaned = cleaned.replace(PROJECT_PROMPT_INLINE_RE, ""); - if (cleaned === beforeProjectStrip) { - cleaned = cleaned.replace(PROJECT_PROMPT_BLOCK_RE, ""); - } - cleaned = cleaned.replace(MESSAGE_ID_RE, "").trim(); - return cleaned; +type AgentsListResult = { + defaultId: string; + mainKey: string; + scope?: string; + agents: Array<{ + id: string; + name?: string; + identity?: { + name?: string; + theme?: string; + emoji?: string; + avatar?: string; + avatarUrl?: string; + }; + }>; +}; + +type SessionPreviewItem = { + role: "user" | "assistant" | "tool" | "system" | "other"; + text: string; }; +type SessionsPreviewEntry = { + key: string; + status: "ok" | "empty" | "missing" | "error"; + items: SessionPreviewItem[]; +}; -type ChatHistoryMessage = Record<string, unknown>; +type SessionsPreviewResult = { + ts: number; + previews: SessionsPreviewEntry[]; +}; -type ChatHistoryResult = { - sessionKey: string; - sessionId?: string; - messages: ChatHistoryMessage[]; - thinkingLevel?: string; +type SessionsListEntry = { + key: string; + updatedAt?: number | null; + displayName?: string; + origin?: { label?: string | null; provider?: string | null } | null; +}; + +type SessionsListResult = { + sessions?: SessionsListEntry[]; +}; + +type SessionStatusSummary = { + key: string; + updatedAt: number | null; +}; + +type StatusSummary = { + sessions?: { + recent?: SessionStatusSummary[]; + byAgent?: Array<{ agentId: string; recent: SessionStatusSummary[] }>; + }; +}; + +const SPECIAL_UPDATE_HEARTBEAT_RE = /\bheartbeat\b/i; +const SPECIAL_UPDATE_CRON_RE = /\bcron\b/i; + +const resolveSpecialUpdateKind = (message: string) => { + const lowered = message.toLowerCase(); + const heartbeatIndex = lowered.search(SPECIAL_UPDATE_HEARTBEAT_RE); + const cronIndex = lowered.search(SPECIAL_UPDATE_CRON_RE); + if (heartbeatIndex === -1 && cronIndex === -1) return null; + if (heartbeatIndex === -1) return "cron"; + if (cronIndex === -1) return "heartbeat"; + return cronIndex > heartbeatIndex ? "cron" : "heartbeat"; +}; + +const formatEveryMs = (everyMs: number) => { + if (everyMs % 3600000 === 0) { + return `${everyMs / 3600000}h`; + } + if (everyMs % 60000 === 0) { + return `${everyMs / 60000}m`; + } + if (everyMs % 1000 === 0) { + return `${everyMs / 1000}s`; + } + return `${everyMs}ms`; +}; + +const formatCronSchedule = (schedule: CronJobSummary["schedule"]) => { + if (schedule.kind === "every") { + return `Every ${formatEveryMs(schedule.everyMs)}`; + } + if (schedule.kind === "cron") { + return schedule.tz ? `Cron: ${schedule.expr} (${schedule.tz})` : `Cron: ${schedule.expr}`; + } + return `At: ${new Date(schedule.atMs).toLocaleString()}`; +}; + +const buildCronDisplay = (job: CronJobSummary) => { + const payloadText = + job.payload.kind === "systemEvent" ? job.payload.text : job.payload.message; + const lines = [job.name, formatCronSchedule(job.schedule), payloadText].filter(Boolean); + return lines.join("\n"); }; const buildHistoryLines = (messages: ChatHistoryMessage[]) => { const lines: string[] = []; let lastAssistant: string | null = null; let lastRole: string | null = null; + let lastUser: string | null = null; for (const message of messages) { const role = typeof message.role === "string" ? message.role : "other"; const extracted = extractText(message); const text = stripUiMetadata(extracted?.trim() ?? ""); const thinking = role === "assistant" ? formatThinkingMarkdown(extractThinking(message) ?? "") : ""; - if (!text && !thinking) continue; + const toolLines = extractToolLines(message); + if (!text && !thinking && toolLines.length === 0) continue; if (role === "user") { + if (text && isHeartbeatPrompt(text)) { + continue; + } if (text) { lines.push(`> ${text}`); + lastUser = text; } lastRole = "user"; } else if (role === "assistant") { if (thinking) { lines.push(thinking); } + if (toolLines.length > 0) { + lines.push(...toolLines); + } if (text) { lines.push(text); lastAssistant = text; } lastRole = "assistant"; + } else if (toolLines.length > 0) { + lines.push(...toolLines); + } else if (text) { + lines.push(text); } } const deduped: string[] = []; @@ -98,7 +223,52 @@ const buildHistoryLines = (messages: ChatHistoryMessage[]) => { if (deduped[deduped.length - 1] === line) continue; deduped.push(line); } - return { lines: deduped, lastAssistant, lastRole }; + return { lines: deduped, lastAssistant, lastRole, lastUser }; +}; + +const extractReasoningBody = (value: string): string | null => { + const trimmed = value.trim(); + if (!trimmed) return null; + const match = trimmed.match(/^reasoning:\s*([\s\S]*)$/i); + if (!match) return null; + const body = (match[1] ?? "").trim(); + return body || null; +}; + +const resolveThinkingFromAgentStream = ( + data: Record<string, unknown> | null, + rawStream: string +): string | null => { + if (data) { + const extracted = extractThinking(data); + if (extracted) return extracted; + const text = typeof data.text === "string" ? data.text : ""; + const delta = typeof data.delta === "string" ? data.delta : ""; + const prefixed = extractReasoningBody(text) ?? extractReasoningBody(delta); + if (prefixed) return prefixed; + } + const tagged = extractThinkingFromTaggedStream(rawStream); + return tagged || null; +}; + +const findLatestHeartbeatResponse = (messages: ChatHistoryMessage[]) => { + let awaitingHeartbeatReply = false; + let latestResponse: string | null = null; + for (const message of messages) { + const role = typeof message.role === "string" ? message.role : ""; + if (role === "user") { + const text = stripUiMetadata(extractText(message) ?? "").trim(); + awaitingHeartbeatReply = isHeartbeatPrompt(text); + continue; + } + if (role === "assistant" && awaitingHeartbeatReply) { + const text = stripUiMetadata(extractText(message) ?? "").trim(); + if (text) { + latestResponse = text; + } + } + } + return latestResponse; }; const mergeHistoryWithPending = (historyLines: string[], currentLines: string[]) => { @@ -118,267 +288,822 @@ const mergeHistoryWithPending = (historyLines: string[], currentLines: string[]) cursor = foundIndex + 1; continue; } - if (line.startsWith("> ")) { - merged.splice(cursor, 0, line); - cursor += 1; - } + merged.splice(cursor, 0, line); + cursor += 1; } return merged; }; -const buildProjectMessage = (project: ProjectRuntime | null, message: string) => { - const trimmed = message.trim(); - if (!project || !project.repoPath.trim()) { - return trimmed; - } - if (trimmed.startsWith("/")) { - return trimmed; - } - return `Workspace path: ${project.repoPath}. Operate within this repository. You may also read/write your agent workspace files (IDENTITY.md, USER.md, HEARTBEAT.md, TOOLS.md, MEMORY.md). Use MEMORY.md or memory/*.md directly for durable memory; do not rely on memory_search.\n\n${trimmed}`; -}; - -const findTileBySessionKey = ( - projects: ProjectRuntime[], - sessionKey: string -): { projectId: string; tileId: string } | null => { - for (const project of projects) { - const tile = project.tiles.find((entry) => entry.sessionKey === sessionKey); - if (tile) { - return { projectId: project.id, tileId: tile.id }; - } - } - return null; +const findAgentBySessionKey = (agents: AgentState[], sessionKey: string): string | null => { + const exact = agents.find((agent) => isSameSessionKey(agent.sessionKey, sessionKey)); + return exact ? exact.agentId : null; }; -const findTileByRunId = ( - projects: ProjectRuntime[], - runId: string -): { projectId: string; tileId: string } | null => { - for (const project of projects) { - const tile = project.tiles.find((entry) => entry.runId === runId); - if (tile) { - return { projectId: project.id, tileId: tile.id }; - } - } - return null; +const findAgentByRunId = (agents: AgentState[], runId: string): string | null => { + const match = agents.find((agent) => agent.runId === runId); + return match ? match.agentId : null; }; -const AgentCanvasPage = () => { - const { client, status } = useGatewayConnection(); - +const AgentStudioPage = () => { + const [settingsCoordinator] = useState(() => getStudioSettingsCoordinator()); const { - state, - dispatch, - createTile, - createProject, - openProject, - deleteProject, - deleteTile, - renameTile, - updateTile, - } = useAgentCanvasStore(); - const project = getActiveProject(state); - const [showProjectForm, setShowProjectForm] = useState(false); - const [showOpenProjectForm, setShowOpenProjectForm] = useState(false); - const [projectName, setProjectName] = useState(""); - const [projectPath, setProjectPath] = useState(""); - const [projectWarnings, setProjectWarnings] = useState<string[]>([]); - const [openProjectWarnings, setOpenProjectWarnings] = useState<string[]>([]); + client, + status, + gatewayUrl, + token, + error: gatewayError, + connect, + disconnect, + setGatewayUrl, + setToken, + } = useGatewayConnection(); + + const { state, dispatch, hydrateAgents, setError, setLoading } = useAgentStore(); + const [showConnectionPanel, setShowConnectionPanel] = useState(false); + const [focusFilter, setFocusFilter] = useState<FocusFilter>("all"); + const [focusedPreferencesLoaded, setFocusedPreferencesLoaded] = useState(false); + const [heartbeatTick, setHeartbeatTick] = useState(0); const historyInFlightRef = useRef<Set<string>>(new Set()); - const historyPollsRef = useRef<Map<string, number>>(new Map()); const stateRef = useRef(state); - const viewportRef = useRef<HTMLDivElement | null>(null); - const [viewportSize, setViewportSize] = useState({ width: 0, height: 0 }); - // flowInstance removed (zoom controls live in the bottom-right ReactFlow Controls). + const focusFilterTouchedRef = useRef(false); + const summaryRefreshRef = useRef<number | null>(null); + const [gatewayModels, setGatewayModels] = useState<GatewayModelChoice[]>([]); + const [gatewayModelsError, setGatewayModelsError] = useState<string | null>(null); + const [inspectAgentId, setInspectAgentId] = useState<string | null>(null); + const studioSessionIdRef = useRef<string | null>(null); + const thinkingDebugRef = useRef<Set<string>>(new Set()); + const chatRunSeenRef = useRef<Set<string>>(new Set()); + const specialUpdateRef = useRef<Map<string, string>>(new Map()); + const specialUpdateInFlightRef = useRef<Set<string>>(new Set()); + const toolLinesSeenRef = useRef<Map<string, Set<string>>>(new Map()); + const assistantStreamByRunRef = useRef<Map<string, string>>(new Map()); - const tiles = useMemo(() => project?.tiles ?? [], [project?.tiles]); + const agents = state.agents; + const selectedAgent = useMemo(() => getSelectedAgent(state), [state]); + const filteredAgents = useMemo( + () => getFilteredAgents(state, focusFilter), + [focusFilter, state] + ); + const focusedAgent = useMemo(() => { + if (filteredAgents.length === 0) return null; + const selectedInFilter = selectedAgent + ? filteredAgents.find((entry) => entry.agentId === selectedAgent.agentId) + : null; + return selectedInFilter ?? filteredAgents[0] ?? null; + }, [filteredAgents, selectedAgent]); + const inspectAgent = useMemo(() => { + if (!inspectAgentId) return null; + return agents.find((entry) => entry.agentId === inspectAgentId) ?? null; + }, [agents, inspectAgentId]); + const faviconSeed = useMemo(() => { + const firstAgent = agents[0]; + const seed = firstAgent?.avatarSeed ?? firstAgent?.agentId ?? ""; + return seed.trim() || null; + }, [agents]); + const faviconHref = useMemo( + () => (faviconSeed ? buildAvatarDataUrl(faviconSeed) : null), + [faviconSeed] + ); + const errorMessage = state.error ?? gatewayModelsError; - const computeNewTilePosition = useCallback( - (tileSize: { width: number; height: number }) => { - if (!project) { - return { x: 80, y: 200 }; - } + const handleFocusFilterChange = useCallback((next: FocusFilter) => { + focusFilterTouchedRef.current = true; + setFocusFilter(next); + }, []); - if (viewportSize.width === 0 || viewportSize.height === 0) { - const offset = project.tiles.length * 36; - return { x: 80 + offset, y: 200 + offset }; + useEffect(() => { + const selector = 'link[data-agent-favicon="true"]'; + const existing = document.querySelector(selector) as HTMLLinkElement | null; + if (!faviconHref) { + existing?.remove(); + return; + } + if (existing) { + if (existing.href !== faviconHref) { + existing.href = faviconHref; } + return; + } + const link = document.createElement("link"); + link.rel = "icon"; + link.type = "image/svg+xml"; + link.href = faviconHref; + link.setAttribute("data-agent-favicon", "true"); + document.head.appendChild(link); + }, [faviconHref]); - const safeTop = 140; - const edgePadding = 24; - const step = 80; - const maxRings = 12; - const zoom = state.canvas.zoom; + const resolveConfiguredModelKey = useCallback( + (raw: string, models?: Record<string, { alias?: string }>) => { + const trimmed = raw.trim(); + if (!trimmed) return null; + if (trimmed.includes("/")) return trimmed; + if (models) { + const target = Object.entries(models).find( + ([, entry]) => entry?.alias?.trim().toLowerCase() === trimmed.toLowerCase() + ); + if (target?.[0]) return target[0]; + } + return `anthropic/${trimmed}`; + }, + [] + ); - const effectiveSize = { - width: MIN_TILE_SIZE.width, - height: Math.max(tileSize.height, MIN_TILE_SIZE.height), + const buildAllowedModelKeys = useCallback( + (snapshot: GatewayConfigSnapshot | null) => { + const allowedList: string[] = []; + const allowedSet = new Set<string>(); + const defaults = snapshot?.config?.agents?.defaults; + const modelDefaults = defaults?.model; + const modelAliases = defaults?.models; + const pushKey = (raw?: string | null) => { + if (!raw) return; + const resolved = resolveConfiguredModelKey(raw, modelAliases); + if (!resolved) return; + if (allowedSet.has(resolved)) return; + allowedSet.add(resolved); + allowedList.push(resolved); }; + if (typeof modelDefaults === "string") { + pushKey(modelDefaults); + } else if (modelDefaults && typeof modelDefaults === "object") { + pushKey(modelDefaults.primary ?? null); + for (const fallback of modelDefaults.fallbacks ?? []) { + pushKey(fallback); + } + } + if (modelAliases) { + for (const key of Object.keys(modelAliases)) { + pushKey(key); + } + } + return allowedList; + }, + [resolveConfiguredModelKey] + ); - const minCenterY = safeTop + (effectiveSize.height * zoom) / 2; - const screenCenter = { - x: viewportSize.width / 2, - y: Math.max(viewportSize.height / 2, minCenterY), - }; - const worldCenter = screenToWorld(state.canvas, screenCenter); - const base = { - x: worldCenter.x - effectiveSize.width / 2, - y: worldCenter.y - effectiveSize.height / 2, - }; + const summarizeThinkingMessage = useCallback((message: unknown) => { + if (!message || typeof message !== "object") { + return { type: typeof message }; + } + const record = message as Record<string, unknown>; + const summary: Record<string, unknown> = { keys: Object.keys(record) }; + const content = record.content; + if (Array.isArray(content)) { + summary.contentTypes = content.map((item) => { + if (item && typeof item === "object") { + const entry = item as Record<string, unknown>; + return typeof entry.type === "string" ? entry.type : "object"; + } + return typeof item; + }); + } else if (typeof content === "string") { + summary.contentLength = content.length; + } + if (typeof record.text === "string") { + summary.textLength = record.text.length; + } + for (const key of ["analysis", "reasoning", "thinking"]) { + const value = record[key]; + if (typeof value === "string") { + summary[`${key}Length`] = value.length; + } else if (value && typeof value === "object") { + summary[`${key}Keys`] = Object.keys(value as Record<string, unknown>); + } + } + return summary; + }, []); - const rectsOverlap = ( - a: { x: number; y: number; width: number; height: number }, - b: { x: number; y: number; width: number; height: number }, - padding = 0 - ) => { - const ax = a.x - padding; - const ay = a.y - padding; - const aw = a.width + padding * 2; - const ah = a.height + padding * 2; - const bx = b.x; - const by = b.y; - const bw = b.width; - const bh = b.height; - return ax < bx + bw && ax + aw > bx && ay < by + bh && ay + ah > by; - }; + const appendUniqueToolLines = useCallback( + (agentId: string, runId: string | null | undefined, lines: string[]) => { + if (lines.length === 0) return; + if (!runId) { + for (const line of lines) { + dispatch({ + type: "appendOutput", + agentId, + line, + }); + } + return; + } + const map = toolLinesSeenRef.current; + const current = map.get(runId) ?? new Set<string>(); + const { appended, nextSeen } = dedupeRunLines(current, lines); + map.set(runId, nextSeen); + for (const line of appended) { + dispatch({ + type: "appendOutput", + agentId, + line, + }); + } + }, + [dispatch] + ); - const candidateFits = (candidate: { x: number; y: number }) => { - const screen = worldToScreen(state.canvas, candidate); - const tileWidth = effectiveSize.width * zoom; - const tileHeight = effectiveSize.height * zoom; - return ( - screen.x >= edgePadding && - screen.y >= safeTop && - screen.x + tileWidth <= viewportSize.width - edgePadding && - screen.y + tileHeight <= viewportSize.height - edgePadding - ); - }; + const clearRunTracking = useCallback((runId?: string | null) => { + if (!runId) return; + chatRunSeenRef.current.delete(runId); + assistantStreamByRunRef.current.delete(runId); + toolLinesSeenRef.current.delete(runId); + }, []); - const candidateOverlaps = (candidate: { x: number; y: number }) => { - const rect = { - x: candidate.x, - y: candidate.y, - width: effectiveSize.width, - height: effectiveSize.height, - }; - return project.tiles.some((tile) => - rectsOverlap( - rect, - { - x: tile.position.x, - y: tile.position.y, - width: MIN_TILE_SIZE.width, - height: Math.max(tile.size.height, MIN_TILE_SIZE.height), - }, - 24 - ) - ); - }; + const resolveCronJobForAgent = useCallback((jobs: CronJobSummary[], agent: AgentState) => { + if (!jobs.length) return null; + const agentId = agent.agentId?.trim(); + const filtered = agentId ? jobs.filter((job) => job.agentId === agentId) : jobs; + const active = filtered.length > 0 ? filtered : jobs; + return [...active].sort((a, b) => b.updatedAtMs - a.updatedAtMs)[0] ?? null; + }, []); - for (let ring = 0; ring <= maxRings; ring += 1) { - for (let dx = -ring; dx <= ring; dx += 1) { - for (let dy = -ring; dy <= ring; dy += 1) { - if (ring > 0 && Math.abs(dx) !== ring && Math.abs(dy) !== ring) { - continue; - } - const candidate = { - x: base.x + dx * step, - y: base.y + dy * step, - }; - if (!candidateFits(candidate)) continue; - if (!candidateOverlaps(candidate)) return candidate; + const updateSpecialLatestUpdate = useCallback( + async (agentId: string, agent: AgentState, message: string) => { + const key = agentId; + const kind = resolveSpecialUpdateKind(message); + if (!kind) { + if (agent.latestOverride || agent.latestOverrideKind) { + dispatch({ + type: "updateAgent", + agentId: agent.agentId, + patch: { latestOverride: null, latestOverrideKind: null }, + }); + } + return; + } + if (specialUpdateInFlightRef.current.has(key)) return; + specialUpdateInFlightRef.current.add(key); + try { + if (kind === "heartbeat") { + const resolvedId = + agent.agentId?.trim() || parseAgentIdFromSessionKey(agent.sessionKey); + if (!resolvedId) { + dispatch({ + type: "updateAgent", + agentId: agent.agentId, + patch: { latestOverride: null, latestOverrideKind: null }, + }); + return; + } + const sessions = await client.call<SessionsListResult>("sessions.list", { + agentId: resolvedId, + includeGlobal: false, + includeUnknown: false, + limit: 48, + }); + const entries = Array.isArray(sessions.sessions) ? sessions.sessions : []; + const heartbeatSessions = entries.filter((entry) => { + const label = entry.origin?.label; + return typeof label === "string" && label.toLowerCase() === "heartbeat"; + }); + const candidates = heartbeatSessions.length > 0 ? heartbeatSessions : entries; + const sorted = [...candidates].sort( + (a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0) + ); + const sessionKey = sorted[0]?.key; + if (!sessionKey) { + dispatch({ + type: "updateAgent", + agentId: agent.agentId, + patch: { latestOverride: null, latestOverrideKind: null }, + }); + return; } + const history = await client.call<ChatHistoryResult>("chat.history", { + sessionKey, + limit: 200, + }); + const content = findLatestHeartbeatResponse(history.messages ?? []) ?? ""; + dispatch({ + type: "updateAgent", + agentId: agent.agentId, + patch: { + latestOverride: content || null, + latestOverrideKind: content ? "heartbeat" : null, + }, + }); + return; } + const cronResult = await fetchCronJobs(); + const job = resolveCronJobForAgent(cronResult.jobs, agent); + const content = job ? buildCronDisplay(job) : ""; + dispatch({ + type: "updateAgent", + agentId: agent.agentId, + patch: { + latestOverride: content || null, + latestOverrideKind: content ? "cron" : null, + }, + }); + } catch (err) { + const message = + err instanceof Error ? err.message : "Failed to load latest cron/heartbeat update."; + logger.error(message); + } finally { + specialUpdateInFlightRef.current.delete(key); } + }, + [client, dispatch, resolveCronJobForAgent] + ); + + const refreshHeartbeatLatestUpdate = useCallback(() => { + const agents = stateRef.current.agents; + for (const agent of agents) { + void updateSpecialLatestUpdate(agent.agentId, agent, "heartbeat"); + } + }, [updateSpecialLatestUpdate]); + + const resolveAgentName = useCallback((agent: AgentsListResult["agents"][number]) => { + const fromList = typeof agent.name === "string" ? agent.name.trim() : ""; + if (fromList) return fromList; + const fromIdentity = + typeof agent.identity?.name === "string" ? agent.identity.name.trim() : ""; + if (fromIdentity) return fromIdentity; + return agent.id; + }, []); - return base; + const resolveAgentAvatarUrl = useCallback( + (agent: AgentsListResult["agents"][number]) => { + const candidate = agent.identity?.avatarUrl ?? agent.identity?.avatar ?? null; + if (typeof candidate !== "string") return null; + const trimmed = candidate.trim(); + if (!trimmed) return null; + if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) return trimmed; + if (trimmed.startsWith("data:image/")) return trimmed; + return null; }, - [project, state.canvas, viewportSize] + [] ); + const loadAgents = useCallback(async () => { + if (status !== "connected") return; + setLoading(true); + try { + const agentsResult = await client.call<AgentsListResult>("agents.list", {}); + type StudioSessionEntry = { key: string; sessionId: string; updatedAt: number }; + type StudioSessionsForAgent = { agentId: string; entries: StudioSessionEntry[] }; + const studioSessionsByAgent = await Promise.all( + agentsResult.agents.map(async (agent): Promise<StudioSessionsForAgent> => { + const prefix = `agent:${agent.id}:studio:`; + try { + const sessions = await client.call<SessionsListResult>("sessions.list", { + agentId: agent.id, + includeGlobal: false, + includeUnknown: false, + limit: 64, + }); + const entries = Array.isArray(sessions.sessions) ? sessions.sessions : []; + const studioEntries = entries + .map((entry): StudioSessionEntry | null => { + const key = entry.key?.trim(); + if (!key || !key.startsWith(prefix)) return null; + const suffix = key.slice(prefix.length).trim(); + if (!suffix) return null; + const updatedAt = typeof entry.updatedAt === "number" ? entry.updatedAt : 0; + return { key, sessionId: suffix, updatedAt }; + }) + .filter((entry): entry is StudioSessionEntry => Boolean(entry)) + .sort((a, b) => b.updatedAt - a.updatedAt); + return { agentId: agent.id, entries: studioEntries }; + } catch (err) { + logger.error("Failed to list sessions while resolving studio session.", err); + return { agentId: agent.id, entries: [] }; + } + }) + ); + const allStudioSessions = studioSessionsByAgent + .flatMap((group) => group.entries) + .sort((a, b) => b.updatedAt - a.updatedAt); + const hadPersistedSessionId = Boolean(studioSessionIdRef.current?.trim()); + let sessionId = studioSessionIdRef.current?.trim() ?? ""; + const hasPersistedSession = + sessionId.length > 0 && allStudioSessions.some((entry) => entry.sessionId === sessionId); + if (!sessionId || (allStudioSessions.length > 0 && !hasPersistedSession)) { + sessionId = allStudioSessions[0]?.sessionId ?? generateUUID(); + studioSessionIdRef.current = sessionId; + } + if (!hadPersistedSessionId || !hasPersistedSession) { + const key = gatewayUrl.trim(); + if (key) { + try { + await settingsCoordinator.applyPatchNow({ + sessions: { [key]: sessionId }, + }); + } catch (err) { + logger.error("Failed to save studio session ID.", err); + } + } + } + const seeds: AgentStoreSeed[] = agentsResult.agents.map((agent) => { + const avatarSeed = agent.id; + const avatarUrl = resolveAgentAvatarUrl(agent); + const name = resolveAgentName(agent); + return { + agentId: agent.id, + name, + sessionKey: buildAgentStudioSessionKey(agent.id, sessionId), + avatarSeed, + avatarUrl, + }; + }); + const existingSessions = new Set<string>(); + const staleStudioKeys: string[] = []; + for (const seed of seeds) { + const sessionsForAgent = studioSessionsByAgent.find( + (entry) => entry.agentId === seed.agentId + )?.entries; + if (!sessionsForAgent || sessionsForAgent.length === 0) continue; + const active = sessionsForAgent.find( + (entry) => entry.sessionId === sessionId + ); + if (!active) continue; + existingSessions.add(active.key); + for (const entry of sessionsForAgent) { + if (entry.key === active.key) continue; + staleStudioKeys.push(entry.key); + } + } + if (staleStudioKeys.length > 0) { + await Promise.all( + staleStudioKeys.map(async (key) => { + try { + await client.call("sessions.delete", { key }); + } catch (err) { + logger.error("Failed to prune duplicate studio session key.", err); + } + }) + ); + } + hydrateAgents(seeds); + if (existingSessions.size > 0) { + for (const seed of seeds) { + if (!existingSessions.has(seed.sessionKey)) continue; + dispatch({ + type: "updateAgent", + agentId: seed.agentId, + patch: { sessionCreated: true }, + }); + } + } + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to load agents."; + setError(message); + } finally { + setLoading(false); + } + }, [ + client, + dispatch, + hydrateAgents, + resolveAgentAvatarUrl, + resolveAgentName, + setError, + setLoading, + settingsCoordinator, + gatewayUrl, + status, + ]); + useEffect(() => { stateRef.current = state; }, [state]); useEffect(() => { - const node = viewportRef.current; - if (!node) return; - const observer = new ResizeObserver((entries) => { - const entry = entries[0]; - if (!entry) return; - const { width, height } = entry.contentRect; - setViewportSize({ width, height }); - }); - observer.observe(node); - return () => observer.disconnect(); - }, []); + let cancelled = false; + const key = gatewayUrl.trim(); + studioSessionIdRef.current = null; + if (!key) { + setFocusedPreferencesLoaded(true); + return; + } + setFocusedPreferencesLoaded(false); + focusFilterTouchedRef.current = false; + const loadFocusedPreferences = async () => { + try { + const settings = await settingsCoordinator.loadSettings(); + if (cancelled || !settings) { + return; + } + if (focusFilterTouchedRef.current) { + return; + } + const persistedSessionId = resolveStudioSessionId(settings, key); + if (persistedSessionId) { + studioSessionIdRef.current = persistedSessionId; + } + const preference = resolveFocusedPreference(settings, key); + if (preference) { + setFocusFilter(preference.filter); + dispatch({ + type: "selectAgent", + agentId: preference.selectedAgentId, + }); + return; + } + setFocusFilter("all"); + } catch (err) { + logger.error("Failed to load focused preference.", err); + } finally { + if (!cancelled) { + setFocusedPreferencesLoaded(true); + } + } + }; + void loadFocusedPreferences(); + return () => { + cancelled = true; + }; + }, [dispatch, gatewayUrl, settingsCoordinator]); + + useEffect(() => { + return () => { + void settingsCoordinator.flushPending(); + }; + }, [settingsCoordinator]); + + useEffect(() => { + const key = gatewayUrl.trim(); + if (!focusedPreferencesLoaded || !key) return; + settingsCoordinator.schedulePatch( + { + focused: { + [key]: { + mode: "focused", + filter: focusFilter, + selectedAgentId: stateRef.current.selectedAgentId, + }, + }, + }, + 300 + ); + }, [focusFilter, focusedPreferencesLoaded, gatewayUrl, state.selectedAgentId, settingsCoordinator]); + + useEffect(() => { + if (status !== "connected" || !focusedPreferencesLoaded) return; + void loadAgents(); + }, [focusedPreferencesLoaded, gatewayUrl, loadAgents, status]); + + useEffect(() => { + if (status === "disconnected") { + setLoading(false); + } + }, [setLoading, status]); + + useEffect(() => { + if (!inspectAgentId) return; + if (state.selectedAgentId && state.selectedAgentId !== inspectAgentId) { + setInspectAgentId(null); + } + }, [inspectAgentId, state.selectedAgentId]); + + useEffect(() => { + if (inspectAgentId && !inspectAgent) { + setInspectAgentId(null); + } + }, [inspectAgentId, inspectAgent]); + + useEffect(() => { + if (status !== "connected") { + setGatewayModels([]); + setGatewayModelsError(null); + return; + } + let cancelled = false; + const loadModels = async () => { + let configSnapshot: GatewayConfigSnapshot | null = null; + try { + configSnapshot = await client.call<GatewayConfigSnapshot>("config.get", {}); + } catch (err) { + logger.error("Failed to load gateway config.", err); + } + try { + const result = await client.call<{ models: GatewayModelChoice[] }>( + "models.list", + {} + ); + if (cancelled) return; + const catalog = Array.isArray(result.models) ? result.models : []; + const allowedKeys = buildAllowedModelKeys(configSnapshot); + if (allowedKeys.length === 0) { + setGatewayModels(catalog); + setGatewayModelsError(null); + return; + } + const filtered = catalog.filter((entry) => + allowedKeys.includes(`${entry.provider}/${entry.id}`) + ); + const filteredKeys = new Set( + filtered.map((entry) => `${entry.provider}/${entry.id}`) + ); + const extras: GatewayModelChoice[] = []; + for (const key of allowedKeys) { + if (filteredKeys.has(key)) continue; + const [provider, id] = key.split("/"); + if (!provider || !id) continue; + extras.push({ provider, id, name: key }); + } + setGatewayModels([...filtered, ...extras]); + setGatewayModelsError(null); + } catch (err) { + if (cancelled) return; + const message = + err instanceof Error ? err.message : "Failed to load models."; + setGatewayModelsError(message); + setGatewayModels([]); + logger.error("Failed to load gateway models.", err); + } + }; + void loadModels(); + return () => { + cancelled = true; + }; + }, [buildAllowedModelKeys, client, status]); + + const loadSummarySnapshot = useCallback(async () => { + const activeAgents = stateRef.current.agents.filter((agent) => agent.sessionCreated); + const sessionKeys = Array.from( + new Set( + activeAgents + .map((agent) => agent.sessionKey) + .filter((key): key is string => typeof key === "string" && key.trim().length > 0) + ) + ).slice(0, 64); + if (sessionKeys.length === 0) return; + try { + const [statusSummary, previewResult] = await Promise.all([ + client.call<StatusSummary>("status", {}), + client.call<SessionsPreviewResult>("sessions.preview", { + keys: sessionKeys, + limit: 8, + maxChars: 240, + }), + ]); + const previewMap = new Map<string, SessionsPreviewEntry>(); + for (const entry of previewResult.previews ?? []) { + previewMap.set(entry.key, entry); + } + const activityByKey = new Map<string, number>(); + const addActivity = (entries?: SessionStatusSummary[]) => { + if (!entries) return; + for (const entry of entries) { + if (!entry?.key || typeof entry.updatedAt !== "number") continue; + activityByKey.set(entry.key, entry.updatedAt); + } + }; + addActivity(statusSummary.sessions?.recent); + for (const group of statusSummary.sessions?.byAgent ?? []) { + addActivity(group.recent); + } + for (const agent of activeAgents) { + const patch: Partial<AgentState> = {}; + const activity = activityByKey.get(agent.sessionKey); + if (typeof activity === "number") { + patch.lastActivityAt = activity; + } + const preview = previewMap.get(agent.sessionKey); + if (preview?.items?.length) { + const lastAssistant = [...preview.items] + .reverse() + .find((item) => item.role === "assistant"); + const lastUser = [...preview.items] + .reverse() + .find((item) => item.role === "user"); + if (lastAssistant?.text) { + const cleaned = stripUiMetadata(lastAssistant.text); + patch.latestPreview = cleaned; + } + if (lastUser?.text) { + patch.lastUserMessage = stripUiMetadata(lastUser.text); + } + } + if (Object.keys(patch).length > 0) { + dispatch({ + type: "updateAgent", + agentId: agent.agentId, + patch, + }); + } + } + } catch (err) { + logger.error("Failed to load summary snapshot.", err); + } + }, [client, dispatch]); + + useEffect(() => { + if (status !== "connected") return; + void loadSummarySnapshot(); + }, [loadSummarySnapshot, status]); - const handleNewAgent = useCallback(async () => { - if (!project) return; - const name = createRandomAgentName(); - const result = await createTile(project.id, name, "coding"); - if (!result) return; - - const nextPosition = computeNewTilePosition(result.tile.size); - dispatch({ - type: "updateTile", - projectId: project.id, - tileId: result.tile.id, - patch: { position: nextPosition }, + useEffect(() => { + if (status !== "connected") return; + const unsubscribe = client.onEvent((event: EventFrame) => { + if (event.event !== "presence" && event.event !== "heartbeat") return; + if (event.event === "heartbeat") { + setHeartbeatTick((prev) => prev + 1); + refreshHeartbeatLatestUpdate(); + } + if (summaryRefreshRef.current !== null) { + window.clearTimeout(summaryRefreshRef.current); + } + summaryRefreshRef.current = window.setTimeout(() => { + summaryRefreshRef.current = null; + void loadSummarySnapshot(); + }, 750); }); - dispatch({ type: "selectTile", tileId: result.tile.id }); - }, [computeNewTilePosition, createTile, dispatch, project]); + return () => { + if (summaryRefreshRef.current !== null) { + window.clearTimeout(summaryRefreshRef.current); + summaryRefreshRef.current = null; + } + unsubscribe(); + }; + }, [client, loadSummarySnapshot, refreshHeartbeatLatestUpdate, status]); - const loadTileHistory = useCallback( - async (projectId: string, tileId: string) => { - const currentProject = stateRef.current.projects.find( - (entry) => entry.id === projectId - ); - const tile = currentProject?.tiles.find((entry) => entry.id === tileId); - const sessionKey = tile?.sessionKey?.trim(); - if (!tile || !sessionKey) return; + useEffect(() => { + if (!state.selectedAgentId) return; + if (agents.some((agent) => agent.agentId === state.selectedAgentId)) return; + dispatch({ type: "selectAgent", agentId: null }); + }, [agents, dispatch, state.selectedAgentId]); + + useEffect(() => { + const nextId = focusedAgent?.agentId ?? null; + if (state.selectedAgentId === nextId) return; + dispatch({ type: "selectAgent", agentId: nextId }); + }, [dispatch, focusedAgent, state.selectedAgentId]); + + useEffect(() => { + for (const agent of agents) { + const lastMessage = agent.lastUserMessage?.trim() ?? ""; + const kind = resolveSpecialUpdateKind(lastMessage); + const key = agent.agentId; + const marker = kind === "heartbeat" ? `${lastMessage}:${heartbeatTick}` : lastMessage; + const previous = specialUpdateRef.current.get(key); + if (previous === marker) continue; + specialUpdateRef.current.set(key, marker); + void updateSpecialLatestUpdate(agent.agentId, agent, lastMessage); + } + }, [agents, heartbeatTick, updateSpecialLatestUpdate]); + + const loadAgentHistory = useCallback( + async (agentId: string) => { + const agent = stateRef.current.agents.find((entry) => entry.agentId === agentId); + const sessionKey = agent?.sessionKey?.trim(); + if (!agent || !agent.sessionCreated || !sessionKey) return; if (historyInFlightRef.current.has(sessionKey)) return; historyInFlightRef.current.add(sessionKey); + const loadedAt = Date.now(); try { const result = await client.call<ChatHistoryResult>("chat.history", { sessionKey, limit: 200, }); - const { lines, lastAssistant, lastRole } = buildHistoryLines( + const { lines, lastAssistant, lastRole, lastUser } = buildHistoryLines( result.messages ?? [] ); - if (lines.length === 0) return; - const currentLines = tile.outputLines; + if (lines.length === 0) { + dispatch({ + type: "updateAgent", + agentId, + patch: { historyLoadedAt: loadedAt }, + }); + return; + } + const currentLines = agent.outputLines; const mergedLines = mergeHistoryWithPending(lines, currentLines); const isSame = mergedLines.length === currentLines.length && mergedLines.every((line, index) => line === currentLines[index]); if (isSame) { - if (!tile.runId && tile.status === "running" && lastRole === "assistant") { - dispatch({ - type: "updateTile", - projectId, - tileId, - patch: { status: "idle", runId: null, streamText: null, thinkingTrace: null }, - }); + const patch: Partial<AgentState> = { historyLoadedAt: loadedAt }; + if (!agent.runId && agent.status === "running" && lastRole === "assistant") { + patch.status = "idle"; + patch.runId = null; + patch.streamText = null; + patch.thinkingTrace = null; } + dispatch({ + type: "updateAgent", + agentId, + patch, + }); return; } - const patch: Partial<AgentTile> = { + const patch: Partial<AgentState> = { outputLines: mergedLines, lastResult: lastAssistant ?? null, + ...(lastAssistant ? { latestPreview: lastAssistant } : {}), + ...(lastUser ? { lastUserMessage: lastUser } : {}), + historyLoadedAt: loadedAt, }; - if (!tile.runId && tile.status === "running" && lastRole === "assistant") { + if (!agent.runId && agent.status === "running" && lastRole === "assistant") { patch.status = "idle"; patch.runId = null; patch.streamText = null; patch.thinkingTrace = null; } dispatch({ - type: "updateTile", - projectId, - tileId, + type: "updateAgent", + agentId, patch, }); } catch (err) { @@ -391,694 +1116,726 @@ const AgentCanvasPage = () => { [client, dispatch] ); - const startHistoryPolling = useCallback( - (projectId: string, tileId: string) => { - const pollKey = `${projectId}:${tileId}`; - const existing = historyPollsRef.current.get(pollKey); - if (existing) { - window.clearTimeout(existing); - historyPollsRef.current.delete(pollKey); - } + useEffect(() => { + if (status !== "connected") return; + for (const agent of agents) { + if (!agent.sessionCreated || agent.historyLoadedAt) continue; + void loadAgentHistory(agent.agentId); + } + }, [agents, loadAgentHistory, status]); - let attempts = 0; - const maxAttempts = 40; - const poll = async () => { - historyPollsRef.current.delete(pollKey); - attempts += 1; - await loadTileHistory(projectId, tileId); - const currentProject = stateRef.current.projects.find( - (entry) => entry.id === projectId - ); - const tile = currentProject?.tiles.find((entry) => entry.id === tileId); - if (!tile || tile.status !== "running") { - return; - } - if (attempts >= maxAttempts) { - return; - } - const timeoutId = window.setTimeout(poll, 1000); - historyPollsRef.current.set(pollKey, timeoutId); - }; + const handleInspectAgent = useCallback( + (agentId: string) => { + setInspectAgentId(agentId); + dispatch({ type: "selectAgent", agentId }); + }, + [dispatch] + ); - const timeoutId = window.setTimeout(poll, 1000); - historyPollsRef.current.set(pollKey, timeoutId); + const handleDeleteAgent = useCallback( + async (agentId: string) => { + const agent = agents.find((entry) => entry.agentId === agentId); + if (!agent) return; + const confirmed = window.confirm( + `Delete ${agent.name}? This removes the agent from the gateway config.` + ); + if (!confirmed) return; + try { + await deleteGatewayAgent({ + client, + agentId, + sessionKey: agent.sessionKey, + }); + setInspectAgentId(null); + await loadAgents(); + } catch (err) { + const msg = err instanceof Error ? err.message : "Failed to delete agent."; + setError(msg); + } }, - [loadTileHistory] + [agents, client, loadAgents, setError] ); + useEffect(() => { + if (status !== "connected") return; + const hasRunning = agents.some((agent) => agent.status === "running"); + if (!hasRunning) return; + for (const agent of stateRef.current.agents) { + if (agent.status !== "running") continue; + void loadAgentHistory(agent.agentId); + } + const timer = window.setInterval(() => { + for (const agent of stateRef.current.agents) { + if (agent.status !== "running") continue; + void loadAgentHistory(agent.agentId); + } + }, 1500); + return () => { + window.clearInterval(timer); + }; + }, [agents, loadAgentHistory, status]); + const handleSend = useCallback( - async (tileId: string, sessionKey: string, message: string) => { - if (!project) return; + async (agentId: string, sessionKey: string, message: string) => { const trimmed = message.trim(); if (!trimmed) return; const isResetCommand = /^\/(reset|new)(\s|$)/i.test(trimmed); const runId = crypto.randomUUID(); - const tile = project.tiles.find((entry) => entry.id === tileId); - if (!tile) { + assistantStreamByRunRef.current.delete(runId); + const agent = agents.find((entry) => entry.agentId === agentId); + if (!agent) { dispatch({ type: "appendOutput", - projectId: project.id, - tileId, - line: "Error: Tile not found.", + agentId, + line: "Error: Agent not found.", }); return; } if (isResetCommand) { dispatch({ - type: "updateTile", - projectId: project.id, - tileId, + type: "updateAgent", + agentId, patch: { outputLines: [], streamText: null, thinkingTrace: null, lastResult: null }, }); } dispatch({ - type: "updateTile", - projectId: project.id, - tileId, - patch: { status: "running", runId, streamText: "", thinkingTrace: null, draft: "" }, + type: "updateAgent", + agentId, + patch: { + status: "running", + runId, + streamText: "", + thinkingTrace: null, + draft: "", + lastUserMessage: trimmed, + lastActivityAt: Date.now(), + }, }); dispatch({ type: "appendOutput", - projectId: project.id, - tileId, + agentId, line: `> ${trimmed}`, }); try { if (!sessionKey) { - throw new Error("Missing session key for tile."); + throw new Error("Missing session key for agent."); } - if (!tile.sessionSettingsSynced) { + let createdSession = agent.sessionCreated; + if (!agent.sessionSettingsSynced) { await client.call("sessions.patch", { key: sessionKey, - model: tile.model ?? null, - thinkingLevel: tile.thinkingLevel ?? null, + model: agent.model ?? null, + thinkingLevel: agent.thinkingLevel ?? null, }); + createdSession = true; dispatch({ - type: "updateTile", - projectId: project.id, - tileId, - patch: { sessionSettingsSynced: true }, + type: "updateAgent", + agentId, + patch: { sessionSettingsSynced: true, sessionCreated: true }, }); } await client.call("chat.send", { sessionKey, - message: buildProjectMessage(project, trimmed), + message: buildAgentInstruction({ message: trimmed }), deliver: false, idempotencyKey: runId, }); - startHistoryPolling(project.id, tileId); + if (!createdSession) { + dispatch({ + type: "updateAgent", + agentId, + patch: { sessionCreated: true }, + }); + } } catch (err) { const msg = err instanceof Error ? err.message : "Gateway error"; dispatch({ - type: "updateTile", - projectId: project.id, - tileId, + type: "updateAgent", + agentId, patch: { status: "error", runId: null, streamText: null, thinkingTrace: null }, }); dispatch({ type: "appendOutput", - projectId: project.id, - tileId, + agentId, line: `Error: ${msg}`, }); } }, - [client, dispatch, project, startHistoryPolling] + [agents, client, dispatch] ); - useEffect(() => { - const polls = historyPollsRef.current; - return () => { - for (const timeoutId of polls.values()) { - window.clearTimeout(timeoutId); - } - polls.clear(); - }; - }, []); - - useEffect(() => { - if (status !== "connected") return; - if (!project) return; - const tilesToLoad = project.tiles.filter( - (tile) => tile.outputLines.length === 0 && tile.sessionKey?.trim() - ); - if (tilesToLoad.length === 0) return; - let cancelled = false; - const loadHistory = async () => { - for (const tile of tilesToLoad) { - if (cancelled) return; - await loadTileHistory(project.id, tile.id); - } - }; - void loadHistory(); - return () => { - cancelled = true; - }; - }, [loadTileHistory, project, status]); - const handleModelChange = useCallback( - async (tileId: string, sessionKey: string, value: string | null) => { - if (!project) return; + async (agentId: string, sessionKey: string, value: string | null) => { dispatch({ - type: "updateTile", - projectId: project.id, - tileId, + type: "updateAgent", + agentId, patch: { model: value, sessionSettingsSynced: false }, }); + const agent = agents.find((entry) => entry.agentId === agentId); + if (!agent?.sessionCreated) return; try { await client.call("sessions.patch", { key: sessionKey, model: value ?? null, }); dispatch({ - type: "updateTile", - projectId: project.id, - tileId, + type: "updateAgent", + agentId, patch: { sessionSettingsSynced: true }, }); } catch (err) { const msg = err instanceof Error ? err.message : "Failed to set model."; dispatch({ type: "appendOutput", - projectId: project.id, - tileId, + agentId, line: `Model update failed: ${msg}`, }); } }, - [client, dispatch, project] + [agents, client, dispatch] ); const handleThinkingChange = useCallback( - async (tileId: string, sessionKey: string, value: string | null) => { - if (!project) return; + async (agentId: string, sessionKey: string, value: string | null) => { dispatch({ - type: "updateTile", - projectId: project.id, - tileId, + type: "updateAgent", + agentId, patch: { thinkingLevel: value, sessionSettingsSynced: false }, }); + const agent = agents.find((entry) => entry.agentId === agentId); + if (!agent?.sessionCreated) return; try { await client.call("sessions.patch", { key: sessionKey, thinkingLevel: value ?? null, }); dispatch({ - type: "updateTile", - projectId: project.id, - tileId, + type: "updateAgent", + agentId, patch: { sessionSettingsSynced: true }, }); } catch (err) { const msg = err instanceof Error ? err.message : "Failed to set thinking level."; dispatch({ type: "appendOutput", - projectId: project.id, - tileId, + agentId, line: `Thinking update failed: ${msg}`, }); } }, - [client, dispatch, project] + [agents, client, dispatch] + ); + + + const handleToolCallingToggle = useCallback( + (agentId: string, enabled: boolean) => { + dispatch({ + type: "updateAgent", + agentId, + patch: { toolCallingEnabled: enabled }, + }); + }, + [dispatch] + ); + + const handleThinkingTracesToggle = useCallback( + (agentId: string, enabled: boolean) => { + dispatch({ + type: "updateAgent", + agentId, + patch: { showThinkingTraces: enabled }, + }); + }, + [dispatch] ); useEffect(() => { return client.onEvent((event: EventFrame) => { - if (event.event !== "chat") return; - const payload = event.payload as ChatEventPayload | undefined; - if (!payload?.sessionKey) return; - const match = findTileBySessionKey(state.projects, payload.sessionKey); - if (!match) return; - - const project = state.projects.find((entry) => entry.id === match.projectId); - const tile = project?.tiles.find((entry) => entry.id === match.tileId); - const role = - payload.message && typeof payload.message === "object" - ? (payload.message as Record<string, unknown>).role - : null; - if (role === "user") { - return; - } - const nextTextRaw = extractText(payload.message); - const nextText = nextTextRaw ? stripUiMetadata(nextTextRaw) : null; - const nextThinking = extractThinking(payload.message); - if (payload.state === "delta") { - if (typeof nextTextRaw === "string" && UI_METADATA_PREFIX_RE.test(nextTextRaw.trim())) { - return; + if (event.event === "chat") { + const payload = event.payload as ChatEventPayload | undefined; + if (!payload?.sessionKey) return; + if (payload.runId) { + chatRunSeenRef.current.add(payload.runId); } - if (nextThinking) { + const agentId = findAgentBySessionKey(state.agents, payload.sessionKey); + if (!agentId) return; + const agent = state.agents.find((entry) => entry.agentId === agentId); + const summaryPatch = getChatSummaryPatch(payload); + if (summaryPatch) { dispatch({ - type: "updateTile", - projectId: match.projectId, - tileId: match.tileId, - patch: { thinkingTrace: nextThinking, status: "running" }, + type: "updateAgent", + agentId, + patch: { ...summaryPatch, sessionCreated: true }, }); } - if (typeof nextText === "string") { - dispatch({ - type: "setStream", - projectId: match.projectId, - tileId: match.tileId, - value: nextText, - }); + const role = + payload.message && typeof payload.message === "object" + ? (payload.message as Record<string, unknown>).role + : null; + if (role === "user") { + return; + } + dispatch({ + type: "markActivity", + agentId, + }); + const nextTextRaw = extractText(payload.message); + const nextText = nextTextRaw ? stripUiMetadata(nextTextRaw) : null; + const nextThinking = extractThinking(payload.message ?? payload); + const toolLines = extractToolLines(payload.message ?? payload); + const isToolRole = role === "tool" || role === "toolResult"; + if (payload.state === "delta") { + if (typeof nextTextRaw === "string" && isUiMetadataPrefix(nextTextRaw.trim())) { + return; + } + appendUniqueToolLines(agentId, payload.runId ?? null, toolLines); + if (nextThinking) { + dispatch({ + type: "updateAgent", + agentId, + patch: { thinkingTrace: nextThinking, status: "running" }, + }); + } + if (typeof nextText === "string") { + dispatch({ + type: "setStream", + agentId, + value: nextText, + }); + dispatch({ + type: "updateAgent", + agentId, + patch: { status: "running" }, + }); + } + return; + } + + if (payload.state === "final") { + clearRunTracking(payload.runId ?? null); + if ( + !nextThinking && + role === "assistant" && + !thinkingDebugRef.current.has(payload.sessionKey) + ) { + thinkingDebugRef.current.add(payload.sessionKey); + console.warn("No thinking trace extracted from chat event.", { + sessionKey: payload.sessionKey, + message: summarizeThinkingMessage(payload.message ?? payload), + }); + } + const thinkingText = nextThinking ?? agent?.thinkingTrace ?? null; + const thinkingLine = thinkingText ? formatThinkingMarkdown(thinkingText) : ""; + if (thinkingLine) { + dispatch({ + type: "appendOutput", + agentId, + line: thinkingLine, + }); + } + appendUniqueToolLines(agentId, payload.runId ?? null, toolLines); + if ( + !thinkingLine && + role === "assistant" && + agent && + !agent.outputLines.some((line) => isTraceMarkdown(line.trim())) + ) { + void loadAgentHistory(agentId); + } + if (!isToolRole && typeof nextText === "string") { + dispatch({ + type: "appendOutput", + agentId, + line: nextText, + }); + dispatch({ + type: "updateAgent", + agentId, + patch: { lastResult: nextText }, + }); + } + if (agent?.lastUserMessage && !agent.latestOverride) { + void updateSpecialLatestUpdate(agentId, agent, agent.lastUserMessage); + } dispatch({ - type: "updateTile", - projectId: match.projectId, - tileId: match.tileId, - patch: { status: "running" }, + type: "updateAgent", + agentId, + patch: { streamText: null, thinkingTrace: null }, }); + return; } - return; - } - if (payload.state === "final") { - const thinkingText = nextThinking ?? tile?.thinkingTrace ?? null; - const thinkingLine = thinkingText ? formatThinkingMarkdown(thinkingText) : ""; - if (thinkingLine) { + if (payload.state === "aborted") { + clearRunTracking(payload.runId ?? null); dispatch({ type: "appendOutput", - projectId: match.projectId, - tileId: match.tileId, - line: thinkingLine, + agentId, + line: "Run aborted.", + }); + dispatch({ + type: "updateAgent", + agentId, + patch: { streamText: null, thinkingTrace: null }, }); + return; } - if (typeof nextText === "string") { + + if (payload.state === "error") { + clearRunTracking(payload.runId ?? null); dispatch({ type: "appendOutput", - projectId: match.projectId, - tileId: match.tileId, - line: nextText, + agentId, + line: payload.errorMessage ? `Error: ${payload.errorMessage}` : "Run error.", }); dispatch({ - type: "updateTile", - projectId: match.projectId, - tileId: match.tileId, - patch: { lastResult: nextText }, + type: "updateAgent", + agentId, + patch: { streamText: null, thinkingTrace: null }, }); } - dispatch({ - type: "updateTile", - projectId: match.projectId, - tileId: match.tileId, - patch: { streamText: null, thinkingTrace: null }, - }); - return; - } - - if (payload.state === "aborted") { - dispatch({ - type: "appendOutput", - projectId: match.projectId, - tileId: match.tileId, - line: "Run aborted.", - }); - dispatch({ - type: "updateTile", - projectId: match.projectId, - tileId: match.tileId, - patch: { streamText: null, thinkingTrace: null }, - }); return; } - if (payload.state === "error") { - dispatch({ - type: "appendOutput", - projectId: match.projectId, - tileId: match.tileId, - line: payload.errorMessage ? `Error: ${payload.errorMessage}` : "Run error.", - }); - dispatch({ - type: "updateTile", - projectId: match.projectId, - tileId: match.tileId, - patch: { streamText: null, thinkingTrace: null }, - }); - } - }); - }, [client, dispatch, state.projects]); - - useEffect(() => { - return client.onEvent((event: EventFrame) => { if (event.event !== "agent") return; const payload = event.payload as AgentEventPayload | undefined; if (!payload?.runId) return; const directMatch = payload.sessionKey - ? findTileBySessionKey(state.projects, payload.sessionKey) + ? findAgentBySessionKey(state.agents, payload.sessionKey) : null; - const match = directMatch ?? findTileByRunId(state.projects, payload.runId); + const match = directMatch ?? findAgentByRunId(state.agents, payload.runId); if (!match) return; - if (payload.stream !== "lifecycle") return; - const project = state.projects.find((entry) => entry.id === match.projectId); - const tile = project?.tiles.find((entry) => entry.id === match.tileId); - if (!tile) return; - const phase = typeof payload.data?.phase === "string" ? payload.data.phase : ""; - if (phase === "start") { + const agent = state.agents.find((entry) => entry.agentId === match); + if (!agent) return; + dispatch({ + type: "markActivity", + agentId: match, + }); + const stream = typeof payload.stream === "string" ? payload.stream : ""; + const data = + payload.data && typeof payload.data === "object" + ? (payload.data as Record<string, unknown>) + : null; + const hasChatEvents = chatRunSeenRef.current.has(payload.runId); + + if (stream === "assistant") { + const rawText = typeof data?.text === "string" ? data.text : ""; + const rawDelta = typeof data?.delta === "string" ? data.delta : ""; + const previousRaw = assistantStreamByRunRef.current.get(payload.runId) ?? ""; + let mergedRaw = previousRaw; + if (rawText) { + mergedRaw = rawText; + } else if (rawDelta) { + mergedRaw = mergeRuntimeStream(previousRaw, rawDelta); + } + if (mergedRaw) { + assistantStreamByRunRef.current.set(payload.runId, mergedRaw); + } + const liveThinking = resolveThinkingFromAgentStream(data, mergedRaw); + const patch: Partial<AgentState> = { + status: "running", + runId: payload.runId, + lastActivityAt: Date.now(), + sessionCreated: true, + }; + if (liveThinking) { + patch.thinkingTrace = liveThinking; + } dispatch({ - type: "updateTile", - projectId: match.projectId, - tileId: match.tileId, - patch: { status: "running", runId: payload.runId }, + type: "updateAgent", + agentId: match, + patch, }); + if (mergedRaw && (!rawText || !isUiMetadataPrefix(rawText.trim()))) { + const visibleText = extractText({ role: "assistant", content: mergedRaw }) ?? mergedRaw; + const cleaned = stripUiMetadata(visibleText); + if ( + cleaned && + shouldPublishAssistantStream({ + mergedRaw, + rawText, + hasChatEvents, + currentStreamText: agent.streamText ?? null, + }) + ) { + dispatch({ + type: "setStream", + agentId: match, + value: cleaned, + }); + } + } return; } - if (phase === "end") { - if (tile.runId && tile.runId !== payload.runId) return; - dispatch({ - type: "updateTile", - projectId: match.projectId, - tileId: match.tileId, - patch: { status: "idle", runId: null, streamText: null, thinkingTrace: null }, - }); + + if (stream === "tool") { + const phase = typeof data?.phase === "string" ? data.phase : ""; + const name = typeof data?.name === "string" ? data.name : "tool"; + const toolCallId = typeof data?.toolCallId === "string" ? data.toolCallId : ""; + if (phase && phase !== "result") { + const args = + (data?.arguments as unknown) ?? + (data?.args as unknown) ?? + (data?.input as unknown) ?? + (data?.parameters as unknown) ?? + null; + const line = formatToolCallMarkdown({ + id: toolCallId || undefined, + name, + arguments: args, + }); + if (line) { + appendUniqueToolLines(match, payload.runId, [line]); + } + return; + } + if (phase !== "result") return; + const result = data?.result; + const isError = typeof data?.isError === "boolean" ? data.isError : undefined; + const resultRecord = + result && typeof result === "object" ? (result as Record<string, unknown>) : null; + const details = + resultRecord && "details" in resultRecord ? resultRecord.details : undefined; + let content: unknown = result; + if (resultRecord) { + if (Array.isArray(resultRecord.content)) { + content = resultRecord.content; + } else if (typeof resultRecord.text === "string") { + content = resultRecord.text; + } + } + const message = { + role: "tool", + toolName: name, + toolCallId, + isError, + details, + content, + }; + appendUniqueToolLines(match, payload.runId, extractToolLines(message)); return; } - if (phase === "error") { - if (tile.runId && tile.runId !== payload.runId) return; - dispatch({ - type: "updateTile", - projectId: match.projectId, - tileId: match.tileId, - patch: { status: "error", runId: null, streamText: null, thinkingTrace: null }, - }); - } - }); - }, [client, dispatch, state.projects]); - - // Zoom controls are available in the bottom-right of the canvas. - const handleProjectCreate = useCallback(async () => { - if (!projectName.trim()) { - setProjectWarnings(["Workspace name is required."]); - return; - } - const result = await createProject(projectName.trim()); - if (!result) return; - setProjectWarnings(result.warnings); - setProjectName(""); - setShowProjectForm(false); - }, [createProject, projectName]); - - const handleProjectOpen = useCallback(async () => { - if (!projectPath.trim()) { - setOpenProjectWarnings(["Workspace path is required."]); - return; - } - const result = await openProject(projectPath.trim()); - if (!result) return; - setOpenProjectWarnings(result.warnings); - setProjectPath(""); - setShowOpenProjectForm(false); - }, [openProject, projectPath]); - - const handleProjectDelete = useCallback(async () => { - if (!project) return; - const confirmation = window.prompt( - `Type DELETE ${project.name} to confirm workspace deletion.` - ); - if (confirmation !== `DELETE ${project.name}`) { - return; - } - const result = await deleteProject(project.id); - if (result?.warnings.length) { - window.alert(result.warnings.join("\n")); - } - }, [deleteProject, project]); - - const handleCreateDiscordChannel = useCallback(async () => { - if (!project) return; - if (!state.selectedTileId) { - window.alert("Select an agent tile first."); - return; - } - const tile = project.tiles.find((entry) => entry.id === state.selectedTileId); - if (!tile) { - window.alert("Selected agent not found."); - return; - } - try { - const result = await createProjectDiscordChannel(project.id, { - agentId: tile.agentId, - agentName: tile.name, + if (stream !== "lifecycle") return; + const summaryPatch = getAgentSummaryPatch(payload); + if (!summaryPatch) return; + const phase = typeof data?.phase === "string" ? data.phase : ""; + if (phase !== "start" && phase !== "end" && phase !== "error") return; + const transition = resolveLifecyclePatch({ + phase, + incomingRunId: payload.runId, + currentRunId: agent.runId, + lastActivityAt: summaryPatch.lastActivityAt ?? Date.now(), }); - const notice = `Created Discord channel #${result.channelName} for ${tile.name}.`; - if (result.warnings.length) { - window.alert(`${notice}\n${result.warnings.join("\n")}`); - } else { - window.alert(notice); + if (transition.kind === "ignore") return; + if (phase === "end" && !hasChatEvents) { + const finalText = agent.streamText?.trim(); + if (finalText) { + dispatch({ + type: "appendOutput", + agentId: match, + line: finalText, + }); + dispatch({ + type: "updateAgent", + agentId: match, + patch: { lastResult: finalText }, + }); + } } - } catch (err) { - const message = - err instanceof Error ? err.message : "Failed to create Discord channel."; - window.alert(message); - } - }, [project, state.selectedTileId]); - - const handleTileDelete = useCallback( - async (tileId: string) => { - if (!project) return; - const result = await deleteTile(project.id, tileId); - if (result?.warnings.length) { - window.alert(result.warnings.join("\n")); + if (transition.clearRunTracking) { + clearRunTracking(payload.runId); + } + dispatch({ + type: "updateAgent", + agentId: match, + patch: transition.patch, + }); + }); + }, [ + appendUniqueToolLines, + clearRunTracking, + client, + dispatch, + loadAgentHistory, + state.agents, + summarizeThinkingMessage, + updateSpecialLatestUpdate, + ]); + + const handleRenameAgent = useCallback( + async (agentId: string, name: string) => { + const agent = agents.find((entry) => entry.agentId === agentId); + if (!agent) return false; + try { + await renameGatewayAgent({ + client, + agentId, + name, + sessionKey: agent.sessionKey, + }); + dispatch({ + type: "updateAgent", + agentId, + patch: { name }, + }); + return true; + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to rename agent."; + setError(message); + return false; } }, - [deleteTile, project] + [agents, client, dispatch, setError] ); const handleAvatarShuffle = useCallback( - async (tileId: string) => { - if (!project) return; + async (agentId: string) => { const avatarSeed = crypto.randomUUID(); - const result = await updateTile(project.id, tileId, { avatarSeed }); - if (!result) return; - if ("error" in result) { - window.alert(result.error); - return; - } - if (result.warnings.length > 0) { - window.alert(result.warnings.join("\n")); - } + dispatch({ + type: "updateAgent", + agentId, + patch: { avatarSeed }, + }); }, - [project, updateTile] + [dispatch] ); const handleNameShuffle = useCallback( - async (tileId: string) => { - if (!project) return; - const name = createRandomAgentName(); - const result = await renameTile(project.id, tileId, normalizeAgentName(name)); - if (!result) return; - if ("error" in result) { - window.alert(result.error); - return; - } - if (result.warnings.length > 0) { - window.alert(result.warnings.join("\n")); - } + async (agentId: string) => { + const name = normalizeAgentName(createRandomAgentName()); + if (!name) return; + await handleRenameAgent(agentId, name); + }, + [handleRenameAgent] + ); + + const handleDraftChange = useCallback( + (agentId: string, value: string) => { + dispatch({ + type: "updateAgent", + agentId, + patch: { draft: value }, + }); }, - [project, renameTile] + [dispatch] ); + const connectionPanelVisible = showConnectionPanel || status !== "connected"; + return ( - <div className="relative h-screen w-screen overflow-hidden"> - <CanvasFlow - tiles={tiles} - projectId={project?.id ?? null} - transform={state.canvas} - viewportRef={viewportRef} - selectedTileId={state.selectedTileId} - canSend={status === "connected"} - onSelectTile={(id) => dispatch({ type: "selectTile", tileId: id })} - onMoveTile={(id, position) => - project - ? dispatch({ - type: "updateTile", - projectId: project.id, - tileId: id, - patch: { position }, - }) - : null - } - onResizeTile={(id, size) => - project - ? dispatch({ - type: "updateTile", - projectId: project.id, - tileId: id, - patch: { - size: { - width: MIN_TILE_SIZE.width, - height: Math.max(size.height, MIN_TILE_SIZE.height), - }, - }, - }) - : null - } - onDeleteTile={handleTileDelete} - onRenameTile={(id, name) => { - if (!project) return Promise.resolve(false); - return renameTile(project.id, id, name).then((result) => { - if (!result) return false; - if ("error" in result) { - window.alert(result.error); - return false; - } - if (result.warnings.length > 0) { - window.alert(result.warnings.join("\n")); - } - return true; - }); - }} - onDraftChange={(id, value) => - project - ? dispatch({ - type: "updateTile", - projectId: project.id, - tileId: id, - patch: { draft: value }, - }) - : null - } - onSend={handleSend} - onModelChange={handleModelChange} - onThinkingChange={handleThinkingChange} - onAvatarShuffle={handleAvatarShuffle} - onNameShuffle={handleNameShuffle} - onUpdateTransform={(patch) => dispatch({ type: "setCanvas", patch })} - /> - - <div className="pointer-events-none absolute inset-0 z-10 flex flex-col gap-4 p-6"> - <div className="pointer-events-auto mx-auto w-full max-w-6xl"> + <div className="relative min-h-screen w-screen overflow-hidden bg-background"> + <div className="relative z-10 flex h-screen flex-col gap-4 px-3 py-3 sm:px-4 sm:py-4 md:px-6 md:py-6"> + <div className="w-full"> <HeaderBar - projects={state.projects.map((entry) => ({ id: entry.id, name: entry.name }))} - activeProjectId={state.activeProjectId} status={status} - onProjectChange={(projectId) => - dispatch({ - type: "setActiveProject", - projectId: projectId.trim() ? projectId : null, - }) - } - onCreateProject={() => { - setProjectWarnings([]); - setOpenProjectWarnings([]); - setShowOpenProjectForm(false); - setShowProjectForm((prev) => !prev); - }} - onOpenProject={() => { - setProjectWarnings([]); - setOpenProjectWarnings([]); - setShowProjectForm(false); - setShowOpenProjectForm((prev) => !prev); - }} - onDeleteProject={handleProjectDelete} - onNewAgent={handleNewAgent} - onCreateDiscordChannel={handleCreateDiscordChannel} - canCreateDiscordChannel={Boolean(project && project.tiles.length > 0)} + gatewayUrl={gatewayUrl} + agentCount={agents.length} + onConnectionSettings={() => setShowConnectionPanel((prev) => !prev)} /> </div> {state.loading ? ( - <div className="pointer-events-auto mx-auto w-full max-w-4xl"> - <div className="glass-panel px-6 py-6 text-slate-700">Loading workspaces…</div> - </div> - ) : null} - - {showProjectForm ? ( - <div className="pointer-events-auto mx-auto w-full max-w-5xl"> - <div className="glass-panel px-6 py-6"> - <div className="flex flex-col gap-4"> - <div className="grid gap-4"> - <label className="flex flex-col gap-1 text-xs font-semibold uppercase tracking-[0.2em] text-slate-500"> - Workspace name - <input - className="h-11 rounded-full border border-slate-300 bg-white/80 px-4 text-sm text-slate-900 outline-none" - value={projectName} - onChange={(event) => setProjectName(event.target.value)} - /> - </label> - </div> - <div className="flex flex-wrap items-center gap-3"> - <button - className="rounded-full bg-[var(--accent)] px-5 py-2 text-sm font-semibold text-white" - type="button" - onClick={handleProjectCreate} - > - Create Workspace - </button> - <button - className="rounded-full border border-slate-300 px-5 py-2 text-sm font-semibold text-slate-700" - type="button" - onClick={() => setShowProjectForm(false)} - > - Cancel - </button> - </div> - {projectWarnings.length > 0 ? ( - <div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-2 text-sm text-amber-700"> - {projectWarnings.join(" ")} - </div> - ) : null} - </div> + <div className="w-full"> + <div className="glass-panel px-6 py-6 font-mono text-[11px] uppercase tracking-[0.14em] text-muted-foreground"> + Loading agents… </div> </div> ) : null} - {showOpenProjectForm ? ( - <div className="pointer-events-auto mx-auto w-full max-w-5xl"> - <div className="glass-panel px-6 py-6"> - <div className="flex flex-col gap-4"> - <div className="grid gap-4"> - <label className="flex flex-col gap-1 text-xs font-semibold uppercase tracking-[0.2em] text-slate-500"> - Workspace path - <input - className="h-11 rounded-full border border-slate-300 bg-white/80 px-4 text-sm text-slate-900 outline-none" - value={projectPath} - onChange={(event) => setProjectPath(event.target.value)} - placeholder="/Users/you/repos/my-workspace" - /> - </label> - </div> - <div className="flex flex-wrap items-center gap-3"> - <button - className="rounded-full bg-[var(--accent)] px-5 py-2 text-sm font-semibold text-white" - type="button" - onClick={handleProjectOpen} - > - Open Workspace - </button> - <button - className="rounded-full border border-slate-300 px-5 py-2 text-sm font-semibold text-slate-700" - type="button" - onClick={() => setShowOpenProjectForm(false)} - > - Cancel - </button> - </div> - {openProjectWarnings.length > 0 ? ( - <div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-2 text-sm text-amber-700"> - {openProjectWarnings.join(" ")} - </div> - ) : null} - </div> + {connectionPanelVisible ? ( + <div className="w-full"> + <div className="glass-panel px-4 py-4 sm:px-6 sm:py-6"> + <ConnectionPanel + gatewayUrl={gatewayUrl} + token={token} + status={status} + error={gatewayError} + onGatewayUrlChange={setGatewayUrl} + onTokenChange={setToken} + onConnect={() => void connect()} + onDisconnect={disconnect} + /> </div> </div> ) : null} - {state.error ? ( - <div className="pointer-events-auto mx-auto w-full max-w-4xl"> - <div className="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-2 text-sm text-rose-700"> - {state.error} + {errorMessage ? ( + <div className="w-full"> + <div className="rounded-md border border-destructive bg-destructive px-4 py-2 text-sm text-destructive-foreground"> + {errorMessage} </div> </div> ) : null} - {project ? null : ( - <div className="pointer-events-auto mx-auto w-full max-w-4xl"> - <div className="glass-panel px-6 py-8 text-slate-600"> - Create a workspace to begin. - </div> + <div className="flex min-h-0 flex-1 flex-col gap-4 xl:flex-row"> + <FleetSidebar + agents={filteredAgents} + selectedAgentId={focusedAgent?.agentId ?? state.selectedAgentId} + filter={focusFilter} + onFilterChange={handleFocusFilterChange} + onSelectAgent={(agentId) => + dispatch({ type: "selectAgent", agentId }) + } + /> + <div + className="glass-panel min-h-0 flex-1 overflow-hidden p-2 sm:p-3" + data-testid="focused-agent-panel" + > + {focusedAgent ? ( + <AgentChatPanel + agent={focusedAgent} + isSelected={false} + canSend={status === "connected"} + onInspect={() => handleInspectAgent(focusedAgent.agentId)} + onNameChange={(name) => + handleRenameAgent(focusedAgent.agentId, name) + } + onDraftChange={(value) => + handleDraftChange(focusedAgent.agentId, value) + } + onSend={(message) => + handleSend( + focusedAgent.agentId, + focusedAgent.sessionKey, + message + ) + } + onAvatarShuffle={() => handleAvatarShuffle(focusedAgent.agentId)} + onNameShuffle={() => handleNameShuffle(focusedAgent.agentId)} + /> + ) : ( + <div className="flex h-full items-center justify-center rounded-md border border-border/80 bg-card/70 p-6 text-sm text-muted-foreground"> + {agents.length > 0 + ? "No agents match this filter." + : "No agents available."} + </div> + )} </div> - )} + {inspectAgent ? ( + <div className="glass-panel min-h-0 w-full shrink-0 overflow-hidden p-0 xl:min-w-[360px] xl:max-w-[430px]"> + <AgentInspectPanel + key={inspectAgent.agentId} + agent={inspectAgent} + client={client} + models={gatewayModels} + onClose={() => setInspectAgentId(null)} + onDelete={() => handleDeleteAgent(inspectAgent.agentId)} + onModelChange={(value) => + handleModelChange(inspectAgent.agentId, inspectAgent.sessionKey, value) + } + onThinkingChange={(value) => + handleThinkingChange(inspectAgent.agentId, inspectAgent.sessionKey, value) + } + onToolCallingToggle={(enabled) => + handleToolCallingToggle(inspectAgent.agentId, enabled) + } + onThinkingTracesToggle={(enabled) => + handleThinkingTracesToggle(inspectAgent.agentId, enabled) + } + /> + </div> + ) : null} + </div> </div> </div> ); @@ -1086,8 +1843,8 @@ const AgentCanvasPage = () => { export default function Home() { return ( - <AgentCanvasProvider> - <AgentCanvasPage /> - </AgentCanvasProvider> + <AgentStoreProvider> + <AgentStudioPage /> + </AgentStoreProvider> ); } diff --git a/src/app/styles/markdown.css b/src/app/styles/markdown.css index 776f1f50..e412b26c 100644 --- a/src/app/styles/markdown.css +++ b/src/app/styles/markdown.css @@ -2,6 +2,7 @@ display: block; white-space: pre-wrap; word-break: break-word; + line-height: 1.48; } .agent-markdown p { @@ -57,18 +58,20 @@ } .agent-markdown code { - font-family: var(--font-code), ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, + font-family: var(--font-mono), ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; - font-size: 0.85em; - background: rgba(15, 23, 42, 0.08); - padding: 0.1rem 0.25rem; - border-radius: 0.25rem; + font-size: 0.82em; + background: color-mix(in oklch, var(--muted) 85%, transparent); + padding: 0.14rem 0.28rem; + border-radius: 0.32rem; + border: 1px solid color-mix(in oklch, var(--border) 75%, transparent); } .agent-markdown pre { - background: rgba(15, 23, 42, 0.06); - border-radius: 0.5rem; - padding: 0.6rem 0.75rem; + background: color-mix(in oklch, var(--card) 75%, var(--muted)); + border-radius: 0.58rem; + padding: 0.66rem 0.78rem; + border: 1px solid color-mix(in oklch, var(--border) 72%, transparent); overflow-x: auto; } @@ -79,9 +82,9 @@ } .agent-markdown blockquote { - border-left: 3px solid rgba(15, 23, 42, 0.2); - padding-left: 0.75rem; - color: rgba(15, 23, 42, 0.7); + border-left: 3px solid color-mix(in oklch, var(--primary) 28%, var(--border)); + padding-left: 0.7rem; + color: var(--muted-foreground); } .agent-markdown table { @@ -92,12 +95,14 @@ .agent-markdown th, .agent-markdown td { - border: 1px solid rgba(15, 23, 42, 0.15); + border: 1px solid var(--border); padding: 0.3rem 0.5rem; text-align: left; } .agent-markdown a { - color: var(--accent-strong); + color: color-mix(in oklch, var(--primary) 68%, var(--foreground)); text-decoration: underline; + text-decoration-thickness: 1px; + text-underline-offset: 2px; } diff --git a/src/components/theme-toggle.tsx b/src/components/theme-toggle.tsx new file mode 100644 index 00000000..ea1d9765 --- /dev/null +++ b/src/components/theme-toggle.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Moon, Sun } from "lucide-react"; + +const THEME_STORAGE_KEY = "theme"; + +type ThemeMode = "light" | "dark"; + +const getPreferredTheme = (): ThemeMode => { + if (typeof window === "undefined") return "light"; + const stored = window.localStorage.getItem(THEME_STORAGE_KEY); + if (stored === "light" || stored === "dark") return stored; + const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; + return prefersDark ? "dark" : "light"; +}; + +const applyTheme = (mode: ThemeMode) => { + if (typeof document === "undefined") return; + document.documentElement.classList.toggle("dark", mode === "dark"); +}; + +export const ThemeToggle = () => { + // Keep SSR + initial hydration stable ("light") to avoid markup mismatch. + const [theme, setTheme] = useState<ThemeMode>("light"); + + useEffect(() => { + const preferred = getPreferredTheme(); + // eslint-disable-next-line react-hooks/set-state-in-effect + setTheme(preferred); + applyTheme(preferred); + }, []); + + const toggleTheme = () => { + setTheme((current) => { + const next: ThemeMode = current === "dark" ? "light" : "dark"; + if (typeof window !== "undefined") { + window.localStorage.setItem(THEME_STORAGE_KEY, next); + } + applyTheme(next); + return next; + }); + }; + + const isDark = theme === "dark"; + + return ( + <button + type="button" + onClick={toggleTheme} + aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"} + className="inline-flex h-10 w-10 items-center justify-center rounded-md border border-input/90 bg-background/75 text-foreground shadow-sm transition hover:border-ring hover:bg-card" + > + {isDark ? <Sun className="h-[15px] w-[15px]" /> : <Moon className="h-[15px] w-[15px]" />} + </button> + ); +}; diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 915ea2a0..0906d42c 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -11,7 +11,7 @@ const buttonVariants = cva( 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", + "bg-destructive text-destructive-foreground 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: diff --git a/src/features/agents/components/AgentAvatar.tsx b/src/features/agents/components/AgentAvatar.tsx new file mode 100644 index 00000000..44581080 --- /dev/null +++ b/src/features/agents/components/AgentAvatar.tsx @@ -0,0 +1,43 @@ +import Image from "next/image"; +import { useMemo } from "react"; + +import { buildAvatarDataUrl } from "@/lib/avatars/multiavatar"; + +type AgentAvatarProps = { + seed: string; + name: string; + avatarUrl?: string | null; + size?: number; + isSelected?: boolean; +}; + +export const AgentAvatar = ({ + seed, + name, + avatarUrl, + size = 112, + isSelected = false, +}: AgentAvatarProps) => { + const src = useMemo(() => { + const trimmed = avatarUrl?.trim(); + if (trimmed) return trimmed; + return buildAvatarDataUrl(seed); + }, [avatarUrl, seed]); + + return ( + <div + className={`flex items-center justify-center overflow-hidden rounded-full border border-border/80 bg-card shadow-sm transition-transform duration-300 ${isSelected ? "agent-avatar-selected scale-[1.02]" : ""}`} + style={{ width: size, height: size }} + > + <Image + className="pointer-events-none h-full w-full select-none" + src={src} + alt={`Avatar for ${name}`} + width={size} + height={size} + unoptimized + draggable={false} + /> + </div> + ); +}; diff --git a/src/features/agents/components/AgentChatPanel.tsx b/src/features/agents/components/AgentChatPanel.tsx new file mode 100644 index 00000000..8e529107 --- /dev/null +++ b/src/features/agents/components/AgentChatPanel.tsx @@ -0,0 +1,313 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { AgentState as AgentRecord } from "@/features/agents/state/store"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { normalizeAgentName } from "@/lib/names/agentNames"; +import { Shuffle } from "lucide-react"; +import { AgentAvatar } from "./AgentAvatar"; +import { buildAgentChatItems, summarizeToolLabel } from "./chatItems"; + +type AgentChatPanelProps = { + agent: AgentRecord; + isSelected: boolean; + canSend: boolean; + onInspect: () => void; + onNameChange: (name: string) => Promise<boolean>; + onDraftChange: (value: string) => void; + onSend: (message: string) => void; + onAvatarShuffle: () => void; + onNameShuffle: () => void; +}; + +export const AgentChatPanel = ({ + agent, + isSelected, + canSend, + onInspect, + onNameChange, + onDraftChange, + onSend, + onAvatarShuffle, + onNameShuffle, +}: AgentChatPanelProps) => { + const [nameDraft, setNameDraft] = useState(agent.name); + const [draftValue, setDraftValue] = useState(agent.draft); + const draftRef = useRef<HTMLTextAreaElement | null>(null); + const chatRef = useRef<HTMLDivElement | null>(null); + const plainDraftRef = useRef(agent.draft); + + const resizeDraft = useCallback(() => { + const el = draftRef.current; + if (!el) return; + el.style.height = "auto"; + el.style.height = `${el.scrollHeight}px`; + el.style.overflowY = el.scrollHeight > el.clientHeight ? "auto" : "hidden"; + }, []); + + const handleDraftRef = useCallback((el: HTMLTextAreaElement | HTMLInputElement | null) => { + draftRef.current = el instanceof HTMLTextAreaElement ? el : null; + }, []); + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect + setNameDraft(agent.name); + }, [agent.name]); + + useEffect(() => { + if (agent.draft === plainDraftRef.current) return; + plainDraftRef.current = agent.draft; + // eslint-disable-next-line react-hooks/set-state-in-effect + setDraftValue(agent.draft); + }, [agent.draft]); + + useEffect(() => { + resizeDraft(); + }, [resizeDraft, agent.draft]); + + const commitName = async () => { + const next = normalizeAgentName(nameDraft); + if (!next) { + setNameDraft(agent.name); + return; + } + if (next === agent.name) { + return; + } + const ok = await onNameChange(next); + if (!ok) { + setNameDraft(agent.name); + return; + } + setNameDraft(next); + }; + + const statusColor = + agent.status === "running" + ? "border border-primary/30 bg-primary/15 text-foreground" + : agent.status === "error" + ? "border border-destructive/35 bg-destructive/12 text-destructive" + : "border border-border/70 bg-muted text-muted-foreground"; + const statusLabel = + agent.status === "running" + ? "Running" + : agent.status === "error" + ? "Error" + : "Waiting for direction"; + + const chatItems = useMemo( + () => + buildAgentChatItems({ + outputLines: agent.outputLines, + streamText: agent.streamText, + liveThinkingTrace: agent.thinkingTrace?.trim() ?? "", + showThinkingTraces: agent.showThinkingTraces, + toolCallingEnabled: agent.toolCallingEnabled, + }), + [ + agent.outputLines, + agent.streamText, + agent.thinkingTrace, + agent.showThinkingTraces, + agent.toolCallingEnabled, + ] + ); + + const avatarSeed = agent.avatarSeed ?? agent.agentId; + return ( + <div data-agent-panel className="group fade-up relative flex h-full w-full flex-col"> + <div className="px-3 pt-3 sm:px-4 sm:pt-4"> + <div className="flex items-start justify-between gap-4"> + <div className="flex items-start gap-3"> + <div className="relative"> + <AgentAvatar + seed={avatarSeed} + name={agent.name} + avatarUrl={agent.avatarUrl ?? null} + size={96} + isSelected={isSelected} + /> + <button + className="nodrag absolute -bottom-2 -right-2 flex h-8 w-8 items-center justify-center rounded-md border border-border/80 bg-card text-muted-foreground shadow-sm transition hover:border-border hover:bg-muted/65" + type="button" + aria-label="Shuffle avatar" + data-testid="agent-avatar-shuffle" + onClick={(event) => { + event.preventDefault(); + event.stopPropagation(); + onAvatarShuffle(); + }} + > + <Shuffle className="h-4 w-4" /> + </button> + </div> + <div className="flex flex-col gap-2"> + <div + className={`flex items-center gap-2 rounded-md border bg-card/80 px-3 py-1 shadow-sm ${ + isSelected ? "agent-name-selected" : "border-border" + }`} + > + <input + className="w-full bg-transparent text-center text-xs font-semibold uppercase tracking-[0.16em] text-foreground outline-none" + value={nameDraft} + onChange={(event) => setNameDraft(event.target.value)} + onBlur={() => { + void commitName(); + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.currentTarget.blur(); + } + if (event.key === "Escape") { + setNameDraft(agent.name); + event.currentTarget.blur(); + } + }} + /> + <button + className="nodrag flex h-6 w-6 items-center justify-center rounded-md border border-border/80 bg-card text-muted-foreground transition hover:border-border hover:bg-muted/65" + type="button" + aria-label="Shuffle name" + data-testid="agent-name-shuffle" + onClick={(event) => { + event.preventDefault(); + event.stopPropagation(); + onNameShuffle(); + }} + > + <Shuffle className="h-3 w-3" /> + </button> + </div> + <div className="flex flex-wrap items-center gap-2"> + <span + className={`rounded-md px-2 py-0.5 font-mono text-[9px] font-semibold uppercase tracking-[0.14em] ${statusColor}`} + > + {statusLabel} + </span> + <button + className="nodrag rounded-md border border-border/80 bg-card/60 px-3 py-2 font-mono text-[10px] font-semibold uppercase tracking-[0.13em] text-muted-foreground transition hover:border-border hover:bg-muted/65" + type="button" + data-testid="agent-inspect-toggle" + onClick={onInspect} + > + Inspect + </button> + </div> + </div> + </div> + </div> + </div> + + <div className="mt-3 flex min-h-0 flex-1 flex-col gap-3 px-3 pb-3 sm:px-4 sm:pb-4"> + <div + ref={chatRef} + className="flex-1 overflow-auto rounded-md border border-border/80 bg-card/75 p-3 sm:p-4" + onWheel={(event) => { + event.stopPropagation(); + }} + onWheelCapture={(event) => { + event.stopPropagation(); + }} + > + <div className="flex flex-col gap-3 text-xs text-foreground"> + {chatItems.length === 0 ? ( + <div className="text-xs text-muted-foreground">No messages yet.</div> + ) : ( + <> + {chatItems.map((item, index) => { + if (item.kind === "thinking") { + return ( + <details + key={`chat-${agent.agentId}-thinking-${index}`} + className="rounded-md border border-border/70 bg-muted/55 px-2 py-1 text-[11px] text-muted-foreground" + open={item.live && agent.status === "running"} + > + <summary className="cursor-pointer select-none font-mono text-[10px] font-semibold uppercase tracking-[0.11em]"> + Thinking + </summary> + <div className="agent-markdown mt-1 text-foreground"> + <ReactMarkdown remarkPlugins={[remarkGfm]}> + {item.text} + </ReactMarkdown> + </div> + </details> + ); + } + if (item.kind === "user") { + return ( + <div + key={`chat-${agent.agentId}-user-${index}`} + className="rounded-md border border-border/70 bg-muted/70 px-3 py-2 text-foreground" + > + <ReactMarkdown remarkPlugins={[remarkGfm]}>{`> ${item.text}`}</ReactMarkdown> + </div> + ); + } + if (item.kind === "tool") { + const { summaryText, body } = summarizeToolLabel(item.text); + return ( + <details + key={`chat-${agent.agentId}-tool-${index}`} + className="rounded-md border border-border/70 bg-muted/55 px-2 py-1 text-[11px] text-muted-foreground" + > + <summary className="cursor-pointer select-none font-mono text-[10px] font-semibold uppercase tracking-[0.11em]"> + {summaryText} + </summary> + {body ? ( + <div className="agent-markdown mt-1 text-foreground"> + <ReactMarkdown remarkPlugins={[remarkGfm]}>{body}</ReactMarkdown> + </div> + ) : null} + </details> + ); + } + return ( + <div + key={`chat-${agent.agentId}-assistant-${index}`} + className={`agent-markdown rounded-md border border-transparent px-0.5 ${item.live ? "opacity-85" : ""}`} + > + <ReactMarkdown remarkPlugins={[remarkGfm]}>{item.text}</ReactMarkdown> + </div> + ); + })} + </> + )} + </div> + </div> + + <div className="flex items-end gap-2"> + <textarea + ref={handleDraftRef} + rows={1} + value={draftValue} + className="flex-1 resize-none rounded-md border border-border/80 bg-card/75 px-3 py-2 text-[11px] text-foreground outline-none transition focus:border-ring" + onChange={(event) => { + const value = event.target.value; + plainDraftRef.current = value; + setDraftValue(value); + onDraftChange(value); + resizeDraft(); + }} + onKeyDown={(event) => { + if (event.key !== "Enter" || event.shiftKey) return; + if (event.defaultPrevented) return; + event.preventDefault(); + if (!canSend || agent.status === "running") return; + const message = draftValue.trim(); + if (!message) return; + onSend(message); + }} + placeholder="type a message" + /> + <button + className="rounded-md border border-transparent bg-primary px-3 py-2 font-mono text-[10px] font-semibold uppercase tracking-[0.12em] text-primary-foreground shadow-sm transition hover:brightness-110 disabled:cursor-not-allowed disabled:border-border disabled:bg-muted disabled:text-muted-foreground disabled:shadow-none" + type="button" + onClick={() => onSend(draftValue)} + disabled={!canSend || agent.status === "running" || !draftValue.trim()} + > + Send + </button> + </div> + </div> + </div> + ); +}; diff --git a/src/features/agents/components/AgentInspectPanel.tsx b/src/features/agents/components/AgentInspectPanel.tsx new file mode 100644 index 00000000..be5f6a5f --- /dev/null +++ b/src/features/agents/components/AgentInspectPanel.tsx @@ -0,0 +1,777 @@ +"use client"; + +import type React from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import type { AgentState } from "@/features/agents/state/store"; +import type { GatewayClient } from "@/lib/gateway/GatewayClient"; +import { + resolveHeartbeatSettings, + updateGatewayHeartbeat, + type GatewayConfigSnapshot, +} from "@/lib/gateway/agentConfig"; +import { invokeGatewayTool } from "@/lib/gateway/tools"; +import type { GatewayModelChoice } from "@/lib/gateway/models"; +import { + createAgentFilesState, + isAgentFileName, + AGENT_FILE_META, + AGENT_FILE_NAMES, + AGENT_FILE_PLACEHOLDERS, + type AgentFileName, +} from "@/lib/agents/agentFiles"; + +const HEARTBEAT_INTERVAL_OPTIONS = ["15m", "30m", "1h", "2h", "6h", "12h", "24h"]; + +type AgentInspectPanelProps = { + agent: AgentState; + client: GatewayClient; + models: GatewayModelChoice[]; + onClose: () => void; + onDelete: () => void; + onModelChange: (value: string | null) => void; + onThinkingChange: (value: string | null) => void; + onToolCallingToggle: (enabled: boolean) => void; + onThinkingTracesToggle: (enabled: boolean) => void; +}; + +export const AgentInspectPanel = ({ + agent, + client, + models, + onClose, + onDelete, + onModelChange, + onThinkingChange, + onToolCallingToggle, + onThinkingTracesToggle, +}: AgentInspectPanelProps) => { + const [agentFiles, setAgentFiles] = useState(createAgentFilesState); + const [agentFileTab, setAgentFileTab] = useState<AgentFileName>( + AGENT_FILE_NAMES[0] + ); + const [agentFilesLoading, setAgentFilesLoading] = useState(false); + const [agentFilesSaving, setAgentFilesSaving] = useState(false); + const [agentFilesDirty, setAgentFilesDirty] = useState(false); + const [agentFilesError, setAgentFilesError] = useState<string | null>(null); + const [heartbeatLoading, setHeartbeatLoading] = useState(false); + const [heartbeatSaving, setHeartbeatSaving] = useState(false); + const [heartbeatDirty, setHeartbeatDirty] = useState(false); + const [heartbeatError, setHeartbeatError] = useState<string | null>(null); + const [heartbeatOverride, setHeartbeatOverride] = useState(false); + const [heartbeatEnabled, setHeartbeatEnabled] = useState(true); + const [heartbeatEvery, setHeartbeatEvery] = useState("30m"); + const [heartbeatIntervalMode, setHeartbeatIntervalMode] = useState< + "preset" | "custom" + >("preset"); + const [heartbeatCustomMinutes, setHeartbeatCustomMinutes] = useState("45"); + const [heartbeatTargetMode, setHeartbeatTargetMode] = useState< + "last" | "none" | "custom" + >("last"); + const [heartbeatTargetCustom, setHeartbeatTargetCustom] = useState(""); + const [heartbeatIncludeReasoning, setHeartbeatIncludeReasoning] = useState(false); + const [heartbeatActiveHoursEnabled, setHeartbeatActiveHoursEnabled] = + useState(false); + const [heartbeatActiveStart, setHeartbeatActiveStart] = useState("08:00"); + const [heartbeatActiveEnd, setHeartbeatActiveEnd] = useState("18:00"); + const [heartbeatAckMaxChars, setHeartbeatAckMaxChars] = useState("300"); + const extractToolText = useCallback((result: unknown) => { + if (!result || typeof result !== "object") return ""; + const record = result as Record<string, unknown>; + if (typeof record.text === "string") return record.text; + const content = record.content; + if (!Array.isArray(content)) return ""; + const blocks = content + .map((item) => { + if (!item || typeof item !== "object") return null; + const block = item as Record<string, unknown>; + if (block.type !== "text" || typeof block.text !== "string") return null; + return block.text; + }) + .filter((text): text is string => Boolean(text)); + return blocks.join(""); + }, []); + + const isMissingFileError = useCallback( + (message: string) => /no such file|enoent/i.test(message), + [] + ); + + const loadAgentFiles = useCallback(async () => { + setAgentFilesLoading(true); + setAgentFilesError(null); + try { + const sessionKey = agent.sessionKey?.trim(); + if (!sessionKey) { + setAgentFilesError("Session key is missing for this agent."); + return; + } + const results = await Promise.all( + AGENT_FILE_NAMES.map(async (name) => { + const response = await invokeGatewayTool({ + tool: "read", + sessionKey, + args: { path: name }, + }); + if (!response.ok) { + if (isMissingFileError(response.error)) { + return { name, content: "", exists: false }; + } + throw new Error(response.error); + } + const content = extractToolText(response.result); + return { name, content, exists: true }; + }) + ); + const nextState = createAgentFilesState(); + for (const file of results) { + if (!isAgentFileName(file.name)) continue; + nextState[file.name] = { + content: file.content ?? "", + exists: Boolean(file.exists), + }; + } + setAgentFiles(nextState); + setAgentFilesDirty(false); + } catch (err) { + const message = + err instanceof Error ? err.message : "Failed to load agent files."; + setAgentFilesError(message); + } finally { + setAgentFilesLoading(false); + } + }, [extractToolText, isMissingFileError, agent.sessionKey]); + + const saveAgentFiles = useCallback(async () => { + setAgentFilesSaving(true); + setAgentFilesError(null); + try { + const sessionKey = agent.sessionKey?.trim(); + if (!sessionKey) { + setAgentFilesError("Session key is missing for this agent."); + return; + } + await Promise.all( + AGENT_FILE_NAMES.map(async (name) => { + const response = await invokeGatewayTool({ + tool: "write", + sessionKey, + args: { path: name, content: agentFiles[name].content }, + }); + if (!response.ok) { + throw new Error(response.error); + } + return name; + }) + ); + const nextState = createAgentFilesState(); + for (const name of AGENT_FILE_NAMES) { + nextState[name] = { + content: agentFiles[name].content, + exists: true, + }; + } + setAgentFiles(nextState); + setAgentFilesDirty(false); + } catch (err) { + const message = + err instanceof Error ? err.message : "Failed to save agent files."; + setAgentFilesError(message); + } finally { + setAgentFilesSaving(false); + } + }, [agent.sessionKey, agentFiles]); + + const handleAgentFileTabChange = useCallback( + (nextTab: AgentFileName) => { + if (nextTab === agentFileTab) return; + if (agentFilesDirty && !agentFilesSaving) { + void saveAgentFiles(); + } + setAgentFileTab(nextTab); + }, + [saveAgentFiles, agentFilesDirty, agentFilesSaving, agentFileTab] + ); + + const loadHeartbeat = useCallback(async () => { + setHeartbeatLoading(true); + setHeartbeatError(null); + try { + const snapshot = await client.call<GatewayConfigSnapshot>("config.get", {}); + const config = + snapshot.config && typeof snapshot.config === "object" ? snapshot.config : {}; + const result = resolveHeartbeatSettings(config, agent.agentId); + const every = result.heartbeat.every ?? "30m"; + const enabled = every !== "0m"; + const isPreset = HEARTBEAT_INTERVAL_OPTIONS.includes(every); + if (isPreset) { + setHeartbeatIntervalMode("preset"); + } else { + setHeartbeatIntervalMode("custom"); + const parsed = + every.endsWith("m") + ? Number.parseInt(every, 10) + : every.endsWith("h") + ? Number.parseInt(every, 10) * 60 + : Number.parseInt(every, 10); + if (Number.isFinite(parsed) && parsed > 0) { + setHeartbeatCustomMinutes(String(parsed)); + } + } + const target = result.heartbeat.target ?? "last"; + const targetMode = target === "last" || target === "none" ? target : "custom"; + setHeartbeatOverride(result.hasOverride); + setHeartbeatEnabled(enabled); + setHeartbeatEvery(enabled ? every : "30m"); + setHeartbeatTargetMode(targetMode); + setHeartbeatTargetCustom(targetMode === "custom" ? target : ""); + setHeartbeatIncludeReasoning(Boolean(result.heartbeat.includeReasoning)); + if (result.heartbeat.activeHours) { + setHeartbeatActiveHoursEnabled(true); + setHeartbeatActiveStart(result.heartbeat.activeHours.start); + setHeartbeatActiveEnd(result.heartbeat.activeHours.end); + } else { + setHeartbeatActiveHoursEnabled(false); + } + if (typeof result.heartbeat.ackMaxChars === "number") { + setHeartbeatAckMaxChars(String(result.heartbeat.ackMaxChars)); + } else { + setHeartbeatAckMaxChars("300"); + } + setHeartbeatDirty(false); + } catch (err) { + const message = + err instanceof Error ? err.message : "Failed to load heartbeat settings."; + setHeartbeatError(message); + } finally { + setHeartbeatLoading(false); + } + }, [client, agent.agentId]); + + const saveHeartbeat = useCallback(async () => { + setHeartbeatSaving(true); + setHeartbeatError(null); + try { + const target = + heartbeatTargetMode === "custom" + ? heartbeatTargetCustom.trim() + : heartbeatTargetMode; + let every = heartbeatEnabled ? heartbeatEvery.trim() : "0m"; + if (heartbeatEnabled && heartbeatIntervalMode === "custom") { + const customValue = Number.parseInt(heartbeatCustomMinutes, 10); + if (!Number.isFinite(customValue) || customValue <= 0) { + setHeartbeatError("Custom interval must be a positive number."); + setHeartbeatSaving(false); + return; + } + every = `${customValue}m`; + } + const ackParsed = Number.parseInt(heartbeatAckMaxChars, 10); + const ackMaxChars = Number.isFinite(ackParsed) ? ackParsed : 300; + const activeHours = + heartbeatActiveHoursEnabled && heartbeatActiveStart && heartbeatActiveEnd + ? { start: heartbeatActiveStart, end: heartbeatActiveEnd } + : null; + const result = await updateGatewayHeartbeat({ + client, + agentId: agent.agentId, + sessionKey: agent.sessionKey, + payload: { + override: heartbeatOverride, + heartbeat: { + every, + target: target || "last", + includeReasoning: heartbeatIncludeReasoning, + ackMaxChars, + activeHours, + }, + }, + }); + setHeartbeatOverride(result.hasOverride); + setHeartbeatEnabled(result.heartbeat.every !== "0m"); + setHeartbeatEvery(result.heartbeat.every); + setHeartbeatTargetMode( + result.heartbeat.target === "last" || result.heartbeat.target === "none" + ? result.heartbeat.target + : "custom" + ); + setHeartbeatTargetCustom( + result.heartbeat.target === "last" || result.heartbeat.target === "none" + ? "" + : result.heartbeat.target + ); + setHeartbeatIncludeReasoning(result.heartbeat.includeReasoning); + if (result.heartbeat.activeHours) { + setHeartbeatActiveHoursEnabled(true); + setHeartbeatActiveStart(result.heartbeat.activeHours.start); + setHeartbeatActiveEnd(result.heartbeat.activeHours.end); + } else { + setHeartbeatActiveHoursEnabled(false); + } + if (typeof result.heartbeat.ackMaxChars === "number") { + setHeartbeatAckMaxChars(String(result.heartbeat.ackMaxChars)); + } else { + setHeartbeatAckMaxChars("300"); + } + setHeartbeatDirty(false); + } catch (err) { + const message = + err instanceof Error ? err.message : "Failed to save heartbeat settings."; + setHeartbeatError(message); + } finally { + setHeartbeatSaving(false); + } + }, [ + heartbeatActiveEnd, + heartbeatActiveHoursEnabled, + heartbeatActiveStart, + heartbeatAckMaxChars, + heartbeatCustomMinutes, + heartbeatEnabled, + heartbeatEvery, + heartbeatIncludeReasoning, + heartbeatIntervalMode, + heartbeatOverride, + heartbeatTargetCustom, + heartbeatTargetMode, + client, + agent.agentId, + agent.sessionKey, + ]); + + useEffect(() => { + void loadAgentFiles(); + void loadHeartbeat(); + }, [loadAgentFiles, loadHeartbeat]); + + useEffect(() => { + if (!AGENT_FILE_NAMES.includes(agentFileTab)) { + setAgentFileTab(AGENT_FILE_NAMES[0]); + } + }, [agentFileTab]); + + const modelOptions = useMemo( + () => + models.map((entry) => ({ + value: `${entry.provider}/${entry.id}`, + label: + entry.name === `${entry.provider}/${entry.id}` + ? entry.name + : `${entry.name} (${entry.provider}/${entry.id})`, + reasoning: entry.reasoning, + })), + [models] + ); + const modelValue = agent.model ?? ""; + const modelOptionsWithFallback = + modelValue && !modelOptions.some((option) => option.value === modelValue) + ? [{ value: modelValue, label: modelValue, reasoning: undefined }, ...modelOptions] + : modelOptions; + const selectedModel = modelOptionsWithFallback.find( + (option) => option.value === modelValue + ); + const allowThinking = selectedModel?.reasoning !== false; + + return ( + <div + className="agent-inspect-panel" + data-testid="agent-inspect-panel" + style={{ position: "relative", left: "auto", top: "auto", width: "100%", height: "100%" }} + > + <div className="flex items-center justify-between border-b border-border/80 px-4 py-3"> + <div> + <div className="font-mono text-[10px] font-semibold uppercase tracking-[0.16em] text-muted-foreground"> + Inspect + </div> + <div className="console-title text-2xl leading-none text-foreground">{agent.name}</div> + </div> + <button + className="rounded-md border border-border/80 bg-card/70 px-3 py-2 font-mono text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground transition hover:border-border hover:bg-muted/65" + type="button" + data-testid="agent-inspect-close" + onClick={onClose} + > + Close + </button> + </div> + + <div className="flex flex-col gap-4 p-4"> + <section + className="flex min-h-[420px] flex-1 flex-col rounded-md border border-border/80 bg-card/70 p-4" + data-testid="agent-inspect-files" + > + <div className="flex flex-wrap items-center justify-between gap-2"> + <div className="font-mono text-[10px] font-semibold uppercase tracking-[0.16em] text-muted-foreground"> + Brain files + </div> + <div className="font-mono text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground"> + {agentFilesLoading + ? "Loading..." + : agentFilesDirty + ? "Saving on tab change" + : "All changes saved"} + </div> + </div> + {agentFilesError ? ( + <div className="mt-3 rounded-md border border-destructive bg-destructive px-3 py-2 text-xs text-destructive-foreground"> + {agentFilesError} + </div> + ) : null} + <div className="mt-4 flex flex-wrap items-end gap-2"> + {AGENT_FILE_NAMES.map((name) => { + const active = name === agentFileTab; + const label = AGENT_FILE_META[name].title.replace(".md", ""); + return ( + <button + key={name} + type="button" + className={`rounded-full border px-3 py-1.5 font-mono text-[10px] font-semibold uppercase tracking-[0.12em] transition ${ + active + ? "border-border bg-background text-foreground shadow-sm" + : "border-transparent bg-muted/60 text-muted-foreground hover:border-border/80 hover:bg-muted" + }`} + onClick={() => handleAgentFileTabChange(name)} + > + {label} + </button> + ); + })} + </div> + <div className="mt-3 flex-1 overflow-auto rounded-md border border-border/70 bg-muted/40 p-4"> + <div className="flex items-start justify-between gap-3"> + <div> + <div className="text-sm font-semibold uppercase tracking-[0.05em] text-foreground"> + {AGENT_FILE_META[agentFileTab].title} + </div> + <div className="text-xs text-muted-foreground"> + {AGENT_FILE_META[agentFileTab].hint} + </div> + </div> + {!agentFiles[agentFileTab].exists ? ( + <span className="rounded-md border border-border bg-muted px-2 py-1 font-mono text-[9px] font-semibold uppercase tracking-[0.12em] text-muted-foreground"> + new + </span> + ) : null} + </div> + + <textarea + className="mt-4 min-h-[220px] w-full resize-none rounded-md border border-border bg-background/75 px-3 py-2 font-mono text-xs text-foreground outline-none" + value={agentFiles[agentFileTab].content} + placeholder={ + agentFiles[agentFileTab].content.trim().length === 0 + ? AGENT_FILE_PLACEHOLDERS[agentFileTab] + : undefined + } + disabled={agentFilesLoading || agentFilesSaving} + onChange={(event) => { + const value = event.target.value; + setAgentFiles((prev) => ({ + ...prev, + [agentFileTab]: { ...prev[agentFileTab], content: value }, + })); + setAgentFilesDirty(true); + }} + /> + </div> + <div className="mt-4 flex items-center justify-between gap-2 border-t border-border pt-4"> + <div className="text-xs text-muted-foreground"> + {agentFilesDirty ? "Auto-save on tab switch." : "Up to date."} + </div> + </div> + </section> + + <section + className="rounded-md border border-border/80 bg-card/70 p-4" + data-testid="agent-inspect-settings" + > + <div className="font-mono text-[10px] font-semibold uppercase tracking-[0.16em] text-muted-foreground"> + Settings + </div> + <div className="mt-3 grid gap-3 md:grid-cols-[1.2fr_1fr]"> + <label className="flex min-w-0 flex-col gap-2 font-mono text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground"> + <span>Model</span> + <select + className="h-10 w-full min-w-0 overflow-hidden text-ellipsis whitespace-nowrap rounded-md border border-border bg-card/75 px-3 text-xs font-semibold text-foreground" + value={agent.model ?? ""} + onChange={(event) => { + const value = event.target.value.trim(); + onModelChange(value ? value : null); + }} + > + {modelOptionsWithFallback.length === 0 ? ( + <option value="">No models found</option> + ) : null} + {modelOptionsWithFallback.map((option) => ( + <option key={option.value} value={option.value}> + {option.label} + </option> + ))} + </select> + </label> + {allowThinking ? ( + <label className="flex flex-col gap-2 font-mono text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground"> + <span>Thinking</span> + <select + className="h-10 rounded-md border border-border bg-card/75 px-3 text-xs font-semibold text-foreground" + value={agent.thinkingLevel ?? ""} + onChange={(event) => { + const value = event.target.value.trim(); + onThinkingChange(value ? value : null); + }} + > + <option value="">Default</option> + <option value="off">Off</option> + <option value="minimal">Minimal</option> + <option value="low">Low</option> + <option value="medium">Medium</option> + <option value="high">High</option> + <option value="xhigh">XHigh</option> + </select> + </label> + ) : ( + <div /> + )} + </div> + + <div className="mt-4 grid gap-3 md:grid-cols-2"> + <label className="flex items-center justify-between gap-3 rounded-md border border-border/80 bg-card/75 px-3 py-2 font-mono text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground"> + <span>Show tool calls</span> + <input + type="checkbox" + className="h-4 w-4 rounded border-input text-foreground" + checked={agent.toolCallingEnabled} + onChange={(event) => onToolCallingToggle(event.target.checked)} + /> + </label> + <label className="flex items-center justify-between gap-3 rounded-md border border-border/80 bg-card/75 px-3 py-2 font-mono text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground"> + <span>Show thinking</span> + <input + type="checkbox" + className="h-4 w-4 rounded border-input text-foreground" + checked={agent.showThinkingTraces} + onChange={(event) => onThinkingTracesToggle(event.target.checked)} + /> + </label> + </div> + + <div className="mt-4 rounded-md border border-border/80 bg-card/70 p-4"> + <div className="flex flex-wrap items-center justify-between gap-2"> + <div className="font-mono text-[10px] font-semibold uppercase tracking-[0.16em] text-muted-foreground"> + Heartbeat config + </div> + <div className="font-mono text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground"> + {heartbeatLoading + ? "Loading..." + : heartbeatDirty + ? "Unsaved changes" + : "All changes saved"} + </div> + </div> + {heartbeatError ? ( + <div className="mt-3 rounded-md border border-destructive bg-destructive px-3 py-2 text-xs text-destructive-foreground"> + {heartbeatError} + </div> + ) : null} + <label className="mt-4 flex items-center justify-between gap-3 font-mono text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground"> + <span>Override defaults</span> + <input + type="checkbox" + className="h-4 w-4 rounded border-input text-foreground" + checked={heartbeatOverride} + disabled={heartbeatLoading || heartbeatSaving} + onChange={(event) => { + setHeartbeatOverride(event.target.checked); + setHeartbeatDirty(true); + }} + /> + </label> + <label className="mt-4 flex items-center justify-between gap-3 font-mono text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground"> + <span>Enabled</span> + <input + type="checkbox" + className="h-4 w-4 rounded border-input text-foreground" + checked={heartbeatEnabled} + disabled={heartbeatLoading || heartbeatSaving} + onChange={(event) => { + setHeartbeatEnabled(event.target.checked); + setHeartbeatOverride(true); + setHeartbeatDirty(true); + }} + /> + </label> + <label className="mt-4 flex flex-col gap-2 font-mono text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground"> + <span>Interval</span> + <select + className="h-10 rounded-md border border-border bg-card/75 px-3 text-xs font-semibold text-foreground" + value={heartbeatIntervalMode === "custom" ? "custom" : heartbeatEvery} + disabled={heartbeatLoading || heartbeatSaving} + onChange={(event) => { + const value = event.target.value; + if (value === "custom") { + setHeartbeatIntervalMode("custom"); + } else { + setHeartbeatIntervalMode("preset"); + setHeartbeatEvery(value); + } + setHeartbeatOverride(true); + setHeartbeatDirty(true); + }} + > + {HEARTBEAT_INTERVAL_OPTIONS.map((option) => ( + <option key={option} value={option}> + Every {option} + </option> + ))} + <option value="custom">Custom</option> + </select> + </label> + {heartbeatIntervalMode === "custom" ? ( + <input + type="number" + min={1} + className="mt-2 h-10 w-full rounded-md border border-border bg-card/75 px-3 text-xs text-foreground outline-none" + value={heartbeatCustomMinutes} + disabled={heartbeatLoading || heartbeatSaving} + onChange={(event) => { + setHeartbeatCustomMinutes(event.target.value); + setHeartbeatOverride(true); + setHeartbeatDirty(true); + }} + placeholder="Minutes" + /> + ) : null} + <label className="mt-4 flex flex-col gap-2 font-mono text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground"> + <span>Target</span> + <select + className="h-10 rounded-md border border-border bg-card/75 px-3 text-xs font-semibold text-foreground" + value={heartbeatTargetMode} + disabled={heartbeatLoading || heartbeatSaving} + onChange={(event) => { + setHeartbeatTargetMode( + event.target.value as "last" | "none" | "custom" + ); + setHeartbeatOverride(true); + setHeartbeatDirty(true); + }} + > + <option value="last">Last channel</option> + <option value="none">No delivery</option> + <option value="custom">Custom</option> + </select> + </label> + {heartbeatTargetMode === "custom" ? ( + <input + className="mt-2 h-10 w-full rounded-md border border-border bg-card/75 px-3 text-xs text-foreground outline-none" + value={heartbeatTargetCustom} + disabled={heartbeatLoading || heartbeatSaving} + onChange={(event) => { + setHeartbeatTargetCustom(event.target.value); + setHeartbeatOverride(true); + setHeartbeatDirty(true); + }} + placeholder="Channel id (e.g., whatsapp)" + /> + ) : null} + <label className="mt-4 flex items-center justify-between gap-3 font-mono text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground"> + <span>Include reasoning</span> + <input + type="checkbox" + className="h-4 w-4 rounded border-input text-foreground" + checked={heartbeatIncludeReasoning} + disabled={heartbeatLoading || heartbeatSaving} + onChange={(event) => { + setHeartbeatIncludeReasoning(event.target.checked); + setHeartbeatOverride(true); + setHeartbeatDirty(true); + }} + /> + </label> + <label className="mt-4 flex items-center justify-between gap-3 font-mono text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground"> + <span>Active hours</span> + <input + type="checkbox" + className="h-4 w-4 rounded border-input text-foreground" + checked={heartbeatActiveHoursEnabled} + disabled={heartbeatLoading || heartbeatSaving} + onChange={(event) => { + setHeartbeatActiveHoursEnabled(event.target.checked); + setHeartbeatOverride(true); + setHeartbeatDirty(true); + }} + /> + </label> + {heartbeatActiveHoursEnabled ? ( + <div className="mt-2 grid gap-2 sm:grid-cols-2"> + <input + type="time" + className="h-10 w-full rounded-md border border-border bg-card/75 px-3 text-xs text-foreground outline-none" + value={heartbeatActiveStart} + disabled={heartbeatLoading || heartbeatSaving} + onChange={(event) => { + setHeartbeatActiveStart(event.target.value); + setHeartbeatOverride(true); + setHeartbeatDirty(true); + }} + /> + <input + type="time" + className="h-10 w-full rounded-md border border-border bg-card/75 px-3 text-xs text-foreground outline-none" + value={heartbeatActiveEnd} + disabled={heartbeatLoading || heartbeatSaving} + onChange={(event) => { + setHeartbeatActiveEnd(event.target.value); + setHeartbeatOverride(true); + setHeartbeatDirty(true); + }} + /> + </div> + ) : null} + <label className="mt-4 flex flex-col gap-2 font-mono text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground"> + <span>ACK max chars</span> + <input + type="number" + min={0} + className="h-10 w-full rounded-md border border-border bg-card/75 px-3 text-xs text-foreground outline-none" + value={heartbeatAckMaxChars} + disabled={heartbeatLoading || heartbeatSaving} + onChange={(event) => { + setHeartbeatAckMaxChars(event.target.value); + setHeartbeatOverride(true); + setHeartbeatDirty(true); + }} + /> + </label> + <div className="mt-4 flex items-center justify-between gap-2"> + <div className="text-xs text-muted-foreground"> + {heartbeatDirty ? "Remember to save changes." : "Up to date."} + </div> + <button + className="rounded-md border border-transparent bg-primary/90 px-4 py-2 font-mono text-[10px] font-semibold uppercase tracking-[0.12em] text-primary-foreground disabled:cursor-not-allowed disabled:border-border disabled:bg-muted disabled:text-muted-foreground disabled:opacity-100" + type="button" + disabled={heartbeatLoading || heartbeatSaving || !heartbeatDirty} + onClick={() => void saveHeartbeat()} + > + {heartbeatSaving ? "Saving..." : "Save heartbeat"} + </button> + </div> + </div> + </section> + + <section className="rounded-md border border-destructive/30 bg-destructive/4 p-4"> + <div className="font-mono text-[10px] font-semibold uppercase tracking-[0.16em] text-destructive"> + Delete agent + </div> + <div className="mt-3 text-[11px] text-muted-foreground"> + Removes the agent from the gateway config. + </div> + <button + className="mt-3 w-full rounded-md border border-destructive/50 bg-transparent px-3 py-2 font-mono text-[10px] font-semibold uppercase tracking-[0.12em] text-destructive shadow-sm transition hover:bg-destructive/10" + type="button" + onClick={onDelete} + > + Delete agent + </button> + </section> + </div> + </div> + ); +}; diff --git a/src/features/canvas/components/ConnectionPanel.tsx b/src/features/agents/components/ConnectionPanel.tsx similarity index 58% rename from src/features/canvas/components/ConnectionPanel.tsx rename to src/features/agents/components/ConnectionPanel.tsx index bb699d89..fa507538 100644 --- a/src/features/canvas/components/ConnectionPanel.tsx +++ b/src/features/agents/components/ConnectionPanel.tsx @@ -15,15 +15,15 @@ const statusStyles: Record<GatewayStatus, { label: string; className: string }> { disconnected: { label: "Disconnected", - className: "bg-slate-200 text-slate-700", + className: "border border-border/70 bg-muted text-muted-foreground", }, connecting: { label: "Connecting", - className: "bg-amber-200 text-amber-900", + className: "border border-border/70 bg-secondary text-secondary-foreground", }, connected: { label: "Connected", - className: "bg-emerald-200 text-emerald-900", + className: "border border-primary/30 bg-primary/15 text-foreground", }, }; @@ -42,15 +42,15 @@ export const ConnectionPanel = ({ const isConnecting = status === "connecting"; return ( - <div className="flex flex-col gap-3"> + <div className="fade-up-delay flex flex-col gap-3"> <div className="flex flex-wrap items-center gap-3"> <span - className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-wide ${statusConfig.className}`} + className={`inline-flex items-center rounded-md px-3 py-1 font-mono text-[10px] font-semibold uppercase tracking-[0.15em] ${statusConfig.className}`} > {statusConfig.label} </span> <button - className="rounded-full border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-900 transition hover:border-slate-400 disabled:cursor-not-allowed disabled:opacity-60" + className="rounded-md border border-input/90 bg-background/70 px-4 py-2 text-xs font-semibold uppercase tracking-[0.12em] text-foreground transition hover:border-border hover:bg-muted/65 disabled:cursor-not-allowed disabled:opacity-60" type="button" onClick={isConnected ? onDisconnect : onConnect} disabled={isConnecting || !gatewayUrl.trim()} @@ -59,10 +59,10 @@ export const ConnectionPanel = ({ </button> </div> <div className="grid gap-3 lg:grid-cols-[1.4fr_1fr]"> - <label className="flex flex-col gap-1 text-xs font-semibold uppercase tracking-wide text-slate-600"> + <label className="flex flex-col gap-1 font-mono text-[10px] font-semibold uppercase tracking-[0.15em] text-muted-foreground"> Gateway URL <input - className="h-10 rounded-full border border-slate-300 bg-white/80 px-4 text-sm text-slate-900 outline-none transition focus:border-slate-500" + className="h-10 rounded-md border border-input bg-background/75 px-4 font-sans text-sm text-foreground outline-none transition focus:border-ring" type="text" value={gatewayUrl} onChange={(event) => onGatewayUrlChange(event.target.value)} @@ -70,10 +70,10 @@ export const ConnectionPanel = ({ spellCheck={false} /> </label> - <label className="flex flex-col gap-1 text-xs font-semibold uppercase tracking-wide text-slate-600"> + <label className="flex flex-col gap-1 font-mono text-[10px] font-semibold uppercase tracking-[0.15em] text-muted-foreground"> Token <input - className="h-10 rounded-full border border-slate-300 bg-white/80 px-4 text-sm text-slate-900 outline-none transition focus:border-slate-500" + className="h-10 rounded-md border border-input bg-background/75 px-4 font-sans text-sm text-foreground outline-none transition focus:border-ring" type="password" value={token} onChange={(event) => onTokenChange(event.target.value)} @@ -83,7 +83,7 @@ export const ConnectionPanel = ({ </label> </div> {error ? ( - <p className="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-2 text-sm text-rose-700"> + <p className="rounded-md border border-destructive bg-destructive px-4 py-2 text-sm text-destructive-foreground"> {error} </p> ) : null} diff --git a/src/features/agents/components/FleetSidebar.tsx b/src/features/agents/components/FleetSidebar.tsx new file mode 100644 index 00000000..83ad0a75 --- /dev/null +++ b/src/features/agents/components/FleetSidebar.tsx @@ -0,0 +1,133 @@ +import type { AgentState, FocusFilter } from "@/features/agents/state/store"; +import { getAttentionForAgent } from "@/features/agents/state/store"; +import { AgentAvatar } from "./AgentAvatar"; + +type FleetSidebarProps = { + agents: AgentState[]; + selectedAgentId: string | null; + filter: FocusFilter; + onFilterChange: (next: FocusFilter) => void; + onSelectAgent: (agentId: string) => void; +}; + +const FILTER_OPTIONS: Array<{ value: FocusFilter; label: string; testId: string }> = [ + { value: "all", label: "All", testId: "fleet-filter-all" }, + { + value: "needs-attention", + label: "Needs Attention", + testId: "fleet-filter-needs-attention", + }, + { value: "running", label: "Running", testId: "fleet-filter-running" }, + { value: "idle", label: "Idle", testId: "fleet-filter-idle" }, +]; + +const statusLabel: Record<AgentState["status"], string> = { + idle: "Idle", + running: "Running", + error: "Error", +}; + +const statusClassName: Record<AgentState["status"], string> = { + idle: "border border-border/70 bg-muted text-muted-foreground", + running: "border border-primary/30 bg-primary/15 text-foreground", + error: "border border-destructive/35 bg-destructive/12 text-destructive", +}; + +export const FleetSidebar = ({ + agents, + selectedAgentId, + filter, + onFilterChange, + onSelectAgent, +}: FleetSidebarProps) => { + return ( + <aside + className="glass-panel fade-up-delay relative flex h-full w-full min-w-72 flex-col gap-3 p-3 xl:max-w-[320px]" + data-testid="fleet-sidebar" + > + <div className="pointer-events-none absolute inset-x-0 top-0 h-16 bg-[linear-gradient(90deg,color-mix(in_oklch,var(--primary)_8%,transparent)_0%,transparent_80%)]" /> + <div className="px-1"> + <p className="font-mono text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground"> + Fleet + </p> + <p className="console-title text-2xl leading-none text-foreground">Agents ({agents.length})</p> + </div> + + <div className="flex flex-wrap gap-2"> + {FILTER_OPTIONS.map((option) => { + const active = filter === option.value; + return ( + <button + key={option.value} + type="button" + data-testid={option.testId} + aria-pressed={active} + className={`rounded-md border px-2 py-1 font-mono text-[10px] font-semibold uppercase tracking-[0.13em] transition ${ + active + ? "border-border bg-muted text-foreground shadow-xs" + : "border-border/80 bg-card/65 text-muted-foreground hover:border-border hover:bg-muted/70" + }`} + onClick={() => onFilterChange(option.value)} + > + {option.label} + </button> + ); + })} + </div> + + <div className="min-h-0 flex-1 overflow-auto"> + {agents.length === 0 ? ( + <div className="rounded-md border border-border/80 bg-card/75 p-3 text-xs text-muted-foreground"> + No agents available. + </div> + ) : ( + <div className="flex flex-col gap-2"> + {agents.map((agent) => { + const selected = selectedAgentId === agent.agentId; + const attention = getAttentionForAgent(agent, selectedAgentId); + const avatarSeed = agent.avatarSeed ?? agent.agentId; + return ( + <button + key={agent.agentId} + type="button" + data-testid={`fleet-agent-row-${agent.agentId}`} + className={`group flex w-full items-center gap-3 rounded-md border px-3 py-2 text-left transition ${ + selected + ? "border-ring/40 bg-muted/60 shadow-xs" + : "border-border/70 bg-card/65 hover:border-border hover:bg-muted/55" + }`} + onClick={() => onSelectAgent(agent.agentId)} + > + <AgentAvatar + seed={avatarSeed} + name={agent.name} + avatarUrl={agent.avatarUrl ?? null} + size={28} + isSelected={selected} + /> + <div className="min-w-0 flex-1"> + <p className="truncate text-[11px] font-semibold uppercase tracking-[0.13em] text-foreground"> + {agent.name} + </p> + <div className="mt-1 flex flex-wrap items-center gap-2"> + <span + className={`rounded px-1.5 py-0.5 font-mono text-[9px] font-semibold uppercase tracking-[0.12em] ${statusClassName[agent.status]}`} + > + {statusLabel[agent.status]} + </span> + {attention === "needs-attention" ? ( + <span className="rounded border border-border/80 bg-card/75 px-1.5 py-0.5 font-mono text-[9px] font-semibold uppercase tracking-[0.12em] text-muted-foreground"> + Attention + </span> + ) : null} + </div> + </div> + </button> + ); + })} + </div> + )} + </div> + </aside> + ); +}; diff --git a/src/features/agents/components/HeaderBar.tsx b/src/features/agents/components/HeaderBar.tsx new file mode 100644 index 00000000..ad0a10a4 --- /dev/null +++ b/src/features/agents/components/HeaderBar.tsx @@ -0,0 +1,70 @@ +import type { GatewayStatus } from "@/lib/gateway/GatewayClient"; +import { ThemeToggle } from "@/components/theme-toggle"; + +type HeaderBarProps = { + status: GatewayStatus; + gatewayUrl: string; + agentCount: number; + onConnectionSettings: () => void; +}; + +const statusDotStyles: Record<GatewayStatus, string> = { + disconnected: "bg-muted-foreground/45", + connecting: "bg-secondary-foreground/55", + connected: "bg-primary/75", +}; + +const statusLabel: Record<GatewayStatus, string> = { + disconnected: "Disconnected", + connecting: "Connecting", + connected: "Connected", +}; + +export const HeaderBar = ({ + status, + gatewayUrl, + agentCount, + onConnectionSettings, +}: HeaderBarProps) => { + return ( + <div className="glass-panel fade-up relative overflow-hidden px-4 py-4 sm:px-6"> + <div className="pointer-events-none absolute inset-0 bg-[linear-gradient(120deg,transparent_0%,color-mix(in_oklch,var(--primary)_7%,transparent)_48%,transparent_100%)] opacity-55" /> + <div className="relative grid items-center gap-4 lg:grid-cols-[minmax(0,1fr)_auto]"> + <div className="min-w-0"> + <p className="console-title text-2xl leading-none text-foreground sm:text-3xl"> + OpenClaw Studio + </p> + <p className="mt-1 truncate text-xs font-semibold uppercase tracking-widest text-muted-foreground"> + Agents ({agentCount}) + </p> + {gatewayUrl ? ( + <p className="mt-1 truncate font-mono text-[11px] text-muted-foreground/90"> + {gatewayUrl} + </p> + ) : null} + </div> + + <div className="flex flex-col items-end gap-2"> + <div className="inline-flex items-center gap-2 rounded-md border border-border/80 bg-card/70 px-3 py-1 font-mono text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground"> + <span + className={`status-ping h-2 w-2 rounded-full ${statusDotStyles[status]}`} + aria-hidden="true" + /> + {statusLabel[status]} + </div> + <div className="flex items-center gap-2"> + <ThemeToggle /> + <button + className="rounded-md border border-input/90 bg-background/75 px-4 py-2 text-xs font-semibold uppercase tracking-[0.12em] text-foreground transition hover:border-ring hover:bg-card" + type="button" + onClick={onConnectionSettings} + data-testid="gateway-settings-toggle" + > + Connection Settings + </button> + </div> + </div> + </div> + </div> + ); +}; diff --git a/src/features/agents/components/chatItems.ts b/src/features/agents/components/chatItems.ts new file mode 100644 index 00000000..790e0a87 --- /dev/null +++ b/src/features/agents/components/chatItems.ts @@ -0,0 +1,131 @@ +import { + formatThinkingMarkdown, + isToolMarkdown, + isTraceMarkdown, + parseToolMarkdown, + stripTraceMarkdown, +} from "@/lib/text/message-extract"; + +export type AgentChatItem = + | { kind: "user"; text: string } + | { kind: "assistant"; text: string; live?: boolean } + | { kind: "tool"; text: string } + | { kind: "thinking"; text: string; live?: boolean }; + +export type BuildAgentChatItemsInput = { + outputLines: string[]; + streamText: string | null; + liveThinkingTrace: string; + showThinkingTraces: boolean; + toolCallingEnabled: boolean; +}; + +export const normalizeAssistantDisplayText = (value: string): string => { + const lines = value.replace(/\r\n?/g, "\n").split("\n"); + const normalized: string[] = []; + let lastWasBlank = false; + for (const rawLine of lines) { + const line = rawLine.replace(/[ \t]+$/g, ""); + if (line.trim().length === 0) { + if (lastWasBlank) continue; + normalized.push(""); + lastWasBlank = true; + continue; + } + normalized.push(line); + lastWasBlank = false; + } + return normalized.join("\n").trim(); +}; + +const normalizeThinkingDisplayText = (value: string): string => { + const markdown = formatThinkingMarkdown(value); + const normalized = stripTraceMarkdown(markdown).trim(); + return normalized; +}; + +export const buildAgentChatItems = ({ + outputLines, + streamText, + liveThinkingTrace, + showThinkingTraces, + toolCallingEnabled, +}: BuildAgentChatItemsInput): AgentChatItem[] => { + const items: AgentChatItem[] = []; + const appendThinking = (text: string, live?: boolean) => { + const normalized = text.trim(); + if (!normalized) return; + const previous = items[items.length - 1]; + if (!previous || previous.kind !== "thinking") { + items.push({ kind: "thinking", text: normalized, live }); + return; + } + if (previous.text === normalized) { + if (live) previous.live = true; + return; + } + if (normalized.startsWith(previous.text)) { + previous.text = normalized; + if (live) previous.live = true; + return; + } + if (previous.text.startsWith(normalized)) { + if (live) previous.live = true; + return; + } + previous.text = `${previous.text}\n\n${normalized}`; + if (live) previous.live = true; + }; + + for (const line of outputLines) { + if (!line) continue; + if (isTraceMarkdown(line)) { + if (!showThinkingTraces) continue; + const text = stripTraceMarkdown(line).trim(); + if (!text) continue; + appendThinking(text); + continue; + } + if (isToolMarkdown(line)) { + if (!toolCallingEnabled) continue; + items.push({ kind: "tool", text: line }); + continue; + } + const trimmed = line.trim(); + if (trimmed.startsWith(">")) { + const text = trimmed.replace(/^>\s?/, "").trim(); + if (text) items.push({ kind: "user", text }); + continue; + } + const normalizedAssistant = normalizeAssistantDisplayText(line); + if (!normalizedAssistant) continue; + items.push({ kind: "assistant", text: normalizedAssistant }); + } + + if (showThinkingTraces) { + const normalizedLiveThinking = normalizeThinkingDisplayText(liveThinkingTrace); + if (normalizedLiveThinking) { + appendThinking(normalizedLiveThinking, true); + } + } + + const liveStream = streamText?.trim(); + if (liveStream) { + const normalizedStream = normalizeAssistantDisplayText(liveStream); + if (normalizedStream) { + items.push({ kind: "assistant", text: normalizedStream, live: true }); + } + } + + return items; +}; + +export const summarizeToolLabel = (line: string): { summaryText: string; body: string } => { + const parsed = parseToolMarkdown(line); + const summaryLabel = parsed.kind === "result" ? "Tool result" : "Tool call"; + const summaryText = parsed.label ? `${summaryLabel}: ${parsed.label}` : summaryLabel; + return { + summaryText, + body: parsed.body, + }; +}; diff --git a/src/features/agents/state/runtimeEventBridge.ts b/src/features/agents/state/runtimeEventBridge.ts new file mode 100644 index 00000000..00940531 --- /dev/null +++ b/src/features/agents/state/runtimeEventBridge.ts @@ -0,0 +1,119 @@ +import type { AgentState } from "./store"; + +type LifecyclePhase = "start" | "end" | "error"; + +type LifecyclePatchInput = { + phase: LifecyclePhase; + incomingRunId: string; + currentRunId: string | null; + lastActivityAt: number; +}; + +type LifecycleTransitionStart = { + kind: "start"; + patch: Partial<AgentState>; + clearRunTracking: false; +}; + +type LifecycleTransitionTerminal = { + kind: "terminal"; + patch: Partial<AgentState>; + clearRunTracking: true; +}; + +type LifecycleTransitionIgnore = { + kind: "ignore"; +}; + +export type LifecycleTransition = + | LifecycleTransitionStart + | LifecycleTransitionTerminal + | LifecycleTransitionIgnore; + +type ShouldPublishAssistantStreamInput = { + mergedRaw: string; + rawText: string; + hasChatEvents: boolean; + currentStreamText: string | null; +}; + +type DedupeRunLinesResult = { + appended: string[]; + nextSeen: Set<string>; +}; + +export const mergeRuntimeStream = (current: string, incoming: string): string => { + if (!incoming) return current; + if (!current) return incoming; + if (incoming.startsWith(current)) return incoming; + if (current.startsWith(incoming)) return current; + if (current.endsWith(incoming)) return current; + if (incoming.endsWith(current)) return incoming; + return `${current}${incoming}`; +}; + +export const dedupeRunLines = (seen: Set<string>, lines: string[]): DedupeRunLinesResult => { + const nextSeen = new Set(seen); + const appended: string[] = []; + for (const line of lines) { + if (!line || nextSeen.has(line)) continue; + nextSeen.add(line); + appended.push(line); + } + return { appended, nextSeen }; +}; + +export const resolveLifecyclePatch = (input: LifecyclePatchInput): LifecycleTransition => { + const { phase, incomingRunId, currentRunId, lastActivityAt } = input; + if (phase === "start") { + return { + kind: "start", + clearRunTracking: false, + patch: { + status: "running", + runId: incomingRunId, + sessionCreated: true, + lastActivityAt, + }, + }; + } + if (currentRunId && currentRunId !== incomingRunId) { + return { kind: "ignore" }; + } + if (phase === "error") { + return { + kind: "terminal", + clearRunTracking: true, + patch: { + status: "error", + runId: null, + streamText: null, + thinkingTrace: null, + lastActivityAt, + }, + }; + } + return { + kind: "terminal", + clearRunTracking: true, + patch: { + status: "idle", + runId: null, + streamText: null, + thinkingTrace: null, + lastActivityAt, + }, + }; +}; + +export const shouldPublishAssistantStream = ({ + mergedRaw, + rawText, + hasChatEvents, + currentStreamText, +}: ShouldPublishAssistantStreamInput): boolean => { + if (!mergedRaw.trim()) return false; + if (!hasChatEvents) return true; + if (rawText.trim()) return true; + return !currentStreamText?.trim(); +}; diff --git a/src/features/agents/state/store.tsx b/src/features/agents/state/store.tsx new file mode 100644 index 00000000..238f4bb5 --- /dev/null +++ b/src/features/agents/state/store.tsx @@ -0,0 +1,270 @@ +"use client"; + +import { + createContext, + useCallback, + useContext, + useMemo, + useReducer, + type ReactNode, +} from "react"; + +export type AgentStatus = "idle" | "running" | "error"; +export type FocusFilter = "all" | "needs-attention" | "running" | "idle"; +export type AgentAttention = "normal" | "needs-attention"; + +export type AgentStoreSeed = { + agentId: string; + name: string; + sessionKey: string; + avatarSeed?: string | null; + avatarUrl?: string | null; + model?: string | null; + thinkingLevel?: string | null; + toolCallingEnabled?: boolean; + showThinkingTraces?: boolean; +}; + +export type AgentState = AgentStoreSeed & { + status: AgentStatus; + sessionCreated: boolean; + awaitingUserInput: boolean; + hasUnseenActivity: boolean; + outputLines: string[]; + lastResult: string | null; + lastDiff: string | null; + runId: string | null; + streamText: string | null; + thinkingTrace: string | null; + latestOverride: string | null; + latestOverrideKind: "heartbeat" | "cron" | null; + lastActivityAt: number | null; + latestPreview: string | null; + lastUserMessage: string | null; + draft: string; + sessionSettingsSynced: boolean; + historyLoadedAt: number | null; + toolCallingEnabled: boolean; + showThinkingTraces: boolean; +}; + +export type AgentStoreState = { + agents: AgentState[]; + selectedAgentId: string | null; + loading: boolean; + error: string | null; +}; + +type Action = + | { type: "hydrateAgents"; agents: AgentStoreSeed[] } + | { type: "setError"; error: string | null } + | { type: "setLoading"; loading: boolean } + | { type: "updateAgent"; agentId: string; patch: Partial<AgentState> } + | { type: "appendOutput"; agentId: string; line: string } + | { type: "setStream"; agentId: string; value: string | null } + | { type: "markActivity"; agentId: string; at?: number } + | { type: "selectAgent"; agentId: string | null }; + +const initialState: AgentStoreState = { + agents: [], + selectedAgentId: null, + loading: true, + error: null, +}; + +const createRuntimeAgentState = ( + seed: AgentStoreSeed, + existing?: AgentState | null +): AgentState => { + return { + ...seed, + avatarSeed: seed.avatarSeed ?? existing?.avatarSeed ?? seed.agentId, + avatarUrl: seed.avatarUrl ?? existing?.avatarUrl ?? null, + model: seed.model ?? existing?.model ?? null, + thinkingLevel: seed.thinkingLevel ?? existing?.thinkingLevel ?? "medium", + status: existing?.status ?? "idle", + sessionCreated: existing?.sessionCreated ?? false, + awaitingUserInput: existing?.awaitingUserInput ?? false, + hasUnseenActivity: existing?.hasUnseenActivity ?? false, + outputLines: existing?.outputLines ?? [], + lastResult: existing?.lastResult ?? null, + lastDiff: existing?.lastDiff ?? null, + runId: existing?.runId ?? null, + streamText: existing?.streamText ?? null, + thinkingTrace: existing?.thinkingTrace ?? null, + latestOverride: existing?.latestOverride ?? null, + latestOverrideKind: existing?.latestOverrideKind ?? null, + lastActivityAt: existing?.lastActivityAt ?? null, + latestPreview: existing?.latestPreview ?? null, + lastUserMessage: existing?.lastUserMessage ?? null, + draft: existing?.draft ?? "", + sessionSettingsSynced: existing?.sessionSettingsSynced ?? false, + historyLoadedAt: existing?.historyLoadedAt ?? null, + toolCallingEnabled: seed.toolCallingEnabled ?? existing?.toolCallingEnabled ?? false, + showThinkingTraces: seed.showThinkingTraces ?? existing?.showThinkingTraces ?? true, + }; +}; + +const reducer = (state: AgentStoreState, action: Action): AgentStoreState => { + switch (action.type) { + case "hydrateAgents": { + const byId = new Map(state.agents.map((agent) => [agent.agentId, agent])); + const agents = action.agents.map((seed) => + createRuntimeAgentState(seed, byId.get(seed.agentId)) + ); + const selectedAgentId = + state.selectedAgentId && agents.some((agent) => agent.agentId === state.selectedAgentId) + ? state.selectedAgentId + : agents[0]?.agentId ?? null; + return { + ...state, + agents, + selectedAgentId, + loading: false, + error: null, + }; + } + case "setError": + return { ...state, error: action.error, loading: false }; + case "setLoading": + return { ...state, loading: action.loading }; + case "updateAgent": + return { + ...state, + agents: state.agents.map((agent) => + agent.agentId === action.agentId + ? { ...agent, ...action.patch } + : agent + ), + }; + case "appendOutput": + return { + ...state, + agents: state.agents.map((agent) => + agent.agentId === action.agentId + ? { ...agent, outputLines: [...agent.outputLines, action.line] } + : agent + ), + }; + case "setStream": + return { + ...state, + agents: state.agents.map((agent) => + agent.agentId === action.agentId + ? { ...agent, streamText: action.value } + : agent + ), + }; + case "markActivity": { + const at = action.at ?? Date.now(); + return { + ...state, + agents: state.agents.map((agent) => { + if (agent.agentId !== action.agentId) return agent; + const isSelected = state.selectedAgentId === action.agentId; + return { + ...agent, + lastActivityAt: at, + hasUnseenActivity: isSelected ? false : true, + }; + }), + }; + } + case "selectAgent": + return { + ...state, + selectedAgentId: action.agentId, + agents: + action.agentId === null + ? state.agents + : state.agents.map((agent) => + agent.agentId === action.agentId + ? { ...agent, hasUnseenActivity: false } + : agent + ), + }; + default: + return state; + } +}; + +export const agentStoreReducer = reducer; +export const initialAgentStoreState = initialState; + +type AgentStoreContextValue = { + state: AgentStoreState; + dispatch: React.Dispatch<Action>; + hydrateAgents: (agents: AgentStoreSeed[]) => void; + setLoading: (loading: boolean) => void; + setError: (error: string | null) => void; +}; + +const AgentStoreContext = createContext<AgentStoreContextValue | null>(null); + +export const AgentStoreProvider = ({ children }: { children: ReactNode }) => { + const [state, dispatch] = useReducer(reducer, initialState); + + const hydrateAgents = useCallback( + (agents: AgentStoreSeed[]) => { + dispatch({ type: "hydrateAgents", agents }); + }, + [dispatch] + ); + + const setLoading = useCallback( + (loading: boolean) => dispatch({ type: "setLoading", loading }), + [dispatch] + ); + + const setError = useCallback( + (error: string | null) => dispatch({ type: "setError", error }), + [dispatch] + ); + + const value = useMemo( + () => ({ state, dispatch, hydrateAgents, setLoading, setError }), + [dispatch, hydrateAgents, setError, setLoading, state] + ); + + return ( + <AgentStoreContext.Provider value={value}>{children}</AgentStoreContext.Provider> + ); +}; + +export const useAgentStore = () => { + const ctx = useContext(AgentStoreContext); + if (!ctx) { + throw new Error("AgentStoreProvider is missing."); + } + return ctx; +}; + +export const getSelectedAgent = (state: AgentStoreState): AgentState | null => { + if (!state.selectedAgentId) return null; + return state.agents.find((agent) => agent.agentId === state.selectedAgentId) ?? null; +}; + +export const getAttentionForAgent = ( + agent: AgentState, + selectedAgentId: string | null +): AgentAttention => { + if (agent.status === "error") return "needs-attention"; + if (agent.awaitingUserInput) return "needs-attention"; + if (selectedAgentId !== agent.agentId && agent.hasUnseenActivity) { + return "needs-attention"; + } + return "normal"; +}; + +export const getFilteredAgents = (state: AgentStoreState, filter: FocusFilter): AgentState[] => { + if (filter === "all") return state.agents; + if (filter === "running") { + return state.agents.filter((agent) => agent.status === "running"); + } + if (filter === "idle") { + return state.agents.filter((agent) => agent.status === "idle"); + } + return state.agents.filter( + (agent) => getAttentionForAgent(agent, state.selectedAgentId) === "needs-attention" + ); +}; diff --git a/src/features/agents/state/summary.ts b/src/features/agents/state/summary.ts new file mode 100644 index 00000000..f440a070 --- /dev/null +++ b/src/features/agents/state/summary.ts @@ -0,0 +1,75 @@ +import type { AgentState } from "./store"; +import { extractText } from "@/lib/text/message-extract"; +import { stripUiMetadata, isUiMetadataPrefix } from "@/lib/text/message-metadata"; + +export type ChatEventPayload = { + runId: string; + sessionKey: string; + state: "delta" | "final" | "aborted" | "error"; + message?: unknown; + errorMessage?: string; +}; + +export type AgentEventPayload = { + runId: string; + seq?: number; + stream?: string; + data?: Record<string, unknown>; + sessionKey?: string; +}; + +export const getChatSummaryPatch = ( + payload: ChatEventPayload, + now: number = Date.now() +): Partial<AgentState> | null => { + const message = payload.message; + const role = + message && typeof message === "object" + ? (message as Record<string, unknown>).role + : null; + const rawText = extractText(message); + if (typeof rawText === "string" && isUiMetadataPrefix(rawText.trim())) { + return { lastActivityAt: now }; + } + const cleaned = typeof rawText === "string" ? stripUiMetadata(rawText) : null; + const patch: Partial<AgentState> = { lastActivityAt: now }; + if (role === "user") { + if (cleaned) { + patch.lastUserMessage = cleaned; + } + return patch; + } + if (role === "assistant") { + if (cleaned) { + patch.latestPreview = cleaned; + } + return patch; + } + if (payload.state === "error" && payload.errorMessage) { + patch.latestPreview = payload.errorMessage; + } + return patch; +}; + +export const getAgentSummaryPatch = ( + payload: AgentEventPayload, + now: number = Date.now() +): Partial<AgentState> | null => { + if (payload.stream !== "lifecycle") return null; + const phase = typeof payload.data?.phase === "string" ? payload.data.phase : ""; + if (!phase) return null; + const patch: Partial<AgentState> = { lastActivityAt: now }; + if (phase === "start") { + patch.status = "running"; + return patch; + } + if (phase === "end") { + patch.status = "idle"; + return patch; + } + if (phase === "error") { + patch.status = "error"; + return patch; + } + return patch; +}; diff --git a/src/features/canvas/components/AgentAvatar.tsx b/src/features/canvas/components/AgentAvatar.tsx deleted file mode 100644 index bb9abc47..00000000 --- a/src/features/canvas/components/AgentAvatar.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { useMemo } from "react"; - -import { buildAvatarDataUrl } from "@/lib/avatars/multiavatar"; - -type AgentAvatarProps = { - seed: string; - name: string; - size?: number; - isSelected?: boolean; -}; - -export const AgentAvatar = ({ - seed, - name, - size = 112, - isSelected = false, -}: AgentAvatarProps) => { - const src = useMemo(() => buildAvatarDataUrl(seed), [seed]); - - return ( - <div - className={`flex items-center justify-center overflow-hidden rounded-full border border-slate-200 bg-white/70 shadow-sm ${isSelected ? "agent-avatar-selected" : ""}`} - style={{ width: size, height: size }} - > - <img className="h-full w-full" src={src} alt={`Avatar for ${name}`} /> - </div> - ); -}; diff --git a/src/features/canvas/components/AgentTile.tsx b/src/features/canvas/components/AgentTile.tsx deleted file mode 100644 index 705dd7b0..00000000 --- a/src/features/canvas/components/AgentTile.tsx +++ /dev/null @@ -1,646 +0,0 @@ -import type React from "react"; -import ReactMarkdown from "react-markdown"; -import remarkGfm from "remark-gfm"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { createPortal } from "react-dom"; -import type { AgentTile as AgentTileType, TileSize } from "@/features/canvas/state/store"; -import { isTraceMarkdown, stripTraceMarkdown } from "@/lib/text/extractThinking"; -import { normalizeAgentName } from "@/lib/names/agentNames"; -import { Settings, Shuffle } from "lucide-react"; -import { - fetchProjectTileWorkspaceFiles, - updateProjectTileWorkspaceFiles, -} from "@/lib/projects/client"; -import { - WORKSPACE_FILE_META, - WORKSPACE_FILE_NAMES, - WORKSPACE_FILE_PLACEHOLDERS, - type WorkspaceFileName, -} from "@/lib/projects/workspaceFiles"; -import { AgentAvatar } from "./AgentAvatar"; - -export const MIN_TILE_SIZE = { width: 420, height: 520 }; - -const buildWorkspaceState = () => - Object.fromEntries( - WORKSPACE_FILE_NAMES.map((name) => [name, { content: "", exists: false }]) - ) as Record<WorkspaceFileName, { content: string; exists: boolean }>; - -const buildWorkspaceExpanded = () => - Object.fromEntries(WORKSPACE_FILE_NAMES.map((name) => [name, false])) as Record< - WorkspaceFileName, - boolean - >; - -const isWorkspaceFileName = (value: string): value is WorkspaceFileName => - WORKSPACE_FILE_NAMES.includes(value as WorkspaceFileName); - -type AgentTileProps = { - tile: AgentTileType; - projectId: string | null; - isSelected: boolean; - canSend: boolean; - onDelete: () => void; - onNameChange: (name: string) => Promise<boolean>; - onDraftChange: (value: string) => void; - onSend: (message: string) => void; - onModelChange: (value: string | null) => void; - onThinkingChange: (value: string | null) => void; - onAvatarShuffle: () => void; - onNameShuffle: () => void; - onResize?: (size: TileSize) => void; -}; - -export const AgentTile = ({ - tile, - projectId, - isSelected, - canSend, - onDelete, - onNameChange, - onDraftChange, - onSend, - onModelChange, - onThinkingChange, - onAvatarShuffle, - onNameShuffle, - onResize, -}: AgentTileProps) => { - const [nameDraft, setNameDraft] = useState(tile.name); - const [settingsOpen, setSettingsOpen] = useState(false); - const [workspaceFiles, setWorkspaceFiles] = useState(buildWorkspaceState); - const [workspaceExpanded, setWorkspaceExpanded] = useState(buildWorkspaceExpanded); - const [workspaceLoading, setWorkspaceLoading] = useState(false); - const [workspaceSaving, setWorkspaceSaving] = useState(false); - const [workspaceDirty, setWorkspaceDirty] = useState(false); - const [workspaceError, setWorkspaceError] = useState<string | null>(null); - const workspaceItemRefs = useRef<Record<WorkspaceFileName, HTMLDivElement | null>>( - buildWorkspaceExpanded() - ); - const outputRef = useRef<HTMLDivElement | null>(null); - const draftRef = useRef<HTMLTextAreaElement | null>(null); - const scrollOutputToBottom = useCallback(() => { - const el = outputRef.current; - if (!el) return; - el.scrollTop = el.scrollHeight; - }, []); - - const handleOutputWheel = useCallback( - (event: React.WheelEvent<HTMLDivElement>) => { - if (!isSelected) return; - const el = outputRef.current; - if (!el) return; - event.preventDefault(); - event.stopPropagation(); - const maxTop = Math.max(0, el.scrollHeight - el.clientHeight); - const maxLeft = Math.max(0, el.scrollWidth - el.clientWidth); - const nextTop = Math.max(0, Math.min(maxTop, el.scrollTop + event.deltaY)); - const nextLeft = Math.max(0, Math.min(maxLeft, el.scrollLeft + event.deltaX)); - el.scrollTop = nextTop; - el.scrollLeft = nextLeft; - }, - [isSelected] - ); - - useEffect(() => { - const raf = requestAnimationFrame(scrollOutputToBottom); - return () => cancelAnimationFrame(raf); - }, [scrollOutputToBottom, tile.outputLines, tile.streamText]); - - const resizeDraft = useCallback(() => { - const el = draftRef.current; - if (!el) return; - el.style.height = "auto"; - el.style.height = `${el.scrollHeight}px`; - }, []); - - useEffect(() => { - setNameDraft(tile.name); - }, [tile.name]); - - useEffect(() => { - resizeDraft(); - }, [resizeDraft, tile.draft]); - - useEffect(() => { - const output = outputRef.current; - if (!output) return; - const extra = Math.ceil(output.scrollHeight - output.clientHeight); - if (extra <= 0) return; - onResize?.({ width: tile.size.width, height: tile.size.height + extra }); - }, [ - onResize, - tile.outputLines, - tile.streamText, - tile.thinkingTrace, - tile.size.height, - tile.size.width, - ]); - - const commitName = async () => { - const next = normalizeAgentName(nameDraft); - if (!next) { - setNameDraft(tile.name); - return; - } - if (next === tile.name) { - return; - } - const ok = await onNameChange(next); - if (!ok) { - setNameDraft(tile.name); - return; - } - setNameDraft(next); - }; - - const statusColor = - tile.status === "running" - ? "bg-amber-200 text-amber-900" - : tile.status === "error" - ? "bg-rose-200 text-rose-900" - : "bg-emerald-200 text-emerald-900"; - const showThinking = tile.status === "running" && Boolean(tile.thinkingTrace); - const showTranscript = - tile.outputLines.length > 0 || Boolean(tile.streamText) || showThinking; - const avatarSeed = tile.avatarSeed ?? tile.agentId; - const panelBorder = "border-slate-200"; - - const loadWorkspaceFiles = useCallback(async () => { - if (!projectId) return; - setWorkspaceLoading(true); - setWorkspaceError(null); - try { - const result = await fetchProjectTileWorkspaceFiles(projectId, tile.id); - const nextState = buildWorkspaceState(); - for (const file of result.files) { - if (!isWorkspaceFileName(file.name)) continue; - nextState[file.name] = { - content: file.content ?? "", - exists: Boolean(file.exists), - }; - } - setWorkspaceFiles(nextState); - setWorkspaceDirty(false); - } catch (err) { - const message = - err instanceof Error ? err.message : "Failed to load workspace files."; - setWorkspaceError(message); - } finally { - setWorkspaceLoading(false); - } - }, [projectId, tile.id]); - - const saveWorkspaceFiles = useCallback(async () => { - if (!projectId) return; - setWorkspaceSaving(true); - setWorkspaceError(null); - try { - const payload = { - files: WORKSPACE_FILE_NAMES.map((name) => ({ - name, - content: workspaceFiles[name].content, - })), - }; - const result = await updateProjectTileWorkspaceFiles(projectId, tile.id, payload); - const nextState = buildWorkspaceState(); - for (const file of result.files) { - if (!isWorkspaceFileName(file.name)) continue; - nextState[file.name] = { - content: file.content ?? "", - exists: Boolean(file.exists), - }; - } - setWorkspaceFiles(nextState); - setWorkspaceDirty(false); - } catch (err) { - const message = - err instanceof Error ? err.message : "Failed to save workspace files."; - setWorkspaceError(message); - } finally { - setWorkspaceSaving(false); - } - }, [projectId, tile.id, workspaceFiles]); - - useEffect(() => { - if (!settingsOpen) return; - void loadWorkspaceFiles(); - }, [loadWorkspaceFiles, settingsOpen]); - - const settingsModal = - settingsOpen && typeof document !== "undefined" - ? createPortal( - <div - className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/40 px-6 py-8 backdrop-blur-sm" - role="dialog" - aria-modal="true" - aria-label="Agent settings" - onClick={() => setSettingsOpen(false)} - > - <div - className="w-[min(92vw,920px)] max-h-[90vh] overflow-hidden rounded-[32px] border border-slate-200 bg-white/95 p-6 shadow-2xl" - onClick={(event) => event.stopPropagation()} - > - <div className="flex max-h-[calc(90vh-3rem)] flex-col gap-4 overflow-hidden"> - <div className="flex items-start justify-between gap-4"> - <div> - <div className="text-xs font-semibold uppercase tracking-wide text-slate-500"> - Agent settings - </div> - <div className="mt-1 text-2xl font-semibold text-slate-900"> - {tile.name} - </div> - </div> - <button - className="rounded-full border border-slate-200 px-4 py-2 text-xs font-semibold uppercase text-slate-600" - type="button" - onClick={() => setSettingsOpen(false)} - > - Close - </button> - </div> - - <div className="flex flex-col gap-4 overflow-hidden"> - <div className="rounded-3xl border border-slate-200 bg-white/80 p-4"> - <div className="text-xs font-semibold uppercase tracking-wide text-slate-500"> - Core - </div> - <label className="mt-4 flex flex-col gap-2 text-xs font-semibold uppercase text-slate-500"> - <span>Model</span> - <select - className="h-10 rounded-2xl border border-slate-200 bg-white/80 px-3 text-xs font-semibold text-slate-700" - value={tile.model ?? ""} - onChange={(event) => { - const value = event.target.value.trim(); - onModelChange(value ? value : null); - }} - > - <option value="openai-codex/gpt-5.2-codex">GPT-5.2 Codex</option> - <option value="xai/grok-4-1-fast-reasoning"> - grok-4-1-fast-reasoning - </option> - <option value="xai/grok-4-1-fast-non-reasoning"> - grok-4-1-fast-non-reasoning - </option> - <option value="zai/glm-4.7">glm-4.7</option> - </select> - </label> - {tile.model === "xai/grok-4-1-fast-non-reasoning" ? null : ( - <label className="mt-4 flex flex-col gap-2 text-xs font-semibold uppercase text-slate-500"> - <span>Thinking</span> - <select - className="h-10 rounded-2xl border border-slate-200 bg-white/80 px-3 text-xs font-semibold text-slate-700" - value={tile.thinkingLevel ?? ""} - onChange={(event) => { - const value = event.target.value.trim(); - onThinkingChange(value ? value : null); - }} - > - <option value="">Default</option> - <option value="off">Off</option> - <option value="minimal">Minimal</option> - <option value="low">Low</option> - <option value="medium">Medium</option> - <option value="high">High</option> - <option value="xhigh">XHigh</option> - </select> - </label> - )} - <button - className="mt-6 w-full rounded-full border border-rose-200 bg-rose-50 px-3 py-2 text-xs font-semibold uppercase text-rose-600" - type="button" - onClick={onDelete} - > - Delete agent - </button> - </div> - - <div className="flex min-h-[420px] flex-1 flex-col rounded-3xl border border-slate-200 bg-white/80 p-4"> - <div className="flex flex-wrap items-center justify-between gap-2"> - <div className="text-xs font-semibold uppercase tracking-wide text-slate-500"> - Workspace files - </div> - <div className="text-[11px] font-semibold uppercase text-slate-400"> - {workspaceLoading - ? "Loading..." - : workspaceDirty - ? "Unsaved changes" - : "All changes saved"} - </div> - </div> - {workspaceError ? ( - <div className="mt-3 rounded-2xl border border-rose-200 bg-rose-50 px-3 py-2 text-xs text-rose-600"> - {workspaceError} - </div> - ) : null} - <div className="mt-4 flex-1 overflow-auto pr-1"> - <div className="flex flex-col gap-3"> - {WORKSPACE_FILE_NAMES.map((name) => { - const meta = WORKSPACE_FILE_META[name]; - const isOpen = workspaceExpanded[name]; - const file = workspaceFiles[name]; - return ( - <div - key={name} - className="rounded-2xl border border-slate-200 bg-white/90 p-4" - ref={(node) => { - workspaceItemRefs.current[name] = node; - }} - > - <div className="flex flex-wrap items-start justify-between gap-3"> - <div> - <div className="text-sm font-semibold text-slate-800"> - {meta.title} - </div> - <div className="text-xs text-slate-500"> - {meta.hint} - </div> - </div> - <div className="flex items-center gap-2"> - {!file.exists ? ( - <span className="rounded-full border border-amber-200 bg-amber-50 px-2 py-1 text-[10px] font-semibold uppercase text-amber-700"> - new - </span> - ) : null} - <button - className="rounded-full border border-slate-200 px-3 py-1 text-[11px] font-semibold uppercase text-slate-600" - type="button" - onClick={() => { - const shouldOpen = !workspaceExpanded[name]; - setWorkspaceExpanded((prev) => ({ - ...prev, - [name]: !prev[name], - })); - if (!shouldOpen) return; - const scrollTarget = workspaceItemRefs.current[name]; - if (!scrollTarget) return; - requestAnimationFrame(() => { - requestAnimationFrame(() => { - scrollTarget.scrollIntoView({ - behavior: "smooth", - block: "start", - }); - }); - }); - }} - > - {isOpen ? "Hide" : "Edit"} - </button> - </div> - </div> - {isOpen ? ( - <textarea - className="mt-3 min-h-[140px] w-full resize-y rounded-2xl border border-slate-200 bg-white/80 px-3 py-2 text-xs text-slate-800 outline-none" - value={file.content} - placeholder={ - file.content.trim().length === 0 - ? WORKSPACE_FILE_PLACEHOLDERS[name] - : undefined - } - disabled={workspaceLoading || workspaceSaving} - onChange={(event) => { - const value = event.target.value; - setWorkspaceFiles((prev) => ({ - ...prev, - [name]: { ...prev[name], content: value }, - })); - setWorkspaceDirty(true); - }} - /> - ) : null} - </div> - ); - })} - </div> - </div> - <div className="mt-4 flex flex-wrap items-center justify-between gap-2 border-t border-slate-200 pt-4"> - <div className="text-xs text-slate-400"> - {workspaceDirty ? "Remember to save your changes." : "Up to date."} - </div> - <div className="flex items-center gap-2"> - <button - className="rounded-full border border-slate-200 px-4 py-2 text-xs font-semibold uppercase text-slate-600" - type="button" - onClick={() => setSettingsOpen(false)} - > - Close - </button> - <button - className="rounded-full bg-slate-900 px-4 py-2 text-xs font-semibold uppercase text-white disabled:cursor-not-allowed disabled:bg-slate-400" - type="button" - disabled={ - !projectId || - workspaceLoading || - workspaceSaving || - !workspaceDirty - } - onClick={() => void saveWorkspaceFiles()} - > - {workspaceSaving ? "Saving..." : "Save changes"} - </button> - </div> - </div> - </div> - </div> - </div> - </div> - </div>, - document.body - ) - : null; - - return ( - <div data-tile className="relative flex h-full w-full flex-col gap-3"> - {settingsModal} - <div className="flex flex-col gap-3 px-4 pt-4 pb-4"> - <div className="flex items-start justify-between gap-2"> - <div className="flex flex-1 flex-col items-center gap-2"> - <div className="flex items-center gap-2 rounded-full border border-slate-200 bg-white/90 px-3 py-1 shadow-sm"> - <input - className="w-full bg-transparent text-center text-xs font-semibold uppercase tracking-wide text-slate-700 outline-none" - value={nameDraft} - onChange={(event) => setNameDraft(event.target.value)} - onBlur={() => { - void commitName(); - }} - onKeyDown={(event) => { - if (event.key === "Enter") { - event.currentTarget.blur(); - } - if (event.key === "Escape") { - setNameDraft(tile.name); - event.currentTarget.blur(); - } - }} - /> - <button - className="nodrag flex h-6 w-6 items-center justify-center rounded-full border border-slate-200 bg-white text-slate-600 hover:bg-white" - type="button" - aria-label="Shuffle name" - data-testid="agent-name-shuffle" - onClick={(event) => { - event.preventDefault(); - event.stopPropagation(); - onNameShuffle(); - }} - > - <Shuffle className="h-3 w-3" /> - </button> - </div> - <div className="relative"> - <div data-drag-handle> - <AgentAvatar - seed={avatarSeed} - name={tile.name} - size={120} - isSelected={isSelected} - /> - </div> - <div className="pointer-events-none absolute -bottom-3 left-1/2 -translate-x-1/2"> - <span - className={`rounded-full px-2 py-1 text-[10px] font-semibold uppercase tracking-wide ${statusColor}`} - > - {tile.status} - </span> - </div> - <button - className="nodrag absolute -bottom-2 -right-2 flex h-8 w-8 items-center justify-center rounded-full border border-slate-200 bg-white text-slate-600 shadow-md hover:bg-white" - type="button" - aria-label="Shuffle avatar" - data-testid="agent-avatar-shuffle" - onClick={(event) => { - event.preventDefault(); - event.stopPropagation(); - onAvatarShuffle(); - }} - > - <Shuffle className="h-4 w-4" /> - </button> - </div> - </div> - </div> - <div className="mt-2 flex items-end gap-2"> - <div className="relative"> - <button - className="nodrag flex h-9 w-9 items-center justify-center rounded-full border border-slate-200 bg-white/80 text-slate-600 hover:bg-white" - type="button" - data-testid="agent-options-toggle" - aria-label="Agent options" - onPointerDown={(event) => { - event.preventDefault(); - event.stopPropagation(); - }} - onClick={(event) => { - event.preventDefault(); - event.stopPropagation(); - setSettingsOpen(true); - }} - > - <Settings className="h-4 w-4" /> - </button> - </div> - <textarea - ref={draftRef} - rows={1} - className="max-h-32 flex-1 resize-none rounded-2xl border border-slate-200 bg-white/90 px-3 py-2 text-xs text-slate-900 outline-none" - value={tile.draft} - onChange={(event) => { - onDraftChange(event.target.value); - resizeDraft(); - }} - onKeyDown={(event) => { - if (event.key !== "Enter" || event.shiftKey) return; - if (!isSelected) return; - if (!canSend || tile.status === "running") return; - const message = tile.draft.trim(); - if (!message) return; - event.preventDefault(); - onSend(message); - }} - placeholder="type a message" - /> - <button - className="rounded-full bg-slate-900 px-3 py-2 text-xs font-semibold text-white disabled:cursor-not-allowed disabled:bg-slate-400" - type="button" - onClick={() => onSend(tile.draft)} - disabled={!canSend || tile.status === "running" || !tile.draft.trim()} - > - Send - </button> - </div> - </div> - - {showTranscript ? ( - <div - className={`flex min-h-0 flex-1 flex-col overflow-hidden rounded-3xl border ${panelBorder} bg-white/80 px-4 pb-4 pt-4 shadow-xl backdrop-blur`} - > - <div - ref={outputRef} - className="flex-1 overflow-auto rounded-2xl border border-slate-200 bg-white/60 p-3 text-xs text-slate-700" - onWheel={handleOutputWheel} - data-testid="agent-transcript" - > - <div className="flex flex-col gap-2"> - {showThinking ? ( - <div className="rounded-xl border border-amber-200 bg-amber-50 px-2 py-1 text-[11px] font-medium text-amber-800"> - <div className="agent-markdown"> - <ReactMarkdown remarkPlugins={[remarkGfm]}> - {tile.thinkingTrace} - </ReactMarkdown> - </div> - </div> - ) : null} - {(() => { - const nodes: React.ReactNode[] = []; - for (let index = 0; index < tile.outputLines.length; index += 1) { - const line = tile.outputLines[index]; - if (isTraceMarkdown(line)) { - const traces = [stripTraceMarkdown(line)]; - let cursor = index + 1; - while ( - cursor < tile.outputLines.length && - isTraceMarkdown(tile.outputLines[cursor]) - ) { - traces.push(stripTraceMarkdown(tile.outputLines[cursor])); - cursor += 1; - } - nodes.push( - <details - key={`${tile.id}-trace-${index}`} - className="rounded-xl border border-slate-200 bg-white/80 px-2 py-1 text-[11px] text-slate-600" - > - <summary className="cursor-pointer select-none font-semibold"> - Thinking - </summary> - <div className="agent-markdown mt-1 text-slate-700"> - <ReactMarkdown remarkPlugins={[remarkGfm]}> - {traces.join("\n")} - </ReactMarkdown> - </div> - </details> - ); - index = cursor - 1; - continue; - } - nodes.push( - <div key={`${tile.id}-line-${index}`} className="agent-markdown"> - <ReactMarkdown remarkPlugins={[remarkGfm]}>{line}</ReactMarkdown> - </div> - ); - } - return nodes; - })()} - {tile.streamText ? ( - <div className="agent-markdown text-slate-500"> - <ReactMarkdown remarkPlugins={[remarkGfm]}> - {tile.streamText} - </ReactMarkdown> - </div> - ) : null} - </div> - </div> - </div> - ) : null} - </div> - ); -}; diff --git a/src/features/canvas/components/AgentTileNode.tsx b/src/features/canvas/components/AgentTileNode.tsx deleted file mode 100644 index f0f2abce..00000000 --- a/src/features/canvas/components/AgentTileNode.tsx +++ /dev/null @@ -1,70 +0,0 @@ -"use client"; - -import { NodeResizeControl, type Node, type NodeProps } from "@xyflow/react"; -import type { AgentTile as AgentTileType, TileSize } from "@/features/canvas/state/store"; -import { AgentTile, MIN_TILE_SIZE } from "./AgentTile"; - -export type AgentTileNodeData = { - tile: AgentTileType; - projectId: string | null; - canSend: boolean; - onResize: (size: TileSize) => void; - onDelete: () => void; - onNameChange: (name: string) => Promise<boolean>; - onDraftChange: (value: string) => void; - onSend: (message: string) => void; - onModelChange: (value: string | null) => void; - onThinkingChange: (value: string | null) => void; - onAvatarShuffle: () => void; - onNameShuffle: () => void; -}; - -type AgentTileNodeType = Node<AgentTileNodeData>; - -export const AgentTileNode = ({ data, selected }: NodeProps<AgentTileNodeType>) => { - const { - tile, - projectId, - canSend, - onResize, - onDelete, - onNameChange, - onDraftChange, - onSend, - onModelChange, - onThinkingChange, - onAvatarShuffle, - onNameShuffle, - } = data; - - return ( - <div className="h-full w-full"> - <NodeResizeControl - position="bottom" - className="tile-resize-handle" - minWidth={MIN_TILE_SIZE.width} - maxWidth={MIN_TILE_SIZE.width} - minHeight={MIN_TILE_SIZE.height} - resizeDirection="vertical" - onResizeEnd={(_, params) => { - onResize({ width: MIN_TILE_SIZE.width, height: params.height }); - }} - /> - <AgentTile - tile={tile} - projectId={projectId} - isSelected={selected} - canSend={canSend} - onDelete={onDelete} - onNameChange={onNameChange} - onDraftChange={onDraftChange} - onSend={onSend} - onModelChange={onModelChange} - onThinkingChange={onThinkingChange} - onAvatarShuffle={onAvatarShuffle} - onNameShuffle={onNameShuffle} - onResize={onResize} - /> - </div> - ); -}; diff --git a/src/features/canvas/components/CanvasFlow.tsx b/src/features/canvas/components/CanvasFlow.tsx deleted file mode 100644 index 6ba13f20..00000000 --- a/src/features/canvas/components/CanvasFlow.tsx +++ /dev/null @@ -1,226 +0,0 @@ -"use client"; - -import type React from "react"; -import { useCallback, useEffect, useMemo, useRef } from "react"; -import { - ReactFlow, - Background, - Controls, - MiniMap, - ReactFlowProvider, - useNodesState, - type Node, - type OnMove, -} from "@xyflow/react"; -import type { - AgentTile, - CanvasTransform, - TilePosition, - TileSize, -} from "@/features/canvas/state/store"; -import { AgentTileNode, type AgentTileNodeData } from "./AgentTileNode"; -import { MIN_TILE_SIZE } from "./AgentTile"; - -type CanvasFlowProps = { - tiles: AgentTile[]; - projectId: string | null; - transform: CanvasTransform; - viewportRef?: React.MutableRefObject<HTMLDivElement | null>; - selectedTileId: string | null; - canSend: boolean; - onSelectTile: (id: string | null) => void; - onMoveTile: (id: string, position: TilePosition) => void; - onResizeTile: (id: string, size: TileSize) => void; - onDeleteTile: (id: string) => void; - onRenameTile: (id: string, name: string) => Promise<boolean>; - onDraftChange: (id: string, value: string) => void; - onSend: (id: string, sessionKey: string, message: string) => void; - onModelChange: (id: string, sessionKey: string, value: string | null) => void; - onThinkingChange: (id: string, sessionKey: string, value: string | null) => void; - onAvatarShuffle: (id: string) => void; - onNameShuffle: (id: string) => void; - onUpdateTransform: (patch: Partial<CanvasTransform>) => void; -}; - -type TileNode = Node<AgentTileNodeData>; - -const CanvasFlowInner = ({ - tiles, - projectId, - transform, - viewportRef, - selectedTileId, - canSend, - onSelectTile, - onMoveTile, - onResizeTile, - onDeleteTile, - onRenameTile, - onDraftChange, - onSend, - onModelChange, - onThinkingChange, - onAvatarShuffle, - onNameShuffle, - onUpdateTransform, -}: CanvasFlowProps) => { - const nodeTypes = useMemo(() => ({ agentTile: AgentTileNode }), []); - const handlersRef = useRef({ - onMoveTile, - onResizeTile, - onDeleteTile, - onRenameTile, - onDraftChange, - onSend, - onModelChange, - onThinkingChange, - onAvatarShuffle, - onNameShuffle, - }); - - useEffect(() => { - handlersRef.current = { - onMoveTile, - onResizeTile, - onDeleteTile, - onRenameTile, - onDraftChange, - onSend, - onModelChange, - onThinkingChange, - onAvatarShuffle, - onNameShuffle, - }; - }, [ - onMoveTile, - onResizeTile, - onDeleteTile, - onRenameTile, - onDraftChange, - onSend, - onModelChange, - onThinkingChange, - onAvatarShuffle, - onNameShuffle, - ]); - - const nodesFromTiles = useMemo<TileNode[]>( - () => - tiles.map((tile) => ({ - id: tile.id, - type: "agentTile", - position: tile.position, - width: MIN_TILE_SIZE.width, - height: tile.size.height, - dragHandle: "[data-drag-handle]", - data: { - tile, - projectId, - canSend, - onResize: (size) => handlersRef.current.onResizeTile(tile.id, size), - onDelete: () => handlersRef.current.onDeleteTile(tile.id), - onNameChange: (name) => handlersRef.current.onRenameTile(tile.id, name), - onDraftChange: (value) => handlersRef.current.onDraftChange(tile.id, value), - onSend: (message) => - handlersRef.current.onSend(tile.id, tile.sessionKey, message), - onModelChange: (value) => - handlersRef.current.onModelChange(tile.id, tile.sessionKey, value), - onThinkingChange: (value) => - handlersRef.current.onThinkingChange(tile.id, tile.sessionKey, value), - onAvatarShuffle: () => handlersRef.current.onAvatarShuffle(tile.id), - onNameShuffle: () => handlersRef.current.onNameShuffle(tile.id), - }, - })), - [canSend, projectId, tiles] - ); - - const [nodes, setNodes, onNodesChange] = useNodesState(nodesFromTiles); - - useEffect(() => { - setNodes(nodesFromTiles); - }, [nodesFromTiles, setNodes]); - - const handleMove: OnMove = useCallback( - (_event, viewport) => { - onUpdateTransform({ - zoom: viewport.zoom, - offsetX: viewport.x, - offsetY: viewport.y, - }); - }, - [onUpdateTransform] - ); - - const handleNodeDragStop = useCallback( - (_: React.MouseEvent, node: TileNode) => { - onMoveTile(node.id, node.position); - }, - [onMoveTile] - ); - - const handleNodeClick = useCallback( - (_: React.MouseEvent, node: TileNode) => { - if (node.id === selectedTileId) return; - onSelectTile(node.id); - }, - [onSelectTile, selectedTileId] - ); - - const handleSelectionChange = useCallback( - ({ nodes: selectedNodes }: { nodes: TileNode[] }) => { - const nextSelection = selectedNodes[0]?.id ?? null; - if (nextSelection === selectedTileId) return; - onSelectTile(nextSelection); - }, - [onSelectTile, selectedTileId] - ); - - const setViewportRef = useCallback( - (node: HTMLDivElement | null) => { - if (viewportRef) { - viewportRef.current = node; - } - }, - [viewportRef] - ); - - return ( - <ReactFlow - ref={setViewportRef} - className="canvas-surface h-full w-full" - data-canvas-viewport - nodes={nodes} - edges={[]} - nodeTypes={nodeTypes} - onNodesChange={onNodesChange} - onNodeDragStop={handleNodeDragStop} - onNodeClick={handleNodeClick} - onSelectionChange={handleSelectionChange} - onPaneClick={() => { - if (selectedTileId !== null) { - onSelectTile(null); - } - }} - onMove={handleMove} - fitView - fitViewOptions={{ padding: 0.2 }} - minZoom={0.1} - maxZoom={2} - defaultViewport={{ - x: transform.offsetX, - y: transform.offsetY, - zoom: transform.zoom, - }} - > - <Background /> - <MiniMap /> - <Controls /> - </ReactFlow> - ); -}; - -export const CanvasFlow = (props: CanvasFlowProps) => ( - <ReactFlowProvider> - <CanvasFlowInner {...props} /> - </ReactFlowProvider> -); diff --git a/src/features/canvas/components/HeaderBar.tsx b/src/features/canvas/components/HeaderBar.tsx deleted file mode 100644 index 8c0fc478..00000000 --- a/src/features/canvas/components/HeaderBar.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import type { GatewayStatus } from "@/lib/gateway/GatewayClient"; - -type HeaderBarProps = { - projects: Array<{ id: string; name: string }>; - activeProjectId: string | null; - status: GatewayStatus; - onProjectChange: (projectId: string) => void; - onCreateProject: () => void; - onOpenProject: () => void; - onDeleteProject: () => void; - onNewAgent: () => void; - onCreateDiscordChannel: () => void; - canCreateDiscordChannel: boolean; -}; - -const statusStyles: Record<GatewayStatus, string> = { - disconnected: "bg-slate-200 text-slate-700", - connecting: "bg-amber-200 text-amber-900", - connected: "bg-emerald-200 text-emerald-900", -}; - -export const HeaderBar = ({ - projects, - activeProjectId, - status, - onProjectChange, - onCreateProject, - onOpenProject, - onDeleteProject, - onNewAgent, - onCreateDiscordChannel, - canCreateDiscordChannel, -}: HeaderBarProps) => { - const hasProjects = projects.length > 0; - - return ( - <div className="glass-panel flex flex-col gap-3 px-6 py-4"> - <div className="flex flex-wrap items-center gap-3"> - <div className="flex min-w-0 flex-1 items-center gap-2 overflow-x-auto"> - {hasProjects ? ( - projects.map((project) => { - const isActive = project.id === activeProjectId; - return ( - <button - key={project.id} - className={`rounded-full px-4 py-2 text-sm font-semibold transition ${ - isActive - ? "bg-slate-900 text-white" - : "border border-slate-300 bg-white/80 text-slate-700 hover:border-slate-400" - }`} - type="button" - onClick={() => onProjectChange(project.id)} - > - {project.name} - </button> - ); - }) - ) : ( - <span className="text-sm font-semibold text-slate-500">No workspaces</span> - )} - </div> - <span - className={`rounded-full px-3 py-2 text-xs font-semibold uppercase ${statusStyles[status]}`} - > - {status} - </span> - <button - className="rounded-full border border-slate-300 px-3 py-2 text-xs font-semibold text-slate-700" - type="button" - onClick={onCreateProject} - > - New Workspace - </button> - <button - className="rounded-full border border-slate-300 px-3 py-2 text-xs font-semibold text-slate-700" - type="button" - onClick={onOpenProject} - > - Open Workspace - </button> - <button - className="rounded-full border border-slate-300 px-3 py-2 text-xs font-semibold text-slate-700" - type="button" - onClick={onDeleteProject} - disabled={!activeProjectId} - > - Delete Workspace - </button> - </div> - - <div className="flex flex-wrap items-center gap-3"> - {activeProjectId ? ( - <button - className="rounded-full bg-[var(--accent-strong)] px-4 py-2 text-sm font-semibold text-white transition hover:brightness-110" - type="button" - onClick={onNewAgent} - > - New Agent - </button> - ) : null} - {canCreateDiscordChannel ? ( - <button - className="rounded-full border border-slate-300 px-4 py-2 text-xs font-semibold text-slate-700" - type="button" - onClick={onCreateDiscordChannel} - > - Create Discord Channel - </button> - ) : null} - </div> - </div> - ); -}; diff --git a/src/features/canvas/lib/transform.ts b/src/features/canvas/lib/transform.ts deleted file mode 100644 index 7a90c15c..00000000 --- a/src/features/canvas/lib/transform.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { AgentTile, CanvasTransform } from "@/features/canvas/state/store"; - -type Point = { x: number; y: number }; -type Size = { width: number; height: number }; - -const MIN_ZOOM = 0.25; -const MAX_ZOOM = 3; - -export const clampZoom = (zoom: number): number => { - if (zoom < MIN_ZOOM) return MIN_ZOOM; - if (zoom > MAX_ZOOM) return MAX_ZOOM; - return zoom; -}; - -export const screenToWorld = (transform: CanvasTransform, screen: Point): Point => { - return { - x: (screen.x - transform.offsetX) / transform.zoom, - y: (screen.y - transform.offsetY) / transform.zoom, - }; -}; - -export const worldToScreen = (transform: CanvasTransform, world: Point): Point => { - return { - x: transform.offsetX + world.x * transform.zoom, - y: transform.offsetY + world.y * transform.zoom, - }; -}; - -export const zoomAtScreenPoint = ( - transform: CanvasTransform, - nextZoomRaw: number, - screenPoint: Point -): CanvasTransform => { - const worldPoint = screenToWorld(transform, screenPoint); - const nextZoom = clampZoom(nextZoomRaw); - - return { - zoom: nextZoom, - offsetX: screenPoint.x - worldPoint.x * nextZoom, - offsetY: screenPoint.y - worldPoint.y * nextZoom, - }; -}; - -export const zoomToFit = ( - tiles: AgentTile[], - viewportSize: Size, - paddingPx: number, - currentTransform: CanvasTransform -): CanvasTransform => { - if (tiles.length === 0) return currentTransform; - - const minX = Math.min(...tiles.map((tile) => tile.position.x)); - const minY = Math.min(...tiles.map((tile) => tile.position.y)); - const maxX = Math.max( - ...tiles.map((tile) => tile.position.x + tile.size.width) - ); - const maxY = Math.max( - ...tiles.map((tile) => tile.position.y + tile.size.height) - ); - - const boundsWidth = Math.max(1, maxX - minX); - const boundsHeight = Math.max(1, maxY - minY); - const availableWidth = Math.max(1, viewportSize.width - paddingPx * 2); - const availableHeight = Math.max(1, viewportSize.height - paddingPx * 2); - - const nextZoom = clampZoom( - Math.min(availableWidth / boundsWidth, availableHeight / boundsHeight) - ); - const centerX = (minX + maxX) / 2; - const centerY = (minY + maxY) / 2; - - return { - zoom: nextZoom, - offsetX: viewportSize.width / 2 - centerX * nextZoom, - offsetY: viewportSize.height / 2 - centerY * nextZoom, - }; -}; diff --git a/src/features/canvas/state/store.tsx b/src/features/canvas/state/store.tsx deleted file mode 100644 index f2c1c03f..00000000 --- a/src/features/canvas/state/store.tsx +++ /dev/null @@ -1,477 +0,0 @@ -"use client"; - -import { - createContext, - useCallback, - useContext, - useEffect, - useMemo, - useReducer, - useRef, - type ReactNode, -} from "react"; - -import type { Project, ProjectTile, ProjectsStore } from "@/lib/projects/types"; -import { CANVAS_BASE_ZOOM } from "@/lib/canvasDefaults"; -import { - createProjectTile as apiCreateProjectTile, - createProject as apiCreateProject, - deleteProjectTile as apiDeleteProjectTile, - deleteProject as apiDeleteProject, - fetchProjectsStore, - openProject as apiOpenProject, - renameProjectTile as apiRenameProjectTile, - updateProjectTile as apiUpdateProjectTile, - saveProjectsStore, -} from "@/lib/projects/client"; - -export type AgentStatus = "idle" | "running" | "error"; - -export type TilePosition = { x: number; y: number }; -export type TileSize = { width: number; height: number }; - -export type AgentTile = ProjectTile & { - status: AgentStatus; - outputLines: string[]; - lastResult: string | null; - lastDiff: string | null; - runId: string | null; - streamText: string | null; - thinkingTrace: string | null; - draft: string; - sessionSettingsSynced: boolean; -}; - -export type ProjectRuntime = Omit<Project, "tiles"> & { - tiles: AgentTile[]; -}; - -export type CanvasTransform = { - zoom: number; - offsetX: number; - offsetY: number; -}; - -export type CanvasState = { - projects: ProjectRuntime[]; - activeProjectId: string | null; - selectedTileId: string | null; - canvas: CanvasTransform; - loading: boolean; - error: string | null; -}; - -type Action = - | { type: "loadStore"; store: ProjectsStore } - | { type: "setError"; error: string | null } - | { type: "setActiveProject"; projectId: string | null } - | { type: "addProject"; project: ProjectRuntime } - | { type: "removeProject"; projectId: string } - | { type: "updateProject"; projectId: string; patch: Partial<ProjectRuntime> } - | { type: "addTile"; projectId: string; tile: AgentTile } - | { type: "removeTile"; projectId: string; tileId: string } - | { type: "updateTile"; projectId: string; tileId: string; patch: Partial<AgentTile> } - | { type: "appendOutput"; projectId: string; tileId: string; line: string } - | { type: "setStream"; projectId: string; tileId: string; value: string | null } - | { type: "selectTile"; tileId: string | null } - | { type: "setCanvas"; patch: Partial<CanvasTransform> }; - -const initialState: CanvasState = { - projects: [], - activeProjectId: null, - selectedTileId: null, - canvas: { zoom: CANVAS_BASE_ZOOM, offsetX: 0, offsetY: 0 }, - loading: true, - error: null, -}; - -const buildSessionKey = (agentId: string) => `agent:${agentId}:main`; - -const createRuntimeTile = (tile: ProjectTile): AgentTile => ({ - ...tile, - sessionKey: tile.sessionKey || buildSessionKey(tile.agentId), - model: tile.model ?? "openai-codex/gpt-5.2-codex", - thinkingLevel: tile.thinkingLevel ?? "low", - avatarSeed: tile.avatarSeed ?? tile.agentId, - status: "idle", - outputLines: [], - lastResult: null, - lastDiff: null, - runId: null, - streamText: null, - thinkingTrace: null, - draft: "", - sessionSettingsSynced: false, -}); - -const hydrateProject = (project: Project): ProjectRuntime => ({ - ...project, - tiles: Array.isArray(project.tiles) ? project.tiles.map(createRuntimeTile) : [], -}); - -const dehydrateStore = (state: CanvasState): ProjectsStore => ({ - version: 2, - activeProjectId: state.activeProjectId, - projects: state.projects.map((project) => ({ - id: project.id, - name: project.name, - repoPath: project.repoPath, - createdAt: project.createdAt, - updatedAt: project.updatedAt, - tiles: project.tiles.map((tile) => ({ - id: tile.id, - name: tile.name, - agentId: tile.agentId, - role: tile.role, - sessionKey: tile.sessionKey, - model: tile.model ?? null, - thinkingLevel: tile.thinkingLevel ?? null, - avatarSeed: tile.avatarSeed ?? null, - position: tile.position, - size: tile.size, - })), - })), -}); - -const updateProjectList = ( - state: CanvasState, - updater: (projects: ProjectRuntime[]) => ProjectRuntime[] -): CanvasState => { - return { ...state, projects: updater(state.projects) }; -}; - -const reducer = (state: CanvasState, action: Action): CanvasState => { - switch (action.type) { - case "loadStore": { - const projects = action.store.projects.map(hydrateProject); - const activeProjectId = - action.store.activeProjectId && - projects.some((project) => project.id === action.store.activeProjectId) - ? action.store.activeProjectId - : projects[0]?.id ?? null; - return { - ...state, - projects, - activeProjectId, - loading: false, - error: null, - }; - } - case "setError": - return { ...state, error: action.error, loading: false }; - case "setActiveProject": - return { ...state, activeProjectId: action.projectId, selectedTileId: null }; - case "addProject": - return updateProjectList(state, (projects) => [...projects, action.project]); - case "removeProject": - return updateProjectList(state, (projects) => - projects.filter((project) => project.id !== action.projectId) - ); - case "updateProject": - return updateProjectList(state, (projects) => - projects.map((project) => - project.id === action.projectId - ? { ...project, ...action.patch, updatedAt: Date.now() } - : project - ) - ); - case "addTile": - return updateProjectList(state, (projects) => - projects.map((project) => - project.id === action.projectId - ? { - ...project, - tiles: [...project.tiles, action.tile], - updatedAt: Date.now(), - } - : project - ) - ); - case "removeTile": - return updateProjectList(state, (projects) => - projects.map((project) => - project.id === action.projectId - ? { - ...project, - tiles: project.tiles.filter((tile) => tile.id !== action.tileId), - updatedAt: Date.now(), - } - : project - ) - ); - case "updateTile": - return updateProjectList(state, (projects) => - projects.map((project) => - project.id === action.projectId - ? { - ...project, - tiles: project.tiles.map((tile) => - tile.id === action.tileId ? { ...tile, ...action.patch } : tile - ), - updatedAt: Date.now(), - } - : project - ) - ); - case "appendOutput": - return updateProjectList(state, (projects) => - projects.map((project) => - project.id === action.projectId - ? { - ...project, - tiles: project.tiles.map((tile) => - tile.id === action.tileId - ? { ...tile, outputLines: [...tile.outputLines, action.line] } - : tile - ), - } - : project - ) - ); - case "setStream": - return updateProjectList(state, (projects) => - projects.map((project) => - project.id === action.projectId - ? { - ...project, - tiles: project.tiles.map((tile) => - tile.id === action.tileId ? { ...tile, streamText: action.value } : tile - ), - } - : project - ) - ); - case "selectTile": - return { ...state, selectedTileId: action.tileId }; - case "setCanvas": - return { ...state, canvas: { ...state.canvas, ...action.patch } }; - default: - return state; - } -}; - -type StoreContextValue = { - state: CanvasState; - dispatch: React.Dispatch<Action>; - createTile: ( - projectId: string, - name: string, - role: ProjectTile["role"] - ) => Promise<{ tile: ProjectTile; warnings: string[] } | null>; - refreshStore: () => Promise<void>; - createProject: (name: string) => Promise<{ warnings: string[] } | null>; - openProject: (path: string) => Promise<{ warnings: string[] } | null>; - deleteProject: (projectId: string) => Promise<{ warnings: string[] } | null>; - deleteTile: ( - projectId: string, - tileId: string - ) => Promise<{ warnings: string[] } | null>; - renameTile: ( - projectId: string, - tileId: string, - name: string - ) => Promise<{ warnings: string[] } | { error: string } | null>; - updateTile: ( - projectId: string, - tileId: string, - payload: { avatarSeed?: string | null } - ) => Promise<{ warnings: string[] } | { error: string } | null>; -}; - -const StoreContext = createContext<StoreContextValue | null>(null); - -export const AgentCanvasProvider = ({ children }: { children: ReactNode }) => { - const [state, dispatch] = useReducer(reducer, initialState); - const lastSavedRef = useRef<string | null>(null); - const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); - - const refreshStore = useCallback(async () => { - try { - const store = await fetchProjectsStore(); - dispatch({ type: "loadStore", store }); - } catch (err) { - const message = err instanceof Error ? err.message : "Failed to load workspaces."; - dispatch({ type: "setError", error: message }); - } - }, []); - - useEffect(() => { - void refreshStore(); - }, [refreshStore]); - - useEffect(() => { - if (state.loading) return; - const payload = dehydrateStore(state); - const serialized = JSON.stringify(payload); - if (serialized === lastSavedRef.current) return; - - if (saveTimerRef.current) { - clearTimeout(saveTimerRef.current); - } - saveTimerRef.current = setTimeout(() => { - void saveProjectsStore(payload).then(() => { - lastSavedRef.current = serialized; - }); - }, 250); - }, [state]); - - const createTile = useCallback( - async (projectId: string, name: string, role: ProjectTile["role"]) => { - try { - const result = await apiCreateProjectTile(projectId, { name, role }); - dispatch({ type: "loadStore", store: result.store }); - return { tile: result.tile, warnings: result.warnings }; - } catch (err) { - const message = err instanceof Error ? err.message : "Failed to create tile."; - dispatch({ type: "setError", error: message }); - return null; - } - }, - [dispatch] - ); - - const createProject = useCallback(async (name: string) => { - try { - const result = await apiCreateProject({ name }); - dispatch({ type: "loadStore", store: result.store }); - return { warnings: result.warnings }; - } catch (err) { - const message = err instanceof Error ? err.message : "Failed to create workspace."; - dispatch({ type: "setError", error: message }); - return null; - } - }, []); - - const openProject = useCallback(async (path: string) => { - try { - const result = await apiOpenProject({ path }); - dispatch({ type: "loadStore", store: result.store }); - return { warnings: result.warnings }; - } catch (err) { - const message = err instanceof Error ? err.message : "Failed to open workspace."; - dispatch({ type: "setError", error: message }); - return null; - } - }, []); - - const deleteProject = useCallback(async (projectId: string) => { - try { - const result = await apiDeleteProject(projectId); - dispatch({ type: "loadStore", store: result.store }); - return { warnings: result.warnings }; - } catch (err) { - const message = err instanceof Error ? err.message : "Failed to delete workspace."; - dispatch({ type: "setError", error: message }); - return null; - } - }, []); - - const deleteTile = useCallback(async (projectId: string, tileId: string) => { - try { - const result = await apiDeleteProjectTile(projectId, tileId); - dispatch({ type: "loadStore", store: result.store }); - return { warnings: result.warnings }; - } catch (err) { - const message = err instanceof Error ? err.message : "Failed to delete tile."; - dispatch({ type: "setError", error: message }); - return null; - } - }, []); - - const renameTile = useCallback( - async (projectId: string, tileId: string, name: string) => { - const project = state.projects.find((item) => item.id === projectId); - const tile = project?.tiles.find((item) => item.id === tileId); - if (tile) { - dispatch({ type: "updateTile", projectId, tileId, patch: { name } }); - } - try { - const result = await apiRenameProjectTile(projectId, tileId, { name }); - return { warnings: result.warnings }; - } catch (err) { - const message = err instanceof Error ? err.message : "Failed to rename tile."; - if (tile) { - dispatch({ - type: "updateTile", - projectId, - tileId, - patch: { name: tile.name }, - }); - } - dispatch({ type: "setError", error: message }); - return { error: message }; - } - }, - [dispatch, state.projects] - ); - - const updateTile = useCallback( - async ( - projectId: string, - tileId: string, - payload: { avatarSeed?: string | null } - ) => { - const project = state.projects.find((item) => item.id === projectId); - const tile = project?.tiles.find((item) => item.id === tileId); - if (tile) { - dispatch({ type: "updateTile", projectId, tileId, patch: payload }); - } - try { - const result = await apiUpdateProjectTile(projectId, tileId, payload); - return { warnings: result.warnings }; - } catch (err) { - const message = err instanceof Error ? err.message : "Failed to update tile."; - if (tile) { - dispatch({ - type: "updateTile", - projectId, - tileId, - patch: { avatarSeed: tile.avatarSeed ?? null }, - }); - } - dispatch({ type: "setError", error: message }); - return { error: message }; - } - }, - [dispatch, state.projects] - ); - - const value = useMemo<StoreContextValue>(() => { - return { - state, - dispatch, - createTile, - refreshStore, - createProject, - openProject, - deleteProject, - deleteTile, - renameTile, - updateTile, - }; - }, [ - state, - createTile, - refreshStore, - createProject, - openProject, - deleteProject, - deleteTile, - renameTile, - updateTile, - ]); - - return <StoreContext.Provider value={value}>{children}</StoreContext.Provider>; -}; - -export const useAgentCanvasStore = () => { - const ctx = useContext(StoreContext); - if (!ctx) { - throw new Error("AgentCanvasProvider is missing."); - } - return ctx; -}; - -export const getActiveProject = (state: CanvasState): ProjectRuntime | null => { - return state.projects.find((project) => project.id === state.activeProjectId) ?? null; -}; diff --git a/src/lib/projects/workspaceFiles.ts b/src/lib/agents/agentFiles.ts similarity index 64% rename from src/lib/projects/workspaceFiles.ts rename to src/lib/agents/agentFiles.ts index 9ae7d563..6042a449 100644 --- a/src/lib/projects/workspaceFiles.ts +++ b/src/lib/agents/agentFiles.ts @@ -1,4 +1,4 @@ -export const WORKSPACE_FILE_NAMES = [ +export const AGENT_FILE_NAMES = [ "AGENTS.md", "SOUL.md", "IDENTITY.md", @@ -8,9 +8,12 @@ export const WORKSPACE_FILE_NAMES = [ "MEMORY.md", ] as const; -export type WorkspaceFileName = (typeof WORKSPACE_FILE_NAMES)[number]; +export type AgentFileName = (typeof AGENT_FILE_NAMES)[number]; -export const WORKSPACE_FILE_META: Record<WorkspaceFileName, { title: string; hint: string }> = { +export const isAgentFileName = (value: string): value is AgentFileName => + AGENT_FILE_NAMES.includes(value as AgentFileName); + +export const AGENT_FILE_META: Record<AgentFileName, { title: string; hint: string }> = { "AGENTS.md": { title: "AGENTS.md", hint: "Operating instructions, priorities, and rules.", @@ -41,12 +44,17 @@ export const WORKSPACE_FILE_META: Record<WorkspaceFileName, { title: string; hin }, }; -export const WORKSPACE_FILE_PLACEHOLDERS: Record<WorkspaceFileName, string> = { +export const AGENT_FILE_PLACEHOLDERS: Record<AgentFileName, string> = { "AGENTS.md": "How should this agent work? Priorities, rules, and habits.", "SOUL.md": "Tone, personality, boundaries, and how it should sound.", - "IDENTITY.md": "Name, vibe, emoji, and a one‑line identity.", + "IDENTITY.md": "Name, vibe, emoji, and a one-line identity.", "USER.md": "How should it address you? Preferences and context.", "TOOLS.md": "Local tool notes, conventions, and shortcuts.", "HEARTBEAT.md": "A tiny checklist for periodic runs.", "MEMORY.md": "Durable facts, decisions, and preferences to remember.", }; + +export const createAgentFilesState = () => + Object.fromEntries( + AGENT_FILE_NAMES.map((name) => [name, { content: "", exists: false }]) + ) as Record<AgentFileName, { content: string; exists: boolean }>; diff --git a/src/lib/agents/configList.ts b/src/lib/agents/configList.ts new file mode 100644 index 00000000..ad4212f3 --- /dev/null +++ b/src/lib/agents/configList.ts @@ -0,0 +1,44 @@ +export type ConfigAgentEntry = Record<string, unknown> & { id: string }; + +const isRecord = (value: unknown): value is Record<string, unknown> => + Boolean(value && typeof value === "object" && !Array.isArray(value)); + +export const readConfigAgentList = ( + config: Record<string, unknown> | undefined +): ConfigAgentEntry[] => { + if (!config) return []; + const agents = isRecord(config.agents) ? config.agents : null; + const list = Array.isArray(agents?.list) ? agents.list : []; + return list.filter((entry): entry is ConfigAgentEntry => { + if (!isRecord(entry)) return false; + if (typeof entry.id !== "string") return false; + return entry.id.trim().length > 0; + }); +}; + +export const writeConfigAgentList = ( + config: Record<string, unknown>, + list: ConfigAgentEntry[] +): Record<string, unknown> => { + const agents = isRecord(config.agents) ? { ...config.agents } : {}; + return { ...config, agents: { ...agents, list } }; +}; + +export const upsertConfigAgentEntry = ( + list: ConfigAgentEntry[], + agentId: string, + updater: (entry: ConfigAgentEntry) => ConfigAgentEntry +): { list: ConfigAgentEntry[]; entry: ConfigAgentEntry } => { + let updatedEntry: ConfigAgentEntry | null = null; + const nextList = list.map((entry) => { + if (entry.id !== agentId) return entry; + const next = updater({ ...entry, id: agentId }); + updatedEntry = next; + return next; + }); + if (!updatedEntry) { + updatedEntry = updater({ id: agentId }); + nextList.push(updatedEntry); + } + return { list: nextList, entry: updatedEntry }; +}; diff --git a/src/lib/canvasDefaults.ts b/src/lib/canvasDefaults.ts deleted file mode 100644 index c2136695..00000000 --- a/src/lib/canvasDefaults.ts +++ /dev/null @@ -1 +0,0 @@ -export const CANVAS_BASE_ZOOM = 1.1; diff --git a/src/lib/clawdbot/config.ts b/src/lib/clawdbot/config.ts index 041f68b4..e2685aa4 100644 --- a/src/lib/clawdbot/config.ts +++ b/src/lib/clawdbot/config.ts @@ -1,55 +1,21 @@ import fs from "node:fs"; -import os from "node:os"; import path from "node:path"; -import { env } from "@/lib/env"; +import { + readConfigAgentList, + writeConfigAgentList, + type ConfigAgentEntry, +} from "@/lib/agents/configList"; +import { resolveConfigPathCandidates, resolveStateDir } from "@/lib/clawdbot/paths"; type ClawdbotConfig = Record<string, unknown>; -type AgentEntry = { - id: string; +export type AgentEntry = ConfigAgentEntry & { name?: string; workspace?: string; }; -const LEGACY_STATE_DIRNAME = ".clawdbot"; -const NEW_STATE_DIRNAME = ".moltbot"; -const CONFIG_FILENAME = "moltbot.json"; - -const resolveUserPath = (input: string) => { - const trimmed = input.trim(); - if (!trimmed) return trimmed; - if (trimmed.startsWith("~")) { - const expanded = trimmed.replace(/^~(?=$|[\\/])/, os.homedir()); - return path.resolve(expanded); - } - return path.resolve(trimmed); -}; - -const resolveStateDir = () => { - const raw = env.MOLTBOT_STATE_DIR ?? env.CLAWDBOT_STATE_DIR; - if (raw?.trim()) { - return resolveUserPath(raw); - } - return path.join(os.homedir(), LEGACY_STATE_DIRNAME); -}; - -const resolveConfigPathCandidates = () => { - const explicit = env.MOLTBOT_CONFIG_PATH ?? env.CLAWDBOT_CONFIG_PATH; - if (explicit?.trim()) { - return [resolveUserPath(explicit)]; - } - const candidates: string[] = []; - if (env.MOLTBOT_STATE_DIR?.trim()) { - candidates.push(path.join(resolveUserPath(env.MOLTBOT_STATE_DIR), CONFIG_FILENAME)); - } - if (env.CLAWDBOT_STATE_DIR?.trim()) { - candidates.push(path.join(resolveUserPath(env.CLAWDBOT_STATE_DIR), CONFIG_FILENAME)); - } - candidates.push(path.join(os.homedir(), NEW_STATE_DIRNAME, CONFIG_FILENAME)); - candidates.push(path.join(os.homedir(), LEGACY_STATE_DIRNAME, CONFIG_FILENAME)); - return candidates; -}; +const CONFIG_FILENAME = "openclaw.json"; const parseJsonLoose = (raw: string) => { try { @@ -75,83 +41,11 @@ export const saveClawdbotConfig = (configPath: string, config: ClawdbotConfig) = fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf8"); }; -const readAgentList = (config: ClawdbotConfig): AgentEntry[] => { - const agents = (config.agents ?? {}) as Record<string, unknown>; - const list = Array.isArray(agents.list) ? agents.list : []; - return list.filter((entry): entry is AgentEntry => Boolean(entry && typeof entry === "object")); -}; - -const writeAgentList = (config: ClawdbotConfig, list: AgentEntry[]) => { - const agents = (config.agents ?? {}) as Record<string, unknown>; - agents.list = list; - config.agents = agents; -}; - -export const upsertAgentEntry = ( - config: ClawdbotConfig, - entry: { agentId: string; agentName: string; workspaceDir: string } -): boolean => { - const list = readAgentList(config); - let changed = false; - let found = false; - const next = list.map((item) => { - if (item.id !== entry.agentId) return item; - found = true; - const nextItem: AgentEntry = { ...item }; - if (entry.agentName && entry.agentName !== item.name) { - nextItem.name = entry.agentName; - changed = true; - } - if (entry.workspaceDir !== item.workspace) { - nextItem.workspace = entry.workspaceDir; - changed = true; - } - return nextItem; - }); - if (!found) { - next.push({ id: entry.agentId, name: entry.agentName, workspace: entry.workspaceDir }); - changed = true; - } - if (changed) { - writeAgentList(config, next); - } - return changed; -}; - -export const renameAgentEntry = ( - config: ClawdbotConfig, - entry: { fromAgentId: string; toAgentId: string; agentName: string; workspaceDir: string } -): boolean => { - const list = readAgentList(config); - let changed = false; - let found = false; - const next = list.map((item) => { - if (item.id !== entry.fromAgentId) return item; - found = true; - const nextItem: AgentEntry = { ...item, id: entry.toAgentId }; - if (entry.agentName && entry.agentName !== item.name) { - nextItem.name = entry.agentName; - } - if (entry.workspaceDir !== item.workspace) { - nextItem.workspace = entry.workspaceDir; - } - changed = true; - return nextItem; - }); - if (!found) { - next.push({ id: entry.toAgentId, name: entry.agentName, workspace: entry.workspaceDir }); - changed = true; - } - if (changed) { - writeAgentList(config, next); - } - return changed; +export const readAgentList = (config: Record<string, unknown>): AgentEntry[] => { + return readConfigAgentList(config); }; -export const removeAgentEntry = (config: ClawdbotConfig, agentId: string): boolean => { - const list = readAgentList(config); - const next = list.filter((item) => item.id !== agentId); - if (next.length === list.length) return false; - writeAgentList(config, next); - return true; +export const writeAgentList = (config: Record<string, unknown>, list: AgentEntry[]) => { + const next = writeConfigAgentList(config, list); + config.agents = next.agents; }; diff --git a/src/lib/clawdbot/gateway.ts b/src/lib/clawdbot/gateway.ts deleted file mode 100644 index 6e0e95f1..00000000 --- a/src/lib/clawdbot/gateway.ts +++ /dev/null @@ -1,16 +0,0 @@ -type GatewayConfig = { - gatewayUrl: string; - token: string; -}; - -export const resolveGatewayConfig = (config: Record<string, unknown>): GatewayConfig => { - const gateway = (config.gateway ?? {}) as Record<string, unknown>; - const port = typeof gateway.port === "number" ? gateway.port : 18789; - const host = - typeof gateway.host === "string" && gateway.host.trim() - ? gateway.host.trim() - : "127.0.0.1"; - const auth = (gateway.auth ?? {}) as Record<string, unknown>; - const token = typeof auth.token === "string" ? auth.token : ""; - return { gatewayUrl: `ws://${host}:${port}`, token }; -}; diff --git a/src/lib/clawdbot/paths.ts b/src/lib/clawdbot/paths.ts new file mode 100644 index 00000000..2f02c7e1 --- /dev/null +++ b/src/lib/clawdbot/paths.ts @@ -0,0 +1,81 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const LEGACY_STATE_DIRNAMES = [".clawdbot", ".moltbot"] as const; +const NEW_STATE_DIRNAME = ".openclaw"; +const CONFIG_FILENAME = "openclaw.json"; +const LEGACY_CONFIG_FILENAMES = ["clawdbot.json", "moltbot.json"] as const; + +export const resolveUserPath = ( + input: string, + homedir: () => string = os.homedir +): string => { + const trimmed = input.trim(); + if (!trimmed) return trimmed; + if (trimmed.startsWith("~")) { + const expanded = trimmed.replace(/^~(?=$|[\\/])/, homedir()); + return path.resolve(expanded); + } + return path.resolve(trimmed); +}; + +export const resolveStateDir = ( + env: NodeJS.ProcessEnv = process.env, + homedir: () => string = os.homedir +): string => { + const override = + env.OPENCLAW_STATE_DIR?.trim() || + env.MOLTBOT_STATE_DIR?.trim() || + env.CLAWDBOT_STATE_DIR?.trim(); + if (override) return resolveUserPath(override, homedir); + const newDir = path.join(homedir(), NEW_STATE_DIRNAME); + const legacyDirs = LEGACY_STATE_DIRNAMES.map((dir) => path.join(homedir(), dir)); + const hasNew = fs.existsSync(newDir); + if (hasNew) return newDir; + const existingLegacy = legacyDirs.find((dir) => { + try { + return fs.existsSync(dir); + } catch { + return false; + } + }); + return existingLegacy ?? newDir; +}; + +export const resolveConfigPathCandidates = ( + env: NodeJS.ProcessEnv = process.env, + homedir: () => string = os.homedir +): string[] => { + const explicit = + env.OPENCLAW_CONFIG_PATH?.trim() || + env.MOLTBOT_CONFIG_PATH?.trim() || + env.CLAWDBOT_CONFIG_PATH?.trim(); + if (explicit) return [resolveUserPath(explicit, homedir)]; + + const candidates: string[] = []; + const stateDir = + env.OPENCLAW_STATE_DIR?.trim() || + env.MOLTBOT_STATE_DIR?.trim() || + env.CLAWDBOT_STATE_DIR?.trim(); + if (stateDir) { + const resolved = resolveUserPath(stateDir, homedir); + candidates.push(path.join(resolved, CONFIG_FILENAME)); + candidates.push(...LEGACY_CONFIG_FILENAMES.map((name) => path.join(resolved, name))); + } + + const defaultDirs = [ + path.join(homedir(), NEW_STATE_DIRNAME), + ...LEGACY_STATE_DIRNAMES.map((dir) => path.join(homedir(), dir)), + ]; + for (const dir of defaultDirs) { + candidates.push(path.join(dir, CONFIG_FILENAME)); + candidates.push(...LEGACY_CONFIG_FILENAMES.map((name) => path.join(dir, name))); + } + return candidates; +}; + +export const resolveClawdbotEnvPath = ( + env: NodeJS.ProcessEnv = process.env, + homedir: () => string = os.homedir +): string => path.join(resolveStateDir(env, homedir), ".env"); diff --git a/src/lib/cron/client.ts b/src/lib/cron/client.ts new file mode 100644 index 00000000..c6601152 --- /dev/null +++ b/src/lib/cron/client.ts @@ -0,0 +1,6 @@ +import { fetchJson } from "@/lib/http"; +import type { CronJobsResult } from "./types"; + +export const fetchCronJobs = async (): Promise<CronJobsResult> => { + return fetchJson<CronJobsResult>("/api/cron", { cache: "no-store" }); +}; diff --git a/src/lib/cron/types.ts b/src/lib/cron/types.ts new file mode 100644 index 00000000..7ed30f0f --- /dev/null +++ b/src/lib/cron/types.ts @@ -0,0 +1,23 @@ +export type CronSchedule = + | { kind: "at"; atMs: number } + | { kind: "every"; everyMs: number; anchorMs?: number } + | { kind: "cron"; expr: string; tz?: string }; + +export type CronPayload = + | { kind: "systemEvent"; text: string } + | { kind: "agentTurn"; message: string }; + +export type CronJobSummary = { + id: string; + name: string; + agentId?: string; + enabled: boolean; + updatedAtMs: number; + schedule: CronSchedule; + payload: CronPayload; + sessionTarget?: string; +}; + +export type CronJobsResult = { + jobs: CronJobSummary[]; +}; diff --git a/src/lib/discord/discordChannel.ts b/src/lib/discord/discordChannel.ts index 6793b737..2189c50f 100644 --- a/src/lib/discord/discordChannel.ts +++ b/src/lib/discord/discordChannel.ts @@ -1,9 +1,13 @@ import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { slugifyProjectName } from "../ids/slugify"; -import { loadClawdbotConfig, saveClawdbotConfig } from "../clawdbot/config"; +import { slugifyName } from "../ids/slugify"; +import { + loadClawdbotConfig, + readAgentList, + saveClawdbotConfig, + writeAgentList, +} from "../clawdbot/config"; +import { resolveClawdbotEnvPath } from "@/lib/clawdbot/paths"; type DiscordChannelCreateResult = { channelId: string; @@ -13,11 +17,10 @@ type DiscordChannelCreateResult = { warnings: string[]; }; -const ENV_PATH = path.join(os.homedir(), ".clawdbot", ".env"); - const readEnvValue = (key: string) => { - if (!fs.existsSync(ENV_PATH)) return null; - const lines = fs.readFileSync(ENV_PATH, "utf8").split(/\r?\n/); + const envPath = resolveClawdbotEnvPath(); + if (!fs.existsSync(envPath)) return null; + const lines = fs.readFileSync(envPath, "utf8").split(/\r?\n/); for (const line of lines) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith("#")) continue; @@ -69,16 +72,13 @@ const ensureAgentConfig = ( agentName: string, workspaceDir: string ) => { - const agents = (config.agents ?? {}) as Record<string, unknown>; - const list = Array.isArray(agents.list) ? [...agents.list] : []; - const exists = list.some((entry) => { - if (!entry || typeof entry !== "object") return false; - return (entry as Record<string, unknown>).id === agentId; - }); + const list = readAgentList(config); + const exists = list.some((entry) => entry.id === agentId); if (!exists) { - list.push({ id: agentId, name: agentName, workspace: workspaceDir }); - agents.list = list; - config.agents = agents; + writeAgentList(config, [ + ...list, + { id: agentId, name: agentName, workspace: workspaceDir }, + ]); return true; } return false; @@ -224,7 +224,7 @@ export const createDiscordChannelForAgent = async ({ } const { config, configPath } = loadConfig(); const resolvedGuildId = resolveGuildId(config, guildId); - const channelName = slugifyProjectName(agentName); + const channelName = slugifyName(agentName); const warnings: string[] = []; ensureWorkspaceDir(workspaceDir); const addedAgent = ensureAgentConfig(config, agentId, agentName, workspaceDir); diff --git a/src/lib/fs.server.test.ts b/src/lib/fs.server.test.ts new file mode 100644 index 00000000..5d2a054f --- /dev/null +++ b/src/lib/fs.server.test.ts @@ -0,0 +1,80 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + deleteDirRecursiveIfExists, + deleteFileIfExists, + ensureDir, + ensureFile, +} from "./fs.server"; + +const makeTempDir = (name: string) => fs.mkdtempSync(path.join(os.tmpdir(), `${name}-`)); + +describe("fs.server", () => { + it("ensureDir creates directories and throws when path exists as a file", () => { + const root = makeTempDir("ensureDir"); + const dir = path.join(root, "a", "b"); + + ensureDir(dir); + expect(fs.existsSync(dir)).toBe(true); + expect(fs.statSync(dir).isDirectory()).toBe(true); + + const filePath = path.join(root, "file.txt"); + fs.writeFileSync(filePath, "hi", "utf8"); + expect(() => ensureDir(filePath)).toThrow(/not a directory/i); + + fs.rmSync(root, { recursive: true, force: true }); + }); + + it("ensureFile creates missing files and throws when path exists as a directory", () => { + const root = makeTempDir("ensureFile"); + const filePath = path.join(root, "x.txt"); + + ensureFile(filePath, "hello"); + expect(fs.readFileSync(filePath, "utf8")).toBe("hello"); + + const dirPath = path.join(root, "dir"); + fs.mkdirSync(dirPath); + expect(() => ensureFile(dirPath, "nope")).toThrow(/not a file/i); + + fs.rmSync(root, { recursive: true, force: true }); + }); + + it("deleteFileIfExists deletes a file and no-ops when missing", () => { + const root = makeTempDir("deleteFileIfExists"); + const filePath = path.join(root, "x.txt"); + + deleteFileIfExists(filePath); + expect(fs.existsSync(filePath)).toBe(false); + + fs.writeFileSync(filePath, "hi", "utf8"); + expect(fs.existsSync(filePath)).toBe(true); + + deleteFileIfExists(filePath); + expect(fs.existsSync(filePath)).toBe(false); + + fs.rmSync(root, { recursive: true, force: true }); + }); + + it("deleteDirRecursiveIfExists deletes directories and throws on non-directory", () => { + const root = makeTempDir("deleteDirRecursiveIfExists"); + + const missing = path.join(root, "missing"); + expect(deleteDirRecursiveIfExists(missing).deleted).toBe(false); + + const dirPath = path.join(root, "dir"); + fs.mkdirSync(dirPath); + fs.writeFileSync(path.join(dirPath, "a.txt"), "a", "utf8"); + expect(deleteDirRecursiveIfExists(dirPath).deleted).toBe(true); + expect(fs.existsSync(dirPath)).toBe(false); + + const filePath = path.join(root, "file.txt"); + fs.writeFileSync(filePath, "hi", "utf8"); + expect(() => deleteDirRecursiveIfExists(filePath)).toThrow(/not a directory/i); + + fs.rmSync(root, { recursive: true, force: true }); + }); +}); diff --git a/src/lib/fs.server.ts b/src/lib/fs.server.ts new file mode 100644 index 00000000..d4bd2389 --- /dev/null +++ b/src/lib/fs.server.ts @@ -0,0 +1,188 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { resolveUserPath } from "@/lib/clawdbot/paths"; +import type { + PathAutocompleteEntry, + PathAutocompleteResult, +} from "@/lib/path-suggestions/types"; + +export const ensureDir = (dirPath: string) => { + if (fs.existsSync(dirPath)) { + const stat = fs.statSync(dirPath); + if (!stat.isDirectory()) { + throw new Error(`${dirPath} exists and is not a directory.`); + } + return; + } + fs.mkdirSync(dirPath, { recursive: true }); +}; + +export const assertIsFile = (filePath: string, label?: string) => { + if (!fs.existsSync(filePath)) { + throw new Error(`${label ?? filePath} does not exist.`); + } + const stat = fs.statSync(filePath); + if (!stat.isFile()) { + throw new Error(`${label ?? filePath} exists but is not a file.`); + } +}; + +export const assertIsDir = (dirPath: string, label?: string) => { + if (!fs.existsSync(dirPath)) { + throw new Error(`${label ?? dirPath} does not exist.`); + } + const stat = fs.statSync(dirPath); + if (!stat.isDirectory()) { + throw new Error(`${label ?? dirPath} exists but is not a directory.`); + } +}; + +export const ensureFile = (filePath: string, contents: string) => { + if (fs.existsSync(filePath)) { + const stat = fs.statSync(filePath); + if (!stat.isFile()) { + throw new Error(`${filePath} exists but is not a file.`); + } + return; + } + fs.writeFileSync(filePath, contents, "utf8"); +}; + +export const deleteFileIfExists = (filePath: string) => { + if (!fs.existsSync(filePath)) { + return; + } + const stat = fs.statSync(filePath); + if (!stat.isFile()) { + return; + } + fs.rmSync(filePath); +}; + +export const deleteDirRecursiveIfExists = (dirPath: string): { deleted: boolean } => { + if (!fs.existsSync(dirPath)) { + return { deleted: false }; + } + const stat = fs.statSync(dirPath); + if (!stat.isDirectory()) { + throw new Error(`Path is not a directory: ${dirPath}`); + } + fs.rmSync(dirPath, { recursive: true, force: false }); + return { deleted: true }; +}; + +const GITIGNORE_LINES = [".env", ".env.*", "!.env.example"]; + +export const ensureGitRepo = (dir: string): { warnings: string[] } => { + ensureDir(dir); + + const gitDir = path.join(dir, ".git"); + if (!fs.existsSync(gitDir)) { + const result = spawnSync("git", ["init"], { cwd: dir, encoding: "utf8" }); + if (result.status !== 0) { + const stderr = result.stderr?.trim(); + throw new Error(stderr ? `git init failed in ${dir}: ${stderr}` : `git init failed in ${dir}.`); + } + } + + const gitignorePath = path.join(dir, ".gitignore"); + const existing = fs.existsSync(gitignorePath) ? fs.readFileSync(gitignorePath, "utf8") : ""; + const existingLines = existing.split(/\r?\n/); + const missing = GITIGNORE_LINES.filter((line) => !existingLines.includes(line)); + if (missing.length > 0) { + let next = existing; + if (next.length > 0 && !next.endsWith("\n")) { + next += "\n"; + } + next += `${missing.join("\n")}\n`; + fs.writeFileSync(gitignorePath, next, "utf8"); + } + + return { warnings: [] }; +}; + +type PathAutocompleteOptions = { + query: string; + maxResults?: number; + homedir?: () => string; +}; + +const normalizeQuery = (query: string): string => { + const trimmed = query.trim(); + if (!trimmed) { + throw new Error("Query is required."); + } + if (trimmed === "~") { + return "~/"; + } + if (trimmed.startsWith("~")) { + return trimmed; + } + const withoutLeading = trimmed.replace(/^[\\/]+/, ""); + return `~/${withoutLeading}`; +}; + +const isWithinHome = (target: string, home: string): boolean => { + const relative = path.relative(home, target); + if (!relative) return true; + return !relative.startsWith("..") && !path.isAbsolute(relative); +}; + +export const listPathAutocompleteEntries = ({ + query, + maxResults = 10, + homedir = os.homedir, +}: PathAutocompleteOptions): PathAutocompleteResult => { + const normalized = normalizeQuery(query); + const resolvedHome = path.resolve(homedir()); + const resolvedQuery = resolveUserPath(normalized, homedir); + if (!isWithinHome(resolvedQuery, resolvedHome)) { + throw new Error("Path must stay within the home directory."); + } + + const endsWithSlash = normalized.endsWith("/") || normalized.endsWith(path.sep); + const directoryPath = endsWithSlash ? resolvedQuery : path.dirname(resolvedQuery); + const prefix = endsWithSlash ? "" : path.basename(resolvedQuery); + + if (!isWithinHome(directoryPath, resolvedHome)) { + throw new Error("Path must stay within the home directory."); + } + if (!fs.existsSync(directoryPath)) { + throw new Error(`Directory does not exist: ${directoryPath}`); + } + const stat = fs.statSync(directoryPath); + if (!stat.isDirectory()) { + throw new Error(`Path is not a directory: ${directoryPath}`); + } + + const limit = Number.isFinite(maxResults) && maxResults > 0 ? Math.floor(maxResults) : 10; + + const entries = fs + .readdirSync(directoryPath, { withFileTypes: true }) + .filter((entry) => !entry.name.startsWith(".")) + .filter((entry) => entry.name.startsWith(prefix)) + .map((entry) => { + const fullPath = path.join(directoryPath, entry.name); + const relative = path.relative(resolvedHome, fullPath); + const normalizedRelative = relative.split(path.sep).join("/"); + const displayBase = `~/${normalizedRelative}`; + return { + name: entry.name, + fullPath, + displayPath: entry.isDirectory() ? `${displayBase}/` : displayBase, + isDirectory: entry.isDirectory(), + } satisfies PathAutocompleteEntry; + }) + .sort((a, b) => { + if (a.isDirectory !== b.isDirectory) { + return a.isDirectory ? -1 : 1; + } + return a.name.localeCompare(b.name); + }) + .slice(0, limit); + + return { query: normalized, directory: directoryPath, entries }; +}; diff --git a/src/lib/fs/git.ts b/src/lib/fs/git.ts deleted file mode 100644 index daa428a4..00000000 --- a/src/lib/fs/git.ts +++ /dev/null @@ -1,42 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import { spawnSync } from "node:child_process"; - -const GITIGNORE_LINES = [".env", ".env.*", "!.env.example"]; - -export const ensureGitRepo = (dir: string): { warnings: string[] } => { - if (fs.existsSync(dir)) { - const stat = fs.statSync(dir); - if (!stat.isDirectory()) { - throw new Error(`${dir} exists and is not a directory.`); - } - } else { - fs.mkdirSync(dir, { recursive: true }); - } - - const gitDir = path.join(dir, ".git"); - if (!fs.existsSync(gitDir)) { - const result = spawnSync("git", ["init"], { cwd: dir, encoding: "utf8" }); - if (result.status !== 0) { - const stderr = result.stderr?.trim(); - throw new Error( - stderr ? `git init failed in ${dir}: ${stderr}` : `git init failed in ${dir}.` - ); - } - } - - const gitignorePath = path.join(dir, ".gitignore"); - const existing = fs.existsSync(gitignorePath) ? fs.readFileSync(gitignorePath, "utf8") : ""; - const existingLines = existing.split(/\r?\n/); - const missing = GITIGNORE_LINES.filter((line) => !existingLines.includes(line)); - if (missing.length > 0) { - let next = existing; - if (next.length > 0 && !next.endsWith("\n")) { - next += "\n"; - } - next += `${missing.join("\n")}\n`; - fs.writeFileSync(gitignorePath, next, "utf8"); - } - - return { warnings: [] }; -}; diff --git a/src/lib/gateway/GatewayClient.ts b/src/lib/gateway/GatewayClient.ts index f8e96a4d..3810b72e 100644 --- a/src/lib/gateway/GatewayClient.ts +++ b/src/lib/gateway/GatewayClient.ts @@ -1,11 +1,9 @@ import { logger } from "@/lib/logger"; -import { EventFrame, GatewayFrame, ReqFrame, ResFrame } from "./frames"; - -type PendingRequest = { - resolve: (value: unknown) => void; - reject: (error: Error) => void; - timeoutId: ReturnType<typeof setTimeout>; -}; +import type { EventFrame } from "./frames"; +import { + GatewayBrowserClient, + type GatewayHelloOk, +} from "./openclaw/GatewayBrowserClient"; type StatusHandler = (status: GatewayStatus) => void; @@ -43,12 +41,15 @@ export class GatewayResponseError extends Error { } export class GatewayClient { - private socket: WebSocket | null = null; - private pending = new Map<string, PendingRequest>(); + private client: GatewayBrowserClient | null = null; private statusHandlers = new Set<StatusHandler>(); private eventHandlers = new Set<EventHandler>(); private status: GatewayStatus = "disconnected"; - private lastChallenge: unknown = null; + private pendingConnect: Promise<void> | null = null; + private resolveConnect: (() => void) | null = null; + private rejectConnect: ((error: Error) => void) | null = null; + private manualDisconnect = false; + private lastHello: GatewayHelloOk | null = null; onStatus(handler: StatusHandler) { this.statusHandlers.add(handler); @@ -69,75 +70,67 @@ export class GatewayClient { if (!options.gatewayUrl.trim()) { throw new Error("Gateway URL is required."); } - if (this.socket) { + if (this.client) { throw new Error("Gateway is already connected or connecting."); } - this.lastChallenge = null; + this.manualDisconnect = false; this.updateStatus("connecting"); - const socket = new WebSocket(options.gatewayUrl); - this.socket = socket; - - socket.addEventListener("message", (event) => { - this.handleMessage(event.data); + this.pendingConnect = new Promise<void>((resolve, reject) => { + this.resolveConnect = resolve; + this.rejectConnect = reject; }); - socket.addEventListener("close", () => { - this.handleClose(); + this.client = new GatewayBrowserClient({ + url: options.gatewayUrl, + token: options.token, + onHello: (hello) => { + this.lastHello = hello; + this.updateStatus("connected"); + this.resolveConnect?.(); + this.clearConnectPromise(); + }, + onEvent: (event) => { + this.eventHandlers.forEach((handler) => handler(event)); + }, + onClose: ({ code, reason }) => { + const err = new Error(`Gateway closed (${code}): ${reason}`); + if (this.rejectConnect) { + this.rejectConnect(err); + this.clearConnectPromise(); + } + this.updateStatus(this.manualDisconnect ? "disconnected" : "connecting"); + if (this.manualDisconnect) { + logger.info("Gateway disconnected."); + } + }, + onGap: ({ expected, received }) => { + logger.warn(`Gateway event gap expected ${expected}, received ${received}.`); + }, }); - socket.addEventListener("error", () => { - logger.error("Gateway socket error."); - }); + this.client.start(); try { - await this.waitForOpen(socket); - - const connectParams: Record<string, unknown> = { - minProtocol: 3, - maxProtocol: 3, - role: "operator", - scopes: ["operator.admin", "operator.approvals", "operator.pairing"], - client: { - id: "moltbot-control-ui", - version: "0.1.0", - platform: navigator.platform ?? "web", - mode: "ui", - }, - caps: [], - userAgent: navigator.userAgent, - locale: navigator.language, - }; - - if (options.token) { - connectParams.auth = { token: options.token }; - } - - await this.sendRequest("connect", connectParams); - - this.updateStatus("connected"); - logger.info("Gateway connected."); - } catch (error) { - const reason = - error instanceof Error ? error : new Error("Gateway connect failed."); - this.socket?.close(); - this.socket = null; - this.clearPending(reason); + await this.pendingConnect; + } catch (err) { + this.client.stop(); + this.client = null; this.updateStatus("disconnected"); - throw error; + throw err; } } disconnect() { - if (!this.socket) { + if (!this.client) { return; } - this.socket.close(); - this.socket = null; - this.lastChallenge = null; - this.clearPending(new Error("Gateway disconnected.")); + this.manualDisconnect = true; + this.client.stop(); + this.client = null; + this.clearConnectPromise(); this.updateStatus("disconnected"); logger.info("Gateway disconnected."); } @@ -146,16 +139,16 @@ export class GatewayClient { if (!method.trim()) { throw new Error("Gateway method is required."); } - if (!this.socket || this.status !== "connected") { + if (!this.client || !this.client.connected) { throw new Error("Gateway is not connected."); } - const payload = await this.sendRequest(method, params); + const payload = await this.client.request<T>(method, params); return payload as T; } - getLastChallenge() { - return this.lastChallenge; + getLastHello() { + return this.lastHello; } private updateStatus(status: GatewayStatus) { @@ -163,148 +156,9 @@ export class GatewayClient { this.statusHandlers.forEach((handler) => handler(status)); } - private async waitForOpen(socket: WebSocket) { - if (socket.readyState === WebSocket.OPEN) { - return; - } - - await new Promise<void>((resolve, reject) => { - const handleOpen = () => { - cleanup(); - resolve(); - }; - - const handleError = () => { - cleanup(); - reject(new Error("Gateway connection failed.")); - }; - - const handleClose = () => { - cleanup(); - reject(new Error("Gateway closed before handshake.")); - }; - - const cleanup = () => { - socket.removeEventListener("open", handleOpen); - socket.removeEventListener("error", handleError); - socket.removeEventListener("close", handleClose); - }; - - socket.addEventListener("open", handleOpen); - socket.addEventListener("error", handleError); - socket.addEventListener("close", handleClose); - }); - } - - private async waitForChallenge(timeoutMs: number) { - if (this.lastChallenge !== null) { - return this.lastChallenge; - } - return await new Promise<unknown | null>((resolve) => { - const timeoutId = setTimeout(() => { - resolve(null); - }, timeoutMs); - - const unsubscribe = this.onEvent((event) => { - if (event.event !== "connect.challenge") { - return; - } - clearTimeout(timeoutId); - unsubscribe(); - resolve(event.payload ?? null); - }); - }); - } - - private async sendRequest(method: string, params: unknown) { - if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { - throw new Error("Gateway socket is not open."); - } - - const id = crypto.randomUUID(); - const frame: ReqFrame = { type: "req", id, method, params }; - - const payload = await new Promise<unknown>((resolve, reject) => { - const timeoutId = setTimeout(() => { - this.pending.delete(id); - reject(new Error(`Gateway request timed out: ${method}`)); - }, 20000); - - this.pending.set(id, { resolve, reject, timeoutId }); - - this.socket?.send(JSON.stringify(frame)); - }); - - return payload; - } - - private handleMessage(data: unknown) { - if (typeof data !== "string") { - return; - } - - let parsed: GatewayFrame | null = null; - - try { - parsed = JSON.parse(data) as GatewayFrame; - } catch { - logger.error("Failed to parse gateway frame."); - return; - } - - if (parsed.type === "event") { - if (parsed.event === "connect.challenge") { - this.lastChallenge = parsed.payload ?? null; - } - this.eventHandlers.forEach((handler) => handler(parsed)); - return; - } - - if (parsed.type === "res") { - this.handleResponse(parsed); - return; - } - } - - private handleResponse(frame: ResFrame) { - const pending = this.pending.get(frame.id); - if (!pending) { - return; - } - - this.pending.delete(frame.id); - clearTimeout(pending.timeoutId); - - if (frame.ok) { - pending.resolve(frame.payload); - return; - } - - if (frame.error) { - pending.reject(new GatewayResponseError(frame.error)); - return; - } - - pending.reject(new Error("Gateway request failed.")); - } - - private handleClose() { - if (!this.socket) { - return; - } - - this.socket = null; - this.lastChallenge = null; - this.clearPending(new Error("Gateway disconnected.")); - this.updateStatus("disconnected"); - logger.info("Gateway socket closed."); - } - - private clearPending(error: Error) { - this.pending.forEach((pending) => { - clearTimeout(pending.timeoutId); - pending.reject(error); - }); - this.pending.clear(); + private clearConnectPromise() { + this.pendingConnect = null; + this.resolveConnect = null; + this.rejectConnect = null; } } diff --git a/src/lib/gateway/agentConfig.ts b/src/lib/gateway/agentConfig.ts new file mode 100644 index 00000000..a251265b --- /dev/null +++ b/src/lib/gateway/agentConfig.ts @@ -0,0 +1,256 @@ +import { GatewayResponseError, type GatewayClient } from "@/lib/gateway/GatewayClient"; +import { + readConfigAgentList, + upsertConfigAgentEntry, + writeConfigAgentList, + type ConfigAgentEntry, +} from "@/lib/agents/configList"; +import type { + AgentHeartbeat, + AgentHeartbeatResult, + AgentHeartbeatUpdatePayload, +} from "@/lib/gateway/heartbeat"; + +export type GatewayConfigSnapshot = { + config?: Record<string, unknown>; + hash?: string; + exists?: boolean; +}; + +type HeartbeatBlock = Record<string, unknown> | null | undefined; + +const DEFAULT_EVERY = "30m"; +const DEFAULT_TARGET = "last"; +const DEFAULT_ACK_MAX_CHARS = 300; + +const isRecord = (value: unknown): value is Record<string, unknown> => + Boolean(value && typeof value === "object" && !Array.isArray(value)); + +const coerceString = (value: unknown) => (typeof value === "string" ? value : undefined); +const coerceBoolean = (value: unknown) => + typeof value === "boolean" ? value : undefined; +const coerceNumber = (value: unknown) => + typeof value === "number" && Number.isFinite(value) ? value : undefined; + +const coerceActiveHours = (value: unknown) => { + if (!isRecord(value)) return undefined; + const start = coerceString(value.start); + const end = coerceString(value.end); + if (!start || !end) return undefined; + return { start, end }; +}; + +const mergeHeartbeat = (defaults: HeartbeatBlock, override: HeartbeatBlock) => { + const merged = { + ...(defaults ?? {}), + ...(override ?? {}), + } as Record<string, unknown>; + if (override && typeof override === "object" && "activeHours" in override) { + merged.activeHours = (override as Record<string, unknown>).activeHours; + } else if (defaults && typeof defaults === "object" && "activeHours" in defaults) { + merged.activeHours = (defaults as Record<string, unknown>).activeHours; + } + return merged; +}; + +const normalizeHeartbeat = ( + defaults: HeartbeatBlock, + override: HeartbeatBlock +): AgentHeartbeatResult => { + const resolved = mergeHeartbeat(defaults, override); + const every = coerceString(resolved.every) ?? DEFAULT_EVERY; + const target = coerceString(resolved.target) ?? DEFAULT_TARGET; + const includeReasoning = coerceBoolean(resolved.includeReasoning) ?? false; + const ackMaxChars = coerceNumber(resolved.ackMaxChars) ?? DEFAULT_ACK_MAX_CHARS; + const activeHours = coerceActiveHours(resolved.activeHours) ?? null; + return { + heartbeat: { + every, + target, + includeReasoning, + ackMaxChars, + activeHours, + }, + hasOverride: Boolean(override && typeof override === "object"), + }; +}; + +const readHeartbeatDefaults = (config: Record<string, unknown>): HeartbeatBlock => { + const agents = isRecord(config.agents) ? config.agents : null; + const defaults = agents && isRecord(agents.defaults) ? agents.defaults : null; + return (defaults?.heartbeat ?? null) as HeartbeatBlock; +}; + +const buildHeartbeatOverride = (payload: AgentHeartbeat): Record<string, unknown> => { + const nextHeartbeat: Record<string, unknown> = { + every: payload.every, + target: payload.target, + includeReasoning: payload.includeReasoning, + }; + if (payload.ackMaxChars !== undefined && payload.ackMaxChars !== null) { + nextHeartbeat.ackMaxChars = payload.ackMaxChars; + } + if (payload.activeHours) { + nextHeartbeat.activeHours = { + start: payload.activeHours.start, + end: payload.activeHours.end, + }; + } + return nextHeartbeat; +}; + +export const resolveHeartbeatSettings = ( + config: Record<string, unknown>, + agentId: string +): AgentHeartbeatResult => { + const list = readConfigAgentList(config); + const entry = list.find((item) => item.id === agentId) ?? null; + const defaults = readHeartbeatDefaults(config); + const override = + entry && typeof entry === "object" + ? ((entry as Record<string, unknown>).heartbeat as HeartbeatBlock) + : null; + return normalizeHeartbeat(defaults, override); +}; + +const shouldRetryConfigPatch = (err: unknown) => { + if (!(err instanceof GatewayResponseError)) return false; + return /re-run config\.get|config changed since last load/i.test(err.message); +}; + +const applyGatewayConfigPatch = async (params: { + client: GatewayClient; + patch: Record<string, unknown>; + baseHash?: string | null; + exists?: boolean; + sessionKey?: string; + attempt?: number; +}): Promise<void> => { + const attempt = params.attempt ?? 0; + const requiresBaseHash = params.exists !== false; + const baseHash = requiresBaseHash ? params.baseHash?.trim() : undefined; + if (requiresBaseHash && !baseHash) { + throw new Error("Gateway config hash unavailable; re-run config.get."); + } + const payload: Record<string, unknown> = { + raw: JSON.stringify(params.patch, null, 2), + }; + if (baseHash) payload.baseHash = baseHash; + if (params.sessionKey) payload.sessionKey = params.sessionKey; + try { + await params.client.call("config.patch", payload); + } catch (err) { + if (attempt < 1 && shouldRetryConfigPatch(err)) { + const snapshot = await params.client.call<GatewayConfigSnapshot>("config.get", {}); + return applyGatewayConfigPatch({ + ...params, + baseHash: snapshot.hash ?? undefined, + exists: snapshot.exists, + attempt: attempt + 1, + }); + } + throw err; + } +}; + +export const renameGatewayAgent = async (params: { + client: GatewayClient; + agentId: string; + name: string; + sessionKey?: string; +}) => { + const trimmed = params.name.trim(); + if (!trimmed) { + throw new Error("Agent name is required."); + } + const snapshot = await params.client.call<GatewayConfigSnapshot>("config.get", {}); + const baseConfig = isRecord(snapshot.config) ? snapshot.config : {}; + const list = readConfigAgentList(baseConfig); + const { list: nextList, entry } = upsertConfigAgentEntry( + list, + params.agentId, + (entry: ConfigAgentEntry) => ({ + ...entry, + name: trimmed, + })); + const patch = { agents: { list: nextList } }; + await applyGatewayConfigPatch({ + client: params.client, + patch, + baseHash: snapshot.hash ?? undefined, + exists: snapshot.exists, + sessionKey: params.sessionKey, + }); + return entry; +}; + +export const deleteGatewayAgent = async (params: { + client: GatewayClient; + agentId: string; + sessionKey?: string; +}) => { + const snapshot = await params.client.call<GatewayConfigSnapshot>("config.get", {}); + const baseConfig = isRecord(snapshot.config) ? snapshot.config : {}; + const list = readConfigAgentList(baseConfig); + const nextList = list.filter((entry) => entry.id !== params.agentId); + const bindings = Array.isArray(baseConfig.bindings) ? baseConfig.bindings : []; + const nextBindings = bindings.filter((binding) => { + if (!binding || typeof binding !== "object") return true; + const agentId = (binding as Record<string, unknown>).agentId; + return agentId !== params.agentId; + }); + const patch: Record<string, unknown> = {}; + if (nextList.length !== list.length) { + patch.agents = { list: nextList }; + } + if (nextBindings.length !== bindings.length) { + patch.bindings = nextBindings; + } + if (Object.keys(patch).length === 0) { + return { removed: false, removedBindings: 0 }; + } + await applyGatewayConfigPatch({ + client: params.client, + patch, + baseHash: snapshot.hash ?? undefined, + exists: snapshot.exists, + sessionKey: params.sessionKey, + }); + return { + removed: nextList.length !== list.length, + removedBindings: bindings.length - nextBindings.length, + }; +}; + +export const updateGatewayHeartbeat = async (params: { + client: GatewayClient; + agentId: string; + payload: AgentHeartbeatUpdatePayload; + sessionKey?: string; +}): Promise<AgentHeartbeatResult> => { + const snapshot = await params.client.call<GatewayConfigSnapshot>("config.get", {}); + const baseConfig = isRecord(snapshot.config) ? snapshot.config : {}; + const list = readConfigAgentList(baseConfig); + const { list: nextList } = upsertConfigAgentEntry( + list, + params.agentId, + (entry: ConfigAgentEntry) => { + const next = { ...entry }; + if (params.payload.override) { + next.heartbeat = buildHeartbeatOverride(params.payload.heartbeat); + } else if ("heartbeat" in next) { + delete next.heartbeat; + } + return next; + }); + const patch = { agents: { list: nextList } }; + await applyGatewayConfigPatch({ + client: params.client, + patch, + baseHash: snapshot.hash ?? undefined, + exists: snapshot.exists, + sessionKey: params.sessionKey, + }); + const nextConfig = writeConfigAgentList(baseConfig, nextList); + return resolveHeartbeatSettings(nextConfig, params.agentId); +}; diff --git a/src/lib/gateway/frames.ts b/src/lib/gateway/frames.ts index 6de514c9..f7a25958 100644 --- a/src/lib/gateway/frames.ts +++ b/src/lib/gateway/frames.ts @@ -19,12 +19,25 @@ export type ResFrame = { }; }; +export type GatewayStateVersion = { + presence: number; + health: number; +}; + export type EventFrame = { type: "event"; event: string; - payload: unknown; + payload?: unknown; seq?: number; - stateVersion?: number; + stateVersion?: GatewayStateVersion; }; export type GatewayFrame = ReqFrame | ResFrame | EventFrame; + +export const parseGatewayFrame = (raw: string): GatewayFrame | null => { + try { + return JSON.parse(raw) as GatewayFrame; + } catch { + return null; + } +}; diff --git a/src/lib/gateway/heartbeat.ts b/src/lib/gateway/heartbeat.ts new file mode 100644 index 00000000..8b7c3603 --- /dev/null +++ b/src/lib/gateway/heartbeat.ts @@ -0,0 +1,22 @@ +export type AgentHeartbeatActiveHours = { + start: string; + end: string; +}; + +export type AgentHeartbeat = { + every: string; + target: string; + includeReasoning: boolean; + ackMaxChars?: number | null; + activeHours?: AgentHeartbeatActiveHours | null; +}; + +export type AgentHeartbeatResult = { + heartbeat: AgentHeartbeat; + hasOverride: boolean; +}; + +export type AgentHeartbeatUpdatePayload = { + override: boolean; + heartbeat: AgentHeartbeat; +}; diff --git a/src/lib/gateway/models.ts b/src/lib/gateway/models.ts new file mode 100644 index 00000000..68b04262 --- /dev/null +++ b/src/lib/gateway/models.ts @@ -0,0 +1,7 @@ +export type GatewayModelChoice = { + id: string; + name: string; + provider: string; + contextWindow?: number; + reasoning?: boolean; +}; diff --git a/src/lib/gateway/openclaw/GatewayBrowserClient.ts b/src/lib/gateway/openclaw/GatewayBrowserClient.ts new file mode 100644 index 00000000..06035d9c --- /dev/null +++ b/src/lib/gateway/openclaw/GatewayBrowserClient.ts @@ -0,0 +1,293 @@ +import { generateUUID } from "./uuid"; +import { + GATEWAY_CLIENT_MODES, + GATEWAY_CLIENT_NAMES, + type GatewayClientMode, + type GatewayClientName, +} from "./client-info"; +import { buildDeviceAuthPayload } from "./device-auth-payload"; +import { loadOrCreateDeviceIdentity, signDevicePayload } from "./device-identity"; +import { clearDeviceAuthToken, loadDeviceAuthToken, storeDeviceAuthToken } from "./device-auth"; + +export type GatewayEventFrame = { + type: "event"; + event: string; + payload?: unknown; + seq?: number; + stateVersion?: { presence: number; health: number }; +}; + +export type GatewayResponseFrame = { + type: "res"; + id: string; + ok: boolean; + payload?: unknown; + error?: { code: string; message: string; details?: unknown }; +}; + +export type GatewayHelloOk = { + type: "hello-ok"; + protocol: number; + features?: { methods?: string[]; events?: string[] }; + snapshot?: unknown; + auth?: { + deviceToken?: string; + role?: string; + scopes?: string[]; + issuedAtMs?: number; + }; + policy?: { tickIntervalMs?: number }; +}; + +type Pending = { + resolve: (value: unknown) => void; + reject: (err: unknown) => void; +}; + +export type GatewayBrowserClientOptions = { + url: string; + token?: string; + password?: string; + clientName?: GatewayClientName; + clientVersion?: string; + platform?: string; + mode?: GatewayClientMode; + instanceId?: string; + onHello?: (hello: GatewayHelloOk) => void; + onEvent?: (evt: GatewayEventFrame) => void; + onClose?: (info: { code: number; reason: string }) => void; + onGap?: (info: { expected: number; received: number }) => void; +}; + +const CONNECT_FAILED_CLOSE_CODE = 4008; + +export class GatewayBrowserClient { + private ws: WebSocket | null = null; + private pending = new Map<string, Pending>(); + private closed = false; + private lastSeq: number | null = null; + private connectNonce: string | null = null; + private connectSent = false; + private connectTimer: number | null = null; + private backoffMs = 800; + + constructor(private opts: GatewayBrowserClientOptions) {} + + start() { + this.closed = false; + this.connect(); + } + + stop() { + this.closed = true; + this.ws?.close(); + this.ws = null; + this.flushPending(new Error("gateway client stopped")); + } + + get connected() { + return this.ws?.readyState === WebSocket.OPEN; + } + + private connect() { + if (this.closed) return; + this.ws = new WebSocket(this.opts.url); + this.ws.onopen = () => this.queueConnect(); + this.ws.onmessage = (ev) => this.handleMessage(String(ev.data ?? "")); + this.ws.onclose = (ev) => { + const reason = String(ev.reason ?? ""); + this.ws = null; + this.flushPending(new Error(`gateway closed (${ev.code}): ${reason}`)); + this.opts.onClose?.({ code: ev.code, reason }); + this.scheduleReconnect(); + }; + this.ws.onerror = () => { + // ignored; close handler will fire + }; + } + + private scheduleReconnect() { + if (this.closed) return; + const delay = this.backoffMs; + this.backoffMs = Math.min(this.backoffMs * 1.7, 15_000); + window.setTimeout(() => this.connect(), delay); + } + + private flushPending(err: Error) { + for (const [, p] of this.pending) p.reject(err); + this.pending.clear(); + } + + private async sendConnect() { + if (this.connectSent) return; + this.connectSent = true; + if (this.connectTimer !== null) { + window.clearTimeout(this.connectTimer); + this.connectTimer = null; + } + + const isSecureContext = typeof crypto !== "undefined" && !!crypto.subtle; + + const scopes = ["operator.admin", "operator.approvals", "operator.pairing"]; + const role = "operator"; + let deviceIdentity: Awaited<ReturnType<typeof loadOrCreateDeviceIdentity>> | null = null; + let canFallbackToShared = false; + let authToken = this.opts.token; + + if (isSecureContext) { + deviceIdentity = await loadOrCreateDeviceIdentity(); + const storedToken = loadDeviceAuthToken({ + deviceId: deviceIdentity.deviceId, + role, + })?.token; + authToken = storedToken ?? this.opts.token; + canFallbackToShared = Boolean(storedToken && this.opts.token); + } + const auth = + authToken || this.opts.password + ? { + token: authToken, + password: this.opts.password, + } + : undefined; + + let device: + | { + id: string; + publicKey: string; + signature: string; + signedAt: number; + nonce: string | undefined; + } + | undefined; + + if (isSecureContext && deviceIdentity) { + const signedAtMs = Date.now(); + const nonce = this.connectNonce ?? undefined; + const payload = buildDeviceAuthPayload({ + deviceId: deviceIdentity.deviceId, + clientId: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.CONTROL_UI, + clientMode: this.opts.mode ?? GATEWAY_CLIENT_MODES.WEBCHAT, + role, + scopes, + signedAtMs, + token: authToken ?? null, + nonce, + }); + const signature = await signDevicePayload(deviceIdentity.privateKey, payload); + device = { + id: deviceIdentity.deviceId, + publicKey: deviceIdentity.publicKey, + signature, + signedAt: signedAtMs, + nonce, + }; + } + const params = { + minProtocol: 3, + maxProtocol: 3, + client: { + id: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.CONTROL_UI, + version: this.opts.clientVersion ?? "dev", + platform: this.opts.platform ?? navigator.platform ?? "web", + mode: this.opts.mode ?? GATEWAY_CLIENT_MODES.WEBCHAT, + instanceId: this.opts.instanceId, + }, + role, + scopes, + device, + caps: [], + auth, + userAgent: navigator.userAgent, + locale: navigator.language, + }; + + void this.request<GatewayHelloOk>("connect", params) + .then((hello) => { + if (hello?.auth?.deviceToken && deviceIdentity) { + storeDeviceAuthToken({ + deviceId: deviceIdentity.deviceId, + role: hello.auth.role ?? role, + token: hello.auth.deviceToken, + scopes: hello.auth.scopes ?? [], + }); + } + this.backoffMs = 800; + this.opts.onHello?.(hello); + }) + .catch(() => { + if (canFallbackToShared && deviceIdentity) { + clearDeviceAuthToken({ deviceId: deviceIdentity.deviceId, role }); + } + this.ws?.close(CONNECT_FAILED_CLOSE_CODE, "connect failed"); + }); + } + + private handleMessage(raw: string) { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return; + } + + const frame = parsed as { type?: unknown }; + if (frame.type === "event") { + const evt = parsed as GatewayEventFrame; + if (evt.event === "connect.challenge") { + const payload = evt.payload as { nonce?: unknown } | undefined; + const nonce = payload && typeof payload.nonce === "string" ? payload.nonce : null; + if (nonce) { + this.connectNonce = nonce; + void this.sendConnect(); + } + return; + } + const seq = typeof evt.seq === "number" ? evt.seq : null; + if (seq !== null) { + if (this.lastSeq !== null && seq > this.lastSeq + 1) { + this.opts.onGap?.({ expected: this.lastSeq + 1, received: seq }); + } + this.lastSeq = seq; + } + try { + this.opts.onEvent?.(evt); + } catch (err) { + console.error("[gateway] event handler error:", err); + } + return; + } + + if (frame.type === "res") { + const res = parsed as GatewayResponseFrame; + const pending = this.pending.get(res.id); + if (!pending) return; + this.pending.delete(res.id); + if (res.ok) pending.resolve(res.payload); + else pending.reject(new Error(res.error?.message ?? "request failed")); + return; + } + } + + request<T = unknown>(method: string, params?: unknown): Promise<T> { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + return Promise.reject(new Error("gateway not connected")); + } + const id = generateUUID(); + const frame = { type: "req", id, method, params }; + const p = new Promise<T>((resolve, reject) => { + this.pending.set(id, { resolve: (v) => resolve(v as T), reject }); + }); + this.ws.send(JSON.stringify(frame)); + return p; + } + + private queueConnect() { + this.connectNonce = null; + this.connectSent = false; + if (this.connectTimer !== null) window.clearTimeout(this.connectTimer); + this.connectTimer = window.setTimeout(() => { + void this.sendConnect(); + }, 750); + } +} diff --git a/src/lib/gateway/openclaw/client-info.ts b/src/lib/gateway/openclaw/client-info.ts new file mode 100644 index 00000000..658d9cf0 --- /dev/null +++ b/src/lib/gateway/openclaw/client-info.ts @@ -0,0 +1,65 @@ +export const GATEWAY_CLIENT_IDS = { + WEBCHAT_UI: "webchat-ui", + CONTROL_UI: "openclaw-control-ui", + WEBCHAT: "webchat", + CLI: "cli", + GATEWAY_CLIENT: "gateway-client", + MACOS_APP: "openclaw-macos", + IOS_APP: "openclaw-ios", + ANDROID_APP: "openclaw-android", + NODE_HOST: "node-host", + TEST: "test", + FINGERPRINT: "fingerprint", + PROBE: "openclaw-probe", +} as const; + +export type GatewayClientId = (typeof GATEWAY_CLIENT_IDS)[keyof typeof GATEWAY_CLIENT_IDS]; + +export const GATEWAY_CLIENT_NAMES = GATEWAY_CLIENT_IDS; +export type GatewayClientName = GatewayClientId; + +export const GATEWAY_CLIENT_MODES = { + WEBCHAT: "webchat", + CLI: "cli", + UI: "ui", + BACKEND: "backend", + NODE: "node", + PROBE: "probe", + TEST: "test", +} as const; + +export type GatewayClientMode = (typeof GATEWAY_CLIENT_MODES)[keyof typeof GATEWAY_CLIENT_MODES]; + +export type GatewayClientInfo = { + id: GatewayClientId; + displayName?: string; + version: string; + platform: string; + deviceFamily?: string; + modelIdentifier?: string; + mode: GatewayClientMode; + instanceId?: string; +}; + +const GATEWAY_CLIENT_ID_SET = new Set<GatewayClientId>(Object.values(GATEWAY_CLIENT_IDS)); +const GATEWAY_CLIENT_MODE_SET = new Set<GatewayClientMode>(Object.values(GATEWAY_CLIENT_MODES)); + +export function normalizeGatewayClientId(raw?: string | null): GatewayClientId | undefined { + const normalized = raw?.trim().toLowerCase(); + if (!normalized) return undefined; + return GATEWAY_CLIENT_ID_SET.has(normalized as GatewayClientId) + ? (normalized as GatewayClientId) + : undefined; +} + +export function normalizeGatewayClientName(raw?: string | null): GatewayClientName | undefined { + return normalizeGatewayClientId(raw); +} + +export function normalizeGatewayClientMode(raw?: string | null): GatewayClientMode | undefined { + const normalized = raw?.trim().toLowerCase(); + if (!normalized) return undefined; + return GATEWAY_CLIENT_MODE_SET.has(normalized as GatewayClientMode) + ? (normalized as GatewayClientMode) + : undefined; +} diff --git a/src/lib/gateway/openclaw/device-auth-payload.ts b/src/lib/gateway/openclaw/device-auth-payload.ts new file mode 100644 index 00000000..9a70444c --- /dev/null +++ b/src/lib/gateway/openclaw/device-auth-payload.ts @@ -0,0 +1,31 @@ +export type DeviceAuthPayloadParams = { + deviceId: string; + clientId: string; + clientMode: string; + role: string; + scopes: string[]; + signedAtMs: number; + token?: string | null; + nonce?: string | null; + version?: "v1" | "v2"; +}; + +export function buildDeviceAuthPayload(params: DeviceAuthPayloadParams): string { + const version = params.version ?? (params.nonce ? "v2" : "v1"); + const scopes = params.scopes.join(","); + const token = params.token ?? ""; + const base = [ + version, + params.deviceId, + params.clientId, + params.clientMode, + params.role, + scopes, + String(params.signedAtMs), + token, + ]; + if (version === "v2") { + base.push(params.nonce ?? ""); + } + return base.join("|"); +} diff --git a/src/lib/gateway/openclaw/device-auth.ts b/src/lib/gateway/openclaw/device-auth.ts new file mode 100644 index 00000000..e06d5061 --- /dev/null +++ b/src/lib/gateway/openclaw/device-auth.ts @@ -0,0 +1,99 @@ +export type DeviceAuthEntry = { + token: string; + role: string; + scopes: string[]; + updatedAtMs: number; +}; + +type DeviceAuthStore = { + version: 1; + deviceId: string; + tokens: Record<string, DeviceAuthEntry>; +}; + +const STORAGE_KEY = "openclaw.device.auth.v1"; + +function normalizeRole(role: string): string { + return role.trim(); +} + +function normalizeScopes(scopes: string[] | undefined): string[] { + if (!Array.isArray(scopes)) return []; + const out = new Set<string>(); + for (const scope of scopes) { + const trimmed = scope.trim(); + if (trimmed) out.add(trimmed); + } + return [...out].sort(); +} + +function readStore(): DeviceAuthStore | null { + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as DeviceAuthStore; + if (!parsed || parsed.version !== 1) return null; + if (!parsed.deviceId || typeof parsed.deviceId !== "string") return null; + if (!parsed.tokens || typeof parsed.tokens !== "object") return null; + return parsed; + } catch { + return null; + } +} + +function writeStore(store: DeviceAuthStore) { + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(store)); + } catch { + // best-effort + } +} + +export function loadDeviceAuthToken(params: { + deviceId: string; + role: string; +}): DeviceAuthEntry | null { + const store = readStore(); + if (!store || store.deviceId !== params.deviceId) return null; + const role = normalizeRole(params.role); + const entry = store.tokens[role]; + if (!entry || typeof entry.token !== "string") return null; + return entry; +} + +export function storeDeviceAuthToken(params: { + deviceId: string; + role: string; + token: string; + scopes?: string[]; +}): DeviceAuthEntry { + const role = normalizeRole(params.role); + const next: DeviceAuthStore = { + version: 1, + deviceId: params.deviceId, + tokens: {}, + }; + const existing = readStore(); + if (existing && existing.deviceId === params.deviceId) { + next.tokens = { ...existing.tokens }; + } + const entry: DeviceAuthEntry = { + token: params.token, + role, + scopes: normalizeScopes(params.scopes), + updatedAtMs: Date.now(), + }; + next.tokens[role] = entry; + writeStore(next); + return entry; +} + +export function clearDeviceAuthToken(params: { deviceId: string; role: string }) { + const store = readStore(); + if (!store || store.deviceId !== params.deviceId) return; + const role = normalizeRole(params.role); + if (!store.tokens[role]) return; + const next = { ...store, tokens: { ...store.tokens } }; + delete next.tokens[role]; + writeStore(next); +} diff --git a/src/lib/gateway/openclaw/device-identity.ts b/src/lib/gateway/openclaw/device-identity.ts new file mode 100644 index 00000000..38918eae --- /dev/null +++ b/src/lib/gateway/openclaw/device-identity.ts @@ -0,0 +1,108 @@ +import { getPublicKeyAsync, signAsync, utils } from "@noble/ed25519"; + +type StoredIdentity = { + version: 1; + deviceId: string; + publicKey: string; + privateKey: string; + createdAtMs: number; +}; + +export type DeviceIdentity = { + deviceId: string; + publicKey: string; + privateKey: string; +}; + +const STORAGE_KEY = "openclaw-device-identity-v1"; + +function base64UrlEncode(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/g, ""); +} + +function base64UrlDecode(input: string): Uint8Array { + const normalized = input.replaceAll("-", "+").replaceAll("_", "/"); + const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4); + const binary = atob(padded); + const out = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i); + return out; +} + +function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +async function fingerprintPublicKey(publicKey: Uint8Array): Promise<string> { + const hash = await crypto.subtle.digest("SHA-256", new Uint8Array(publicKey)); + return bytesToHex(new Uint8Array(hash)); +} + +async function generateIdentity(): Promise<DeviceIdentity> { + const privateKey = utils.randomSecretKey(); + const publicKey = await getPublicKeyAsync(privateKey); + const deviceId = await fingerprintPublicKey(publicKey); + return { + deviceId, + publicKey: base64UrlEncode(publicKey), + privateKey: base64UrlEncode(privateKey), + }; +} + +export async function loadOrCreateDeviceIdentity(): Promise<DeviceIdentity> { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw) { + const parsed = JSON.parse(raw) as StoredIdentity; + if ( + parsed?.version === 1 && + typeof parsed.deviceId === "string" && + typeof parsed.publicKey === "string" && + typeof parsed.privateKey === "string" + ) { + const derivedId = await fingerprintPublicKey(base64UrlDecode(parsed.publicKey)); + if (derivedId !== parsed.deviceId) { + const updated: StoredIdentity = { + ...parsed, + deviceId: derivedId, + }; + localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); + return { + deviceId: derivedId, + publicKey: parsed.publicKey, + privateKey: parsed.privateKey, + }; + } + return { + deviceId: parsed.deviceId, + publicKey: parsed.publicKey, + privateKey: parsed.privateKey, + }; + } + } + } catch { + // fall through to regenerate + } + + const identity = await generateIdentity(); + const stored: StoredIdentity = { + version: 1, + deviceId: identity.deviceId, + publicKey: identity.publicKey, + privateKey: identity.privateKey, + createdAtMs: Date.now(), + }; + localStorage.setItem(STORAGE_KEY, JSON.stringify(stored)); + return identity; +} + +export async function signDevicePayload(privateKeyBase64Url: string, payload: string) { + const key = base64UrlDecode(privateKeyBase64Url); + const data = new TextEncoder().encode(payload); + const sig = await signAsync(data, key); + return base64UrlEncode(sig); +} diff --git a/src/lib/gateway/openclaw/uuid.ts b/src/lib/gateway/openclaw/uuid.ts new file mode 100644 index 00000000..03a03ad9 --- /dev/null +++ b/src/lib/gateway/openclaw/uuid.ts @@ -0,0 +1,51 @@ +export type CryptoLike = { + randomUUID?: (() => string) | undefined; + getRandomValues?: ((array: Uint8Array) => Uint8Array) | undefined; +}; + +let warnedWeakCrypto = false; + +function uuidFromBytes(bytes: Uint8Array): string { + bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4 + bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1 + + let hex = ""; + for (let i = 0; i < bytes.length; i++) { + hex += bytes[i]!.toString(16).padStart(2, "0"); + } + + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice( + 16, + 20 + )}-${hex.slice(20)}`; +} + +function weakRandomBytes(): Uint8Array { + const bytes = new Uint8Array(16); + const now = Date.now(); + for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256); + bytes[0] ^= now & 0xff; + bytes[1] ^= (now >>> 8) & 0xff; + bytes[2] ^= (now >>> 16) & 0xff; + bytes[3] ^= (now >>> 24) & 0xff; + return bytes; +} + +function warnWeakCryptoOnce() { + if (warnedWeakCrypto) return; + warnedWeakCrypto = true; + console.warn("[uuid] crypto API missing; falling back to weak randomness"); +} + +export function generateUUID(cryptoLike: CryptoLike | null = globalThis.crypto): string { + if (cryptoLike && typeof cryptoLike.randomUUID === "function") return cryptoLike.randomUUID(); + + if (cryptoLike && typeof cryptoLike.getRandomValues === "function") { + const bytes = new Uint8Array(16); + cryptoLike.getRandomValues(bytes); + return uuidFromBytes(bytes); + } + + warnWeakCryptoOnce(); + return uuidFromBytes(weakRandomBytes()); +} diff --git a/src/lib/gateway/sessionKeys.ts b/src/lib/gateway/sessionKeys.ts new file mode 100644 index 00000000..32d81daa --- /dev/null +++ b/src/lib/gateway/sessionKeys.ts @@ -0,0 +1,22 @@ +export const buildAgentMainSessionKey = (agentId: string, mainKey: string) => { + const trimmedAgent = agentId.trim(); + const trimmedKey = mainKey.trim() || "main"; + return `agent:${trimmedAgent}:${trimmedKey}`; +}; + +export const buildAgentStudioSessionKey = (agentId: string, sessionId: string) => { + const trimmedAgent = agentId.trim(); + const trimmedSession = sessionId.trim(); + return `agent:${trimmedAgent}:studio:${trimmedSession}`; +}; + +export const parseAgentIdFromSessionKey = (sessionKey: string): string | null => { + const match = sessionKey.match(/^agent:([^:]+):/); + return match ? match[1] : null; +}; + +export const isSameSessionKey = (a: string, b: string) => { + const left = a.trim(); + const right = b.trim(); + return left.length > 0 && left === right; +}; diff --git a/src/lib/gateway/tools.ts b/src/lib/gateway/tools.ts new file mode 100644 index 00000000..f0c88770 --- /dev/null +++ b/src/lib/gateway/tools.ts @@ -0,0 +1,41 @@ +export type GatewayToolInvokePayload = { + tool: string; + sessionKey?: string; + action?: string; + args?: Record<string, unknown>; + dryRun?: boolean; +}; + +export type GatewayToolInvokeResult = + | { ok: true; result: unknown } + | { ok: false; error: string }; + +export const invokeGatewayTool = async ( + payload: GatewayToolInvokePayload +): Promise<GatewayToolInvokeResult> => { + const res = await fetch("/api/gateway/tools", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const text = await res.text(); + let data: unknown = null; + if (text) { + try { + data = JSON.parse(text); + } catch { + data = null; + } + } + if (!res.ok) { + const error = + data && typeof data === "object" && "error" in data && typeof data.error === "string" + ? data.error + : `Request failed with status ${res.status}.`; + return { ok: false, error }; + } + if (!data || typeof data !== "object" || !("ok" in data)) { + return { ok: false, error: "Invalid gateway tool response." }; + } + return data as GatewayToolInvokeResult; +}; diff --git a/src/lib/gateway/url.ts b/src/lib/gateway/url.ts new file mode 100644 index 00000000..9152fb53 --- /dev/null +++ b/src/lib/gateway/url.ts @@ -0,0 +1,11 @@ +export const toGatewayHttpUrl = (wsUrl: string): string => { + const trimmed = wsUrl.trim(); + if (!trimmed) return trimmed; + if (trimmed.startsWith("wss://")) { + return `https://${trimmed.slice("wss://".length)}`; + } + if (trimmed.startsWith("ws://")) { + return `http://${trimmed.slice("ws://".length)}`; + } + return trimmed; +}; diff --git a/src/lib/gateway/useGatewayConnection.ts b/src/lib/gateway/useGatewayConnection.ts index ab1ef641..f64ebc01 100644 --- a/src/lib/gateway/useGatewayConnection.ts +++ b/src/lib/gateway/useGatewayConnection.ts @@ -7,6 +7,7 @@ import { GatewayStatus, } from "./GatewayClient"; import { env } from "@/lib/env"; +import { getStudioSettingsCoordinator } from "@/lib/studio/client"; const DEFAULT_GATEWAY_URL = env.NEXT_PUBLIC_GATEWAY_URL ?? "ws://127.0.0.1:18789"; const formatGatewayError = (error: unknown) => { @@ -34,43 +35,43 @@ export type GatewayConnectionState = { export const useGatewayConnection = (): GatewayConnectionState => { const [client] = useState(() => new GatewayClient()); + const [settingsCoordinator] = useState(() => getStudioSettingsCoordinator()); const didAutoConnect = useRef(false); const [gatewayUrl, setGatewayUrl] = useState(DEFAULT_GATEWAY_URL); const [token, setToken] = useState(""); const [status, setStatus] = useState<GatewayStatus>("disconnected"); const [error, setError] = useState<string | null>(null); - const [configLoaded, setConfigLoaded] = useState(false); + const [settingsLoaded, setSettingsLoaded] = useState(false); useEffect(() => { let cancelled = false; - const loadConfig = async () => { + const loadSettings = async () => { try { - const res = await fetch("/api/gateway", { cache: "no-store" }); - if (!res.ok) return; - const data = (await res.json()) as { gatewayUrl?: string; token?: string }; + const settings = await settingsCoordinator.loadSettings(); + const gateway = settings?.gateway ?? null; if (cancelled) return; - if (typeof data.gatewayUrl === "string" && data.gatewayUrl.trim()) { - setGatewayUrl(data.gatewayUrl); + if (gateway?.url) { + setGatewayUrl(gateway.url); } - if (typeof data.token === "string") { - setToken(data.token); + if (typeof gateway?.token === "string") { + setToken(gateway.token); } } catch { if (!cancelled) { - setError("Failed to load gateway config."); + setError("Failed to load gateway settings."); } } finally { if (!cancelled) { - setConfigLoaded(true); + setSettingsLoaded(true); } } }; - void loadConfig(); + void loadSettings(); return () => { cancelled = true; }; - }, []); + }, [settingsCoordinator]); useEffect(() => { return client.onStatus((nextStatus) => { @@ -83,9 +84,10 @@ export const useGatewayConnection = (): GatewayConnectionState => { useEffect(() => { return () => { + void settingsCoordinator.flushPending(); client.disconnect(); }; - }, [client]); + }, [client, settingsCoordinator]); const connect = useCallback(async () => { setError(null); @@ -98,11 +100,24 @@ export const useGatewayConnection = (): GatewayConnectionState => { useEffect(() => { if (didAutoConnect.current) return; - if (!configLoaded) return; + if (!settingsLoaded) return; if (!gatewayUrl.trim()) return; didAutoConnect.current = true; void connect(); - }, [connect, configLoaded, gatewayUrl]); + }, [connect, gatewayUrl, settingsLoaded]); + + useEffect(() => { + if (!settingsLoaded) return; + settingsCoordinator.schedulePatch( + { + gateway: { + url: gatewayUrl.trim(), + token, + }, + }, + 400 + ); + }, [gatewayUrl, settingsCoordinator, settingsLoaded, token]); const disconnect = useCallback(() => { setError(null); diff --git a/src/lib/ids/agentId.ts b/src/lib/ids/agentId.ts index abc5618b..ba55cea9 100644 --- a/src/lib/ids/agentId.ts +++ b/src/lib/ids/agentId.ts @@ -1,36 +1,7 @@ -const normalizeSegment = (value: string) => - value - .toLowerCase() - .replace(/[^a-z0-9_-]+/g, "-") - .replace(/-+/g, "-") - .replace(/^-+|-+$/g, ""); - -export const generateAgentId = ({ - projectSlug, - tileName, -}: { - projectSlug: string; - tileName: string; -}): string => { - const projectSegment = normalizeSegment(projectSlug); - const tileSegment = normalizeSegment(tileName); - if (!tileSegment) { - throw new Error("Agent name produced an empty id."); +export const generateAgentId = ({ agentKey }: { agentKey: string }): string => { + const trimmed = agentKey.trim(); + if (!trimmed) { + throw new Error("Agent key is required to generate an agent id."); } - const base = projectSegment ? `proj-${projectSegment}-${tileSegment}` : `proj-${tileSegment}`; - if (base.length <= 64) { - return base; - } - const suffix = `-${tileSegment}`; - const maxProjectLength = Math.max(0, 64 - ("proj".length + suffix.length + 1)); - const trimmedProject = projectSegment.slice(0, maxProjectLength); - const trimmedBase = trimmedProject - ? `proj-${trimmedProject}-${tileSegment}` - : `proj-${tileSegment}`; - if (trimmedBase.length <= 64) { - return trimmedBase; - } - const maxTileLength = Math.max(1, 64 - ("proj".length + 1)); - const trimmedTile = tileSegment.slice(0, maxTileLength); - return `proj-${trimmedTile}`; + return `agent-${trimmed}`; }; diff --git a/src/lib/ids/slugify.ts b/src/lib/ids/slugify.ts index 224dd912..1fc1e27d 100644 --- a/src/lib/ids/slugify.ts +++ b/src/lib/ids/slugify.ts @@ -1,11 +1,11 @@ -export const slugifyProjectName = (name: string): string => { +export const slugifyName = (name: string): string => { const slug = name .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); if (!slug) { - throw new Error("Workspace name produced an empty folder name."); + throw new Error("Name produced an empty folder name."); } return slug; }; diff --git a/src/lib/path-suggestions/types.ts b/src/lib/path-suggestions/types.ts new file mode 100644 index 00000000..53dec778 --- /dev/null +++ b/src/lib/path-suggestions/types.ts @@ -0,0 +1,12 @@ +export type PathAutocompleteEntry = { + name: string; + fullPath: string; + displayPath: string; + isDirectory: boolean; +}; + +export type PathAutocompleteResult = { + query: string; + directory: string; + entries: PathAutocompleteEntry[]; +}; diff --git a/src/lib/projects/agentWorkspace.ts b/src/lib/projects/agentWorkspace.ts deleted file mode 100644 index 54bca749..00000000 --- a/src/lib/projects/agentWorkspace.ts +++ /dev/null @@ -1,10 +0,0 @@ -import os from "node:os"; -import path from "node:path"; - -export const resolveProjectAgentsRoot = (projectId: string) => { - return path.join(os.homedir(), ".clawdbot", "agent-canvas", "workspaces", projectId, "agents"); -}; - -export const resolveAgentWorkspaceDir = (projectId: string, agentId: string) => { - return path.join(resolveProjectAgentsRoot(projectId), agentId); -}; diff --git a/src/lib/projects/client.ts b/src/lib/projects/client.ts deleted file mode 100644 index de5f76ac..00000000 --- a/src/lib/projects/client.ts +++ /dev/null @@ -1,138 +0,0 @@ -import type { - ProjectCreatePayload, - ProjectCreateResult, - ProjectDeleteResult, - ProjectDiscordChannelCreatePayload, - ProjectDiscordChannelCreateResult, - ProjectOpenPayload, - ProjectOpenResult, - ProjectTileCreatePayload, - ProjectTileCreateResult, - ProjectTileDeleteResult, - ProjectTileRenamePayload, - ProjectTileRenameResult, - ProjectTileUpdatePayload, - ProjectTileUpdateResult, - ProjectTileWorkspaceFilesResult, - ProjectTileWorkspaceFilesUpdatePayload, - ProjectsStore, -} from "./types"; -import { fetchJson } from "@/lib/http"; - -export const fetchProjectsStore = async (): Promise<ProjectsStore> => { - return fetchJson<ProjectsStore>("/api/projects", { cache: "no-store" }); -}; - -export const createProject = async ( - payload: ProjectCreatePayload -): Promise<ProjectCreateResult> => { - return fetchJson<ProjectCreateResult>("/api/projects", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); -}; - -export const openProject = async ( - payload: ProjectOpenPayload -): Promise<ProjectOpenResult> => { - return fetchJson<ProjectOpenResult>("/api/projects/open", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); -}; - -export const saveProjectsStore = async (store: ProjectsStore): Promise<ProjectsStore> => { - return fetchJson<ProjectsStore>("/api/projects", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(store), - }); -}; - -export const deleteProject = async (projectId: string): Promise<ProjectDeleteResult> => { - return fetchJson<ProjectDeleteResult>(`/api/projects/${projectId}`, { - method: "DELETE", - }); -}; - -export const createProjectDiscordChannel = async ( - projectId: string, - payload: ProjectDiscordChannelCreatePayload -): Promise<ProjectDiscordChannelCreateResult> => { - return fetchJson<ProjectDiscordChannelCreateResult>(`/api/projects/${projectId}/discord`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); -}; - -export const createProjectTile = async ( - projectId: string, - payload: ProjectTileCreatePayload -): Promise<ProjectTileCreateResult> => { - return fetchJson<ProjectTileCreateResult>(`/api/projects/${projectId}/tiles`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); -}; - -export const deleteProjectTile = async ( - projectId: string, - tileId: string -): Promise<ProjectTileDeleteResult> => { - return fetchJson<ProjectTileDeleteResult>(`/api/projects/${projectId}/tiles/${tileId}`, { - method: "DELETE", - }); -}; - -export const renameProjectTile = async ( - projectId: string, - tileId: string, - payload: ProjectTileRenamePayload -): Promise<ProjectTileRenameResult> => { - return fetchJson<ProjectTileRenameResult>(`/api/projects/${projectId}/tiles/${tileId}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); -}; - -export const updateProjectTile = async ( - projectId: string, - tileId: string, - payload: ProjectTileUpdatePayload -): Promise<ProjectTileUpdateResult> => { - return fetchJson<ProjectTileUpdateResult>(`/api/projects/${projectId}/tiles/${tileId}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); -}; - -export const fetchProjectTileWorkspaceFiles = async ( - projectId: string, - tileId: string -): Promise<ProjectTileWorkspaceFilesResult> => { - return fetchJson<ProjectTileWorkspaceFilesResult>( - `/api/projects/${projectId}/tiles/${tileId}/workspace-files`, - { cache: "no-store" } - ); -}; - -export const updateProjectTileWorkspaceFiles = async ( - projectId: string, - tileId: string, - payload: ProjectTileWorkspaceFilesUpdatePayload -): Promise<ProjectTileWorkspaceFilesResult> => { - return fetchJson<ProjectTileWorkspaceFilesResult>( - `/api/projects/${projectId}/tiles/${tileId}/workspace-files`, - { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - } - ); -}; diff --git a/src/lib/projects/fs.server.ts b/src/lib/projects/fs.server.ts deleted file mode 100644 index 197446ea..00000000 --- a/src/lib/projects/fs.server.ts +++ /dev/null @@ -1,44 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -import { resolveAgentWorkspaceDir } from "./agentWorkspace"; - -export const resolveHomePath = (inputPath: string) => { - if (inputPath === "~") { - return os.homedir(); - } - if (inputPath.startsWith("~/")) { - return path.join(os.homedir(), inputPath.slice(2)); - } - return inputPath; -}; - -export const resolveClawdbotStateDir = () => { - const stateDirRaw = process.env.CLAWDBOT_STATE_DIR ?? "~/.clawdbot"; - return resolveHomePath(stateDirRaw); -}; - -export const resolveAgentStateDir = (agentId: string) => { - return path.join(resolveClawdbotStateDir(), "agents", agentId); -}; - -export const deleteDirIfExists = (targetPath: string, label: string, warnings: string[]) => { - if (!fs.existsSync(targetPath)) { - warnings.push(`${label} not found at ${targetPath}.`); - return; - } - const stat = fs.statSync(targetPath); - if (!stat.isDirectory()) { - throw new Error(`${label} path is not a directory: ${targetPath}`); - } - fs.rmSync(targetPath, { recursive: true, force: false }); -}; - -export const deleteAgentArtifacts = (projectId: string, agentId: string, warnings: string[]) => { - const workspaceDir = resolveAgentWorkspaceDir(projectId, agentId); - deleteDirIfExists(workspaceDir, "Agent workspace", warnings); - - const agentDir = resolveAgentStateDir(agentId); - deleteDirIfExists(agentDir, "Agent state", warnings); -}; diff --git a/src/lib/projects/types.ts b/src/lib/projects/types.ts deleted file mode 100644 index ead495ce..00000000 --- a/src/lib/projects/types.ts +++ /dev/null @@ -1,115 +0,0 @@ -export type ProjectTileRole = "coding" | "research" | "marketing"; - -export type ProjectTile = { - id: string; - name: string; - agentId: string; - role: ProjectTileRole; - sessionKey: string; - model?: string | null; - thinkingLevel?: string | null; - avatarSeed?: string | null; - position: { x: number; y: number }; - size: { width: number; height: number }; -}; - -export type Project = { - id: string; - name: string; - repoPath: string; - createdAt: number; - updatedAt: number; - tiles: ProjectTile[]; -}; - -export type ProjectsStore = { - version: 2; - activeProjectId: string | null; - projects: Project[]; -}; - -export type ProjectCreatePayload = { - name: string; -}; - -export type ProjectCreateResult = { - store: ProjectsStore; - warnings: string[]; -}; - -export type ProjectOpenPayload = { - path: string; -}; - -export type ProjectOpenResult = { - store: ProjectsStore; - warnings: string[]; -}; - -export type ProjectDeleteResult = { - store: ProjectsStore; - warnings: string[]; -}; - -export type ProjectDiscordChannelCreatePayload = { - guildId?: string; - agentId: string; - agentName: string; -}; - -export type ProjectDiscordChannelCreateResult = { - channelId: string; - channelName: string; - guildId: string; - agentId: string; - warnings: string[]; -}; - -export type ProjectTileCreatePayload = { - name: string; - role: ProjectTileRole; -}; - -export type ProjectTileCreateResult = { - store: ProjectsStore; - tile: ProjectTile; - warnings: string[]; -}; - -export type ProjectTileDeleteResult = { - store: ProjectsStore; - warnings: string[]; -}; - -export type ProjectTileRenamePayload = { - name: string; -}; - -export type ProjectTileRenameResult = { - store: ProjectsStore; - warnings: string[]; -}; - -export type ProjectTileUpdatePayload = { - name?: string; - avatarSeed?: string | null; -}; - -export type ProjectTileUpdateResult = { - store: ProjectsStore; - warnings: string[]; -}; - -export type ProjectTileWorkspaceFile = { - name: string; - content: string; - exists: boolean; -}; - -export type ProjectTileWorkspaceFilesResult = { - files: ProjectTileWorkspaceFile[]; -}; - -export type ProjectTileWorkspaceFilesUpdatePayload = { - files: Array<{ name: string; content: string }>; -}; diff --git a/src/lib/studio/client.ts b/src/lib/studio/client.ts new file mode 100644 index 00000000..fd29d694 --- /dev/null +++ b/src/lib/studio/client.ts @@ -0,0 +1,39 @@ +import { fetchJson } from "@/lib/http"; +import type { StudioSettingsPatch } from "@/lib/studio/settings"; +import { + StudioSettingsCoordinator, + type StudioSettingsResponse, +} from "@/lib/studio/coordinator"; + +let studioSettingsCoordinator: StudioSettingsCoordinator | null = null; + +export const fetchStudioSettings = async (): Promise<StudioSettingsResponse> => { + return fetchJson<StudioSettingsResponse>("/api/studio", { cache: "no-store" }); +}; + +export const updateStudioSettings = async ( + patch: StudioSettingsPatch +): Promise<StudioSettingsResponse> => { + return fetchJson<StudioSettingsResponse>("/api/studio", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(patch), + }); +}; + +export const getStudioSettingsCoordinator = (): StudioSettingsCoordinator => { + if (studioSettingsCoordinator) { + return studioSettingsCoordinator; + } + studioSettingsCoordinator = new StudioSettingsCoordinator({ + fetchSettings: fetchStudioSettings, + updateSettings: updateStudioSettings, + }); + return studioSettingsCoordinator; +}; + +export const resetStudioSettingsCoordinator = () => { + if (!studioSettingsCoordinator) return; + studioSettingsCoordinator.dispose(); + studioSettingsCoordinator = null; +}; diff --git a/src/lib/studio/coordinator.ts b/src/lib/studio/coordinator.ts new file mode 100644 index 00000000..edeb08a6 --- /dev/null +++ b/src/lib/studio/coordinator.ts @@ -0,0 +1,129 @@ +import type { + StudioFocusedPreference, + StudioSettings, + StudioSettingsPatch, +} from "@/lib/studio/settings"; + +export type StudioSettingsResponse = { settings: StudioSettings }; + +type FocusedPatch = Record<string, Partial<StudioFocusedPreference> | null>; +type SessionsPatch = Record<string, string | null>; + +export type StudioSettingsCoordinatorTransport = { + fetchSettings: () => Promise<StudioSettingsResponse>; + updateSettings: (patch: StudioSettingsPatch) => Promise<StudioSettingsResponse>; +}; + +const mergeFocusedPatch = ( + current: FocusedPatch | undefined, + next: FocusedPatch | undefined +): FocusedPatch | undefined => { + if (!current && !next) return undefined; + return { + ...(current ?? {}), + ...(next ?? {}), + }; +}; + +const mergeSessionsPatch = ( + current: SessionsPatch | undefined, + next: SessionsPatch | undefined +): SessionsPatch | undefined => { + if (!current && !next) return undefined; + return { + ...(current ?? {}), + ...(next ?? {}), + }; +}; + +const mergeStudioPatch = ( + current: StudioSettingsPatch | null, + next: StudioSettingsPatch +): StudioSettingsPatch => { + if (!current) { + return { + ...(next.gateway !== undefined ? { gateway: next.gateway } : {}), + ...(next.focused ? { focused: { ...next.focused } } : {}), + ...(next.sessions ? { sessions: { ...next.sessions } } : {}), + }; + } + const focused = mergeFocusedPatch(current.focused, next.focused); + const sessions = mergeSessionsPatch(current.sessions, next.sessions); + return { + ...(next.gateway !== undefined + ? { gateway: next.gateway } + : current.gateway !== undefined + ? { gateway: current.gateway } + : {}), + ...(focused ? { focused } : {}), + ...(sessions ? { sessions } : {}), + }; +}; + +export class StudioSettingsCoordinator { + private pendingPatch: StudioSettingsPatch | null = null; + private timer: ReturnType<typeof setTimeout> | null = null; + private queue: Promise<void> = Promise.resolve(); + private disposed = false; + + constructor( + private readonly transport: StudioSettingsCoordinatorTransport, + private readonly defaultDebounceMs: number = 350 + ) {} + + async loadSettings(): Promise<StudioSettings | null> { + const result = await this.transport.fetchSettings(); + return result.settings ?? null; + } + + schedulePatch(patch: StudioSettingsPatch, debounceMs: number = this.defaultDebounceMs): void { + if (this.disposed) return; + this.pendingPatch = mergeStudioPatch(this.pendingPatch, patch); + if (this.timer) { + clearTimeout(this.timer); + } + this.timer = setTimeout(() => { + this.timer = null; + void this.flushPending().catch((err) => { + console.error("Failed to flush pending studio settings patch.", err); + }); + }, debounceMs); + } + + async applyPatchNow(patch: StudioSettingsPatch): Promise<void> { + if (this.disposed) return; + this.pendingPatch = mergeStudioPatch(this.pendingPatch, patch); + await this.flushPending(); + } + + async flushPending(): Promise<void> { + if (this.disposed) { + return this.queue; + } + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + const patch = this.pendingPatch; + this.pendingPatch = null; + if (!patch) { + return this.queue; + } + const write = this.queue.then(async () => { + await this.transport.updateSettings(patch); + }); + this.queue = write.catch((err) => { + console.error("Failed to persist studio settings patch.", err); + }); + return write; + } + + dispose(): void { + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + this.pendingPatch = null; + this.disposed = true; + } +} diff --git a/src/lib/studio/settings.server.ts b/src/lib/studio/settings.server.ts new file mode 100644 index 00000000..e27ad6b7 --- /dev/null +++ b/src/lib/studio/settings.server.ts @@ -0,0 +1,43 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { resolveStateDir } from "@/lib/clawdbot/paths"; +import { + defaultStudioSettings, + mergeStudioSettings, + normalizeStudioSettings, + type StudioSettings, + type StudioSettingsPatch, +} from "@/lib/studio/settings"; + +const SETTINGS_DIRNAME = "openclaw-studio"; +const SETTINGS_FILENAME = "settings.json"; + +const resolveSettingsPath = () => + path.join(resolveStateDir(), SETTINGS_DIRNAME, SETTINGS_FILENAME); + +export const loadStudioSettings = (): StudioSettings => { + const settingsPath = resolveSettingsPath(); + if (!fs.existsSync(settingsPath)) { + return defaultStudioSettings(); + } + const raw = fs.readFileSync(settingsPath, "utf8"); + const parsed = JSON.parse(raw) as unknown; + return normalizeStudioSettings(parsed); +}; + +export const saveStudioSettings = (next: StudioSettings) => { + const settingsPath = resolveSettingsPath(); + const dir = path.dirname(settingsPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(settingsPath, JSON.stringify(next, null, 2), "utf8"); +}; + +export const applyStudioSettingsPatch = (patch: StudioSettingsPatch): StudioSettings => { + const current = loadStudioSettings(); + const next = mergeStudioSettings(current, patch); + saveStudioSettings(next); + return next; +}; diff --git a/src/lib/studio/settings.ts b/src/lib/studio/settings.ts new file mode 100644 index 00000000..8f862104 --- /dev/null +++ b/src/lib/studio/settings.ts @@ -0,0 +1,204 @@ +export type StudioGatewaySettings = { + url: string; + token: string; +}; + +export type FocusFilter = "all" | "needs-attention" | "running" | "idle"; +export type StudioViewMode = "focused"; + +export type StudioFocusedPreference = { + mode: StudioViewMode; + selectedAgentId: string | null; + filter: FocusFilter; +}; + +export type StudioSettings = { + version: 1; + gateway: StudioGatewaySettings | null; + focused: Record<string, StudioFocusedPreference>; + sessions: Record<string, string>; +}; + +export type StudioSettingsPatch = { + gateway?: StudioGatewaySettings | null; + focused?: Record<string, Partial<StudioFocusedPreference> | null>; + sessions?: Record<string, string | null>; +}; + +const SETTINGS_VERSION = 1 as const; + +const isRecord = (value: unknown): value is Record<string, unknown> => + Boolean(value && typeof value === "object"); + +const coerceString = (value: unknown) => (typeof value === "string" ? value.trim() : ""); + +const normalizeGatewayKey = (value: unknown) => { + const key = coerceString(value); + return key ? key : null; +}; + +const normalizeFocusFilter = ( + value: unknown, + fallback: FocusFilter = "all" +): FocusFilter => { + const filter = coerceString(value); + if ( + filter === "all" || + filter === "needs-attention" || + filter === "running" || + filter === "idle" + ) { + return filter; + } + return fallback; +}; + +const normalizeViewMode = ( + value: unknown, + fallback: StudioViewMode = "focused" +): StudioViewMode => { + const mode = coerceString(value); + if (mode === "focused") { + return mode; + } + return fallback; +}; + +const normalizeSelectedAgentId = (value: unknown, fallback: string | null = null) => { + if (value === null) return null; + if (typeof value !== "string") return fallback; + const trimmed = value.trim(); + return trimmed ? trimmed : null; +}; + +const defaultFocusedPreference = (): StudioFocusedPreference => ({ + mode: "focused", + selectedAgentId: null, + filter: "all", +}); + +const normalizeFocusedPreference = ( + value: unknown, + fallback: StudioFocusedPreference = defaultFocusedPreference() +): StudioFocusedPreference => { + if (!isRecord(value)) return fallback; + return { + mode: normalizeViewMode(value.mode, fallback.mode), + selectedAgentId: normalizeSelectedAgentId( + value.selectedAgentId, + fallback.selectedAgentId + ), + filter: normalizeFocusFilter(value.filter, fallback.filter), + }; +}; + +const normalizeGatewaySettings = (value: unknown): StudioGatewaySettings | null => { + if (!isRecord(value)) return null; + const url = coerceString(value.url); + if (!url) return null; + const token = coerceString(value.token); + return { url, token }; +}; + +const normalizeFocused = (value: unknown): Record<string, StudioFocusedPreference> => { + if (!isRecord(value)) return {}; + const focused: Record<string, StudioFocusedPreference> = {}; + for (const [gatewayKeyRaw, focusedRaw] of Object.entries(value)) { + const gatewayKey = normalizeGatewayKey(gatewayKeyRaw); + if (!gatewayKey) continue; + focused[gatewayKey] = normalizeFocusedPreference(focusedRaw); + } + return focused; +}; + +const normalizeSessions = (value: unknown): Record<string, string> => { + if (!isRecord(value)) return {}; + const sessions: Record<string, string> = {}; + for (const [gatewayKeyRaw, sessionIdRaw] of Object.entries(value)) { + const gatewayKey = normalizeGatewayKey(gatewayKeyRaw); + if (!gatewayKey) continue; + const sessionId = coerceString(sessionIdRaw); + if (!sessionId) continue; + sessions[gatewayKey] = sessionId; + } + return sessions; +}; + +export const defaultStudioSettings = (): StudioSettings => ({ + version: SETTINGS_VERSION, + gateway: null, + focused: {}, + sessions: {}, +}); + +export const normalizeStudioSettings = (raw: unknown): StudioSettings => { + if (!isRecord(raw)) return defaultStudioSettings(); + const gateway = normalizeGatewaySettings(raw.gateway); + const focused = normalizeFocused(raw.focused); + const sessions = normalizeSessions(raw.sessions); + return { + version: SETTINGS_VERSION, + gateway, + focused, + sessions, + }; +}; + +export const mergeStudioSettings = ( + current: StudioSettings, + patch: StudioSettingsPatch +): StudioSettings => { + const nextGateway = + patch.gateway === undefined ? current.gateway : normalizeGatewaySettings(patch.gateway); + const nextFocused = { ...current.focused }; + const nextSessions = { ...current.sessions }; + if (patch.focused) { + for (const [keyRaw, value] of Object.entries(patch.focused)) { + const key = normalizeGatewayKey(keyRaw); + if (!key) continue; + if (value === null) { + delete nextFocused[key]; + continue; + } + const fallback = nextFocused[key] ?? defaultFocusedPreference(); + nextFocused[key] = normalizeFocusedPreference(value, fallback); + } + } + if (patch.sessions) { + for (const [keyRaw, value] of Object.entries(patch.sessions)) { + const key = normalizeGatewayKey(keyRaw); + if (!key) continue; + if (value === null) { + delete nextSessions[key]; + continue; + } + const sessionId = coerceString(value); + if (!sessionId) continue; + nextSessions[key] = sessionId; + } + } + return { + version: SETTINGS_VERSION, + gateway: nextGateway ?? null, + focused: nextFocused, + sessions: nextSessions, + }; +}; + +export const resolveFocusedPreference = ( + settings: StudioSettings, + gatewayUrl: string +): StudioFocusedPreference | null => { + const key = normalizeGatewayKey(gatewayUrl); + if (!key) return null; + return settings.focused[key] ?? null; +}; + +export const resolveStudioSessionId = ( + settings: StudioSettings, + gatewayUrl: string +): string | null => { + const key = normalizeGatewayKey(gatewayUrl); + if (!key) return null; + return settings.sessions[key] ?? null; +}; diff --git a/src/lib/text/extractText.ts b/src/lib/text/extractText.ts deleted file mode 100644 index e73f6839..00000000 --- a/src/lib/text/extractText.ts +++ /dev/null @@ -1,96 +0,0 @@ -const ENVELOPE_PREFIX = /^\[([^\]]+)\]\s*/; -const ENVELOPE_CHANNELS = [ - "WebChat", - "WhatsApp", - "Telegram", - "Signal", - "Slack", - "Discord", - "iMessage", - "Teams", - "Matrix", - "Zalo", - "Zalo Personal", - "BlueBubbles", -]; - -const THINKING_TAG_RE = /<\s*\/?\s*think(?:ing)?\s*>/gi; -const THINKING_OPEN_RE = /<\s*think(?:ing)?\s*>/i; -const THINKING_CLOSE_RE = /<\s*\/\s*think(?:ing)?\s*>/i; - -const looksLikeEnvelopeHeader = (header: string): boolean => { - if (/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z\b/.test(header)) return true; - if (/\d{4}-\d{2}-\d{2} \d{2}:\d{2}\b/.test(header)) return true; - return ENVELOPE_CHANNELS.some((label) => header.startsWith(`${label} `)); -}; - -const stripEnvelope = (text: string): string => { - const match = text.match(ENVELOPE_PREFIX); - if (!match) return text; - const header = match[1] ?? ""; - if (!looksLikeEnvelopeHeader(header)) return text; - return text.slice(match[0].length); -}; - -const stripThinkingTags = (value: string): string => { - if (!value) return value; - const hasOpen = THINKING_OPEN_RE.test(value); - const hasClose = THINKING_CLOSE_RE.test(value); - if (!hasOpen && !hasClose) return value; - if (hasOpen !== hasClose) { - if (!hasOpen) return value.replace(THINKING_CLOSE_RE, "").trimStart(); - return value.replace(THINKING_OPEN_RE, "").trimStart(); - } - - if (!THINKING_TAG_RE.test(value)) return value; - THINKING_TAG_RE.lastIndex = 0; - - let result = ""; - let lastIndex = 0; - let inThinking = false; - for (const match of value.matchAll(THINKING_TAG_RE)) { - const idx = match.index ?? 0; - if (!inThinking) { - result += value.slice(lastIndex, idx); - } - const tag = match[0].toLowerCase(); - inThinking = !tag.includes("/"); - lastIndex = idx + match[0].length; - } - if (!inThinking) { - result += value.slice(lastIndex); - } - return result.trimStart(); -}; - -export const extractText = (message: unknown): string | null => { - if (!message || typeof message !== "object") { - return null; - } - const m = message as Record<string, unknown>; - const role = typeof m.role === "string" ? m.role : ""; - const content = m.content; - if (typeof content === "string") { - const processed = role === "assistant" ? stripThinkingTags(content) : stripEnvelope(content); - return processed; - } - if (Array.isArray(content)) { - const parts = content - .map((p) => { - const item = p as Record<string, unknown>; - if (item.type === "text" && typeof item.text === "string") return item.text; - return null; - }) - .filter((v): v is string => typeof v === "string"); - if (parts.length > 0) { - const joined = parts.join("\n"); - const processed = role === "assistant" ? stripThinkingTags(joined) : stripEnvelope(joined); - return processed; - } - } - if (typeof m.text === "string") { - const processed = role === "assistant" ? stripThinkingTags(m.text) : stripEnvelope(m.text); - return processed; - } - return null; -}; diff --git a/src/lib/text/extractThinking.ts b/src/lib/text/extractThinking.ts deleted file mode 100644 index 0afb8e3d..00000000 --- a/src/lib/text/extractThinking.ts +++ /dev/null @@ -1,67 +0,0 @@ -const THINKING_BLOCK_RE = - /<\s*think(?:ing)?\s*>([\s\S]*?)<\s*\/\s*think(?:ing)?\s*>/gi; -const TRACE_MARKDOWN_PREFIX = "[[trace]]"; - -const extractRawText = (message: unknown): string | null => { - if (!message || typeof message !== "object") return null; - const m = message as Record<string, unknown>; - const content = m.content; - if (typeof content === "string") return content; - if (Array.isArray(content)) { - const parts = content - .map((p) => { - const item = p as Record<string, unknown>; - if (item.type === "text" && typeof item.text === "string") return item.text; - return null; - }) - .filter((v): v is string => typeof v === "string"); - if (parts.length > 0) return parts.join("\n"); - } - if (typeof m.text === "string") return m.text; - return null; -}; - -export const extractThinking = (message: unknown): string | null => { - if (!message || typeof message !== "object") return null; - const m = message as Record<string, unknown>; - const content = m.content; - const parts: string[] = []; - if (Array.isArray(content)) { - for (const p of content) { - const item = p as Record<string, unknown>; - if (item.type === "thinking" && typeof item.thinking === "string") { - const cleaned = item.thinking.trim(); - if (cleaned) parts.push(cleaned); - } - } - } - if (parts.length > 0) return parts.join("\n"); - - const rawText = extractRawText(message); - if (!rawText) return null; - const matches = [...rawText.matchAll(THINKING_BLOCK_RE)]; - const extracted = matches - .map((match) => (match[1] ?? "").trim()) - .filter(Boolean); - return extracted.length > 0 ? extracted.join("\n") : null; -}; - -export const formatThinkingMarkdown = (text: string): string => { - const trimmed = text.trim(); - if (!trimmed) return ""; - const lines = trimmed - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean) - .map((line) => `_${line}_`); - if (lines.length === 0) return ""; - return `${TRACE_MARKDOWN_PREFIX}\n${lines.join("\n")}`; -}; - -export const isTraceMarkdown = (line: string): boolean => - line.startsWith(TRACE_MARKDOWN_PREFIX); - -export const stripTraceMarkdown = (line: string): string => { - if (!isTraceMarkdown(line)) return line; - return line.slice(TRACE_MARKDOWN_PREFIX.length).trimStart(); -}; diff --git a/src/lib/text/message-extract.ts b/src/lib/text/message-extract.ts new file mode 100644 index 00000000..a9cd7a16 --- /dev/null +++ b/src/lib/text/message-extract.ts @@ -0,0 +1,411 @@ +const ENVELOPE_PREFIX = /^\[([^\]]+)\]\s*/; +const ENVELOPE_CHANNELS = [ + "WebChat", + "WhatsApp", + "Telegram", + "Signal", + "Slack", + "Discord", + "iMessage", + "Teams", + "Matrix", + "Zalo", + "Zalo Personal", + "BlueBubbles", +]; + +const textCache = new WeakMap<object, string | null>(); +const thinkingCache = new WeakMap<object, string | null>(); + +const THINKING_TAG_RE = /<\s*\/?\s*(think(?:ing)?|analysis)\s*>/gi; +const THINKING_OPEN_RE = /<\s*(think(?:ing)?|analysis)\s*>/i; +const THINKING_CLOSE_RE = /<\s*\/\s*(think(?:ing)?|analysis)\s*>/i; + +const THINKING_BLOCK_RE = + /<\s*(think(?:ing)?|analysis)\s*>([\s\S]*?)<\s*\/\s*\1\s*>/gi; +const THINKING_STREAM_TAG_RE = /<\s*(\/?)\s*(?:think(?:ing)?|analysis|thought|antthinking)\s*>/gi; +const TRACE_MARKDOWN_PREFIX = "[[trace]]"; + +const TOOL_CALL_PREFIX = "[[tool]]"; +const TOOL_RESULT_PREFIX = "[[tool-result]]"; + +type ToolCallRecord = { + id?: string; + name?: string; + arguments?: unknown; +}; + +type ToolResultRecord = { + toolCallId?: string; + toolName?: string; + details?: Record<string, unknown> | null; + isError?: boolean; + text?: string | null; +}; + +const looksLikeEnvelopeHeader = (header: string): boolean => { + if (/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z\b/.test(header)) return true; + if (/\d{4}-\d{2}-\d{2} \d{2}:\d{2}\b/.test(header)) return true; + return ENVELOPE_CHANNELS.some((label) => header.startsWith(`${label} `)); +}; + +const stripEnvelope = (text: string): string => { + const match = text.match(ENVELOPE_PREFIX); + if (!match) return text; + const header = match[1] ?? ""; + if (!looksLikeEnvelopeHeader(header)) return text; + return text.slice(match[0].length); +}; + +const stripThinkingTagsFromAssistantText = (value: string): string => { + if (!value) return value; + const hasOpen = THINKING_OPEN_RE.test(value); + const hasClose = THINKING_CLOSE_RE.test(value); + if (!hasOpen && !hasClose) return value; + if (hasOpen !== hasClose) { + if (!hasOpen) return value.replace(THINKING_CLOSE_RE, "").trimStart(); + return value.replace(THINKING_OPEN_RE, "").trimStart(); + } + + if (!THINKING_TAG_RE.test(value)) return value; + THINKING_TAG_RE.lastIndex = 0; + + let result = ""; + let lastIndex = 0; + let inThinking = false; + for (const match of value.matchAll(THINKING_TAG_RE)) { + const idx = match.index ?? 0; + if (!inThinking) { + result += value.slice(lastIndex, idx); + } + const tag = match[0].toLowerCase(); + inThinking = !tag.includes("/"); + lastIndex = idx + match[0].length; + } + if (!inThinking) { + result += value.slice(lastIndex); + } + return result.trimStart(); +}; + +const extractRawText = (message: unknown): string | null => { + if (!message || typeof message !== "object") return null; + const m = message as Record<string, unknown>; + const content = m.content; + if (typeof content === "string") return content; + if (Array.isArray(content)) { + const parts = content + .map((p) => { + const item = p as Record<string, unknown>; + if (item.type === "text" && typeof item.text === "string") return item.text; + return null; + }) + .filter((v): v is string => typeof v === "string"); + if (parts.length > 0) return parts.join("\n"); + } + if (typeof m.text === "string") return m.text; + return null; +}; + +export const extractText = (message: unknown): string | null => { + if (!message || typeof message !== "object") { + return null; + } + const m = message as Record<string, unknown>; + const role = typeof m.role === "string" ? m.role : ""; + const content = m.content; + + const postProcess = (value: string): string => + role === "assistant" ? stripThinkingTagsFromAssistantText(value) : stripEnvelope(value); + + if (typeof content === "string") { + return postProcess(content); + } + + if (Array.isArray(content)) { + const parts = content + .map((p) => { + const item = p as Record<string, unknown>; + if (item.type === "text" && typeof item.text === "string") return item.text; + return null; + }) + .filter((v): v is string => typeof v === "string"); + + if (parts.length > 0) { + return postProcess(parts.join("\n")); + } + } + + if (typeof m.text === "string") { + return postProcess(m.text); + } + + return null; +}; + +export const extractTextCached = (message: unknown): string | null => { + if (!message || typeof message !== "object") return extractText(message); + const obj = message as object; + if (textCache.has(obj)) return textCache.get(obj) ?? null; + const value = extractText(message); + textCache.set(obj, value); + return value; +}; + +export const extractThinking = (message: unknown): string | null => { + if (!message || typeof message !== "object") return null; + const m = message as Record<string, unknown>; + const content = m.content; + const parts: string[] = []; + + const extractFromRecord = (record: Record<string, unknown>): string | null => { + const directKeys = ["thinking", "analysis", "reasoning"] as const; + for (const key of directKeys) { + const value = record[key]; + if (typeof value === "string") { + const cleaned = value.trim(); + if (cleaned) return cleaned; + } + if (value && typeof value === "object") { + const nested = value as Record<string, unknown>; + const nestedKeys = ["text", "content", "summary", "analysis", "reasoning", "thinking"] as const; + for (const nestedKey of nestedKeys) { + const nestedValue = nested[nestedKey]; + if (typeof nestedValue === "string") { + const cleaned = nestedValue.trim(); + if (cleaned) return cleaned; + } + } + } + } + return null; + }; + + if (Array.isArray(content)) { + for (const p of content) { + const item = p as Record<string, unknown>; + const type = typeof item.type === "string" ? item.type : ""; + if (type === "thinking" || type === "analysis" || type === "reasoning") { + const extracted = extractFromRecord(item); + if (extracted) { + parts.push(extracted); + } else if (typeof item.text === "string") { + const cleaned = item.text.trim(); + if (cleaned) parts.push(cleaned); + } + } else if (typeof item.thinking === "string") { + const cleaned = item.thinking.trim(); + if (cleaned) parts.push(cleaned); + } + } + } + if (parts.length > 0) return parts.join("\n"); + + const direct = extractFromRecord(m); + if (direct) return direct; + + const rawText = extractRawText(message); + if (!rawText) return null; + const matches = [...rawText.matchAll(THINKING_BLOCK_RE)]; + const extracted = matches + .map((match) => (match[2] ?? "").trim()) + .filter(Boolean); + return extracted.length > 0 ? extracted.join("\n") : null; +}; + +export const extractThinkingFromTaggedText = (text: string): string => { + if (!text) return ""; + let result = ""; + let lastIndex = 0; + let inThinking = false; + THINKING_STREAM_TAG_RE.lastIndex = 0; + for (const match of text.matchAll(THINKING_STREAM_TAG_RE)) { + const idx = match.index ?? 0; + if (inThinking) { + result += text.slice(lastIndex, idx); + } + const isClose = match[1] === "/"; + inThinking = !isClose; + lastIndex = idx + match[0].length; + } + return result.trim(); +}; + +export const extractThinkingFromTaggedStream = (text: string): string => { + if (!text) return ""; + const closed = extractThinkingFromTaggedText(text); + if (closed) return closed; + const openRe = /<\s*(?:think(?:ing)?|analysis|thought|antthinking)\s*>/gi; + const closeRe = /<\s*\/\s*(?:think(?:ing)?|analysis|thought|antthinking)\s*>/gi; + const openMatches = [...text.matchAll(openRe)]; + if (openMatches.length === 0) return ""; + const closeMatches = [...text.matchAll(closeRe)]; + const lastOpen = openMatches[openMatches.length - 1]; + const lastClose = closeMatches[closeMatches.length - 1]; + if (lastClose && (lastClose.index ?? -1) > (lastOpen.index ?? -1)) { + return closed; + } + const start = (lastOpen.index ?? 0) + lastOpen[0].length; + return text.slice(start).trim(); +}; + +export const extractThinkingCached = (message: unknown): string | null => { + if (!message || typeof message !== "object") return extractThinking(message); + const obj = message as object; + if (thinkingCache.has(obj)) return thinkingCache.get(obj) ?? null; + const value = extractThinking(message); + thinkingCache.set(obj, value); + return value; +}; + +export const formatThinkingMarkdown = (text: string): string => { + const trimmed = text.trim(); + if (!trimmed) return ""; + const lines = trimmed + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => `_${line}_`); + if (lines.length === 0) return ""; + return `${TRACE_MARKDOWN_PREFIX}\n${lines.join("\n")}`; +}; + +export const isTraceMarkdown = (line: string): boolean => + line.startsWith(TRACE_MARKDOWN_PREFIX); + +export const stripTraceMarkdown = (line: string): string => { + if (!isTraceMarkdown(line)) return line; + return line.slice(TRACE_MARKDOWN_PREFIX.length).trimStart(); +}; + +const formatJson = (value: unknown): string => { + if (value === null || value === undefined) return ""; + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean") return String(value); + try { + return JSON.stringify(value, null, 2); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to stringify tool args."; + console.warn(message); + return String(value); + } +}; + +const formatToolResultMeta = (details?: Record<string, unknown> | null, isError?: boolean) => { + const parts: string[] = []; + if (details && typeof details === "object") { + const status = details.status; + if (typeof status === "string" && status.trim()) { + parts.push(status.trim()); + } + const exitCode = details.exitCode; + if (typeof exitCode === "number") { + parts.push(`exit ${exitCode}`); + } + const durationMs = details.durationMs; + if (typeof durationMs === "number") { + parts.push(`${durationMs}ms`); + } + const cwd = details.cwd; + if (typeof cwd === "string" && cwd.trim()) { + parts.push(cwd.trim()); + } + } + if (isError) { + parts.push("error"); + } + return parts.length ? parts.join(" · ") : ""; +}; + +export const extractToolCalls = (message: unknown): ToolCallRecord[] => { + if (!message || typeof message !== "object") return []; + const content = (message as Record<string, unknown>).content; + if (!Array.isArray(content)) return []; + const calls: ToolCallRecord[] = []; + for (const item of content) { + if (!item || typeof item !== "object") continue; + const record = item as Record<string, unknown>; + if (record.type !== "toolCall") continue; + calls.push({ + id: typeof record.id === "string" ? record.id : undefined, + name: typeof record.name === "string" ? record.name : undefined, + arguments: record.arguments, + }); + } + return calls; +}; + +export const extractToolResult = (message: unknown): ToolResultRecord | null => { + if (!message || typeof message !== "object") return null; + const record = message as Record<string, unknown>; + const role = typeof record.role === "string" ? record.role : ""; + if (role !== "toolResult" && role !== "tool") return null; + const details = + record.details && typeof record.details === "object" + ? (record.details as Record<string, unknown>) + : null; + return { + toolCallId: typeof record.toolCallId === "string" ? record.toolCallId : undefined, + toolName: typeof record.toolName === "string" ? record.toolName : undefined, + details, + isError: typeof record.isError === "boolean" ? record.isError : undefined, + text: extractText(record), + }; +}; + +export const formatToolCallMarkdown = (call: ToolCallRecord): string => { + const name = call.name?.trim() || "tool"; + const suffix = call.id ? ` (${call.id})` : ""; + const args = formatJson(call.arguments).trim(); + if (!args) { + return `${TOOL_CALL_PREFIX} ${name}${suffix}`; + } + return `${TOOL_CALL_PREFIX} ${name}${suffix}\n\`\`\`json\n${args}\n\`\`\``; +}; + +export const formatToolResultMarkdown = (result: ToolResultRecord): string => { + const name = result.toolName?.trim() || "tool"; + const suffix = result.toolCallId ? ` (${result.toolCallId})` : ""; + const meta = formatToolResultMeta(result.details, result.isError); + const header = `${name}${suffix}`; + const bodyParts: string[] = []; + if (meta) { + bodyParts.push(meta); + } + const output = result.text?.trim(); + if (output) { + bodyParts.push(`\`\`\`text\n${output}\n\`\`\``); + } + return bodyParts.length === 0 + ? `${TOOL_RESULT_PREFIX} ${header}` + : `${TOOL_RESULT_PREFIX} ${header}\n${bodyParts.join("\n")}`; +}; + +export const extractToolLines = (message: unknown): string[] => { + const lines: string[] = []; + for (const call of extractToolCalls(message)) { + lines.push(formatToolCallMarkdown(call)); + } + const result = extractToolResult(message); + if (result) { + lines.push(formatToolResultMarkdown(result)); + } + return lines; +}; + +export const isToolMarkdown = (line: string): boolean => + line.startsWith(TOOL_CALL_PREFIX) || line.startsWith(TOOL_RESULT_PREFIX); + +export const parseToolMarkdown = ( + line: string +): { kind: "call" | "result"; label: string; body: string } => { + const kind = line.startsWith(TOOL_RESULT_PREFIX) ? "result" : "call"; + const prefix = kind === "result" ? TOOL_RESULT_PREFIX : TOOL_CALL_PREFIX; + const content = line.slice(prefix.length).trimStart(); + const [labelLine, ...rest] = content.split(/\r?\n/); + return { + kind, + label: labelLine?.trim() || (kind === "result" ? "Tool result" : "Tool call"), + body: rest.join("\n").trim(), + }; +}; diff --git a/src/lib/text/message-metadata.ts b/src/lib/text/message-metadata.ts new file mode 100644 index 00000000..3a7dae59 --- /dev/null +++ b/src/lib/text/message-metadata.ts @@ -0,0 +1,43 @@ +export type AgentInstructionParams = { + message: string; +}; + +export const buildAgentInstruction = ({ + message, +}: AgentInstructionParams): string => { + const trimmed = message.trim(); + if (!trimmed) return trimmed; + if (trimmed.startsWith("/")) return trimmed; + return trimmed; +}; + +const PROJECT_PROMPT_BLOCK_RE = /^(?:Project|Workspace) path:[\s\S]*?\n\s*\n/i; +const PROJECT_PROMPT_INLINE_RE = /^(?:Project|Workspace) path:[\s\S]*?memory_search\.\s*/i; +const RESET_PROMPT_RE = + /^A new session was started via \/new or \/reset[\s\S]*?reasoning\.\s*/i; +const MESSAGE_ID_RE = /\s*\[message_id:[^\]]+\]\s*/gi; +const UI_METADATA_PREFIX_RE = + /^(?:Project path:|Workspace path:|A new session was started via \/new or \/reset)/i; +const HEARTBEAT_PROMPT_RE = /^Read HEARTBEAT\.md if it exists\b/i; +const HEARTBEAT_PATH_RE = /Heartbeat file path:/i; + +export const stripUiMetadata = (text: string) => { + if (!text) return text; + let cleaned = text.replace(RESET_PROMPT_RE, ""); + const beforeProjectStrip = cleaned; + cleaned = cleaned.replace(PROJECT_PROMPT_INLINE_RE, ""); + if (cleaned === beforeProjectStrip) { + cleaned = cleaned.replace(PROJECT_PROMPT_BLOCK_RE, ""); + } + cleaned = cleaned.replace(MESSAGE_ID_RE, "").trim(); + return cleaned; +}; + +export const isHeartbeatPrompt = (text: string) => { + if (!text) return false; + const trimmed = text.trim(); + if (!trimmed) return false; + return HEARTBEAT_PROMPT_RE.test(trimmed) || HEARTBEAT_PATH_RE.test(trimmed); +}; + +export const isUiMetadataPrefix = (text: string) => UI_METADATA_PREFIX_RE.test(text); diff --git a/src/lib/text/summary.ts b/src/lib/text/summary.ts new file mode 100644 index 00000000..0c0cbffa --- /dev/null +++ b/src/lib/text/summary.ts @@ -0,0 +1,27 @@ +const SUMMARY_MARKER_RE = /summary\s*[:\-]\s*/gi; + +const splitLines = (text: string) => + text + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + +export const extractSummaryText = (text: string) => { + const trimmed = text.trim(); + if (!trimmed) return trimmed; + let lastIndex = -1; + let lastLength = 0; + for (const match of trimmed.matchAll(SUMMARY_MARKER_RE)) { + if (typeof match.index === "number") { + lastIndex = match.index; + lastLength = match[0].length; + } + } + if (lastIndex >= 0) { + const after = trimmed.slice(lastIndex + lastLength).trim(); + const afterLines = splitLines(after); + if (afterLines.length > 0) return afterLines[0]; + } + const lines = splitLines(trimmed); + return lines.length > 0 ? lines[lines.length - 1] : trimmed; +}; diff --git a/src/lib/tracing.ts b/src/lib/tracing.ts index be2fb191..ff1f50df 100644 --- a/src/lib/tracing.ts +++ b/src/lib/tracing.ts @@ -1,5 +1,5 @@ import { registerOTel } from "@vercel/otel"; export const registerTracing = () => { - registerOTel({ serviceName: "clawdbot-agent-ui" }); + registerOTel({ serviceName: "openclaw-studio" }); }; diff --git a/tests/e2e/agent-avatar.spec.ts b/tests/e2e/agent-avatar.spec.ts new file mode 100644 index 00000000..233678d1 --- /dev/null +++ b/tests/e2e/agent-avatar.spec.ts @@ -0,0 +1,34 @@ +import { expect, test } from "@playwright/test"; + +test.beforeEach(async ({ page }) => { + await page.route("**/api/studio", async (route, request) => { + if (request.method() === "PUT") { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + settings: { version: 1, gateway: null, focused: {}, sessions: {} }, + }), + }); + return; + } + if (request.method() !== "GET") { + await route.fallback(); + return; + } + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + settings: { version: 1, gateway: null, focused: {}, sessions: {} }, + }), + }); + }); +}); + +test("empty focused view shows zero agents when disconnected", async ({ page }) => { + await page.goto("/"); + + await expect(page.getByText("Agents (0)").first()).toBeVisible(); + await expect(page.locator("[data-agent-panel]")).toHaveCount(0); +}); diff --git a/tests/e2e/agent-inspect-panel.spec.ts b/tests/e2e/agent-inspect-panel.spec.ts new file mode 100644 index 00000000..d3f85987 --- /dev/null +++ b/tests/e2e/agent-inspect-panel.spec.ts @@ -0,0 +1,34 @@ +import { expect, test } from "@playwright/test"; + +test.beforeEach(async ({ page }) => { + await page.route("**/api/studio", async (route, request) => { + if (request.method() === "PUT") { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + settings: { version: 1, gateway: null, focused: {}, sessions: {} }, + }), + }); + return; + } + if (request.method() !== "GET") { + await route.fallback(); + return; + } + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + settings: { version: 1, gateway: null, focused: {}, sessions: {} }, + }), + }); + }); +}); + +test("connection panel reflects disconnected state", async ({ page }) => { + await page.goto("/"); + + await expect(page.getByText("Disconnected").first()).toBeVisible(); + await expect(page.getByRole("button", { name: "Connect", exact: true })).toBeEnabled(); +}); diff --git a/tests/e2e/agent-tile-avatar.spec.ts b/tests/e2e/agent-tile-avatar.spec.ts deleted file mode 100644 index c8be3702..00000000 --- a/tests/e2e/agent-tile-avatar.spec.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { expect, test } from "@playwright/test"; - -const store = { - version: 2, - activeProjectId: "project-1", - projects: [ - { - id: "project-1", - name: "Demo Workspace", - repoPath: "/Users/demo", - createdAt: 1, - updatedAt: 1, - tiles: [ - { - id: "tile-1", - name: "Agent A", - agentId: "agent-1", - role: "coding", - sessionKey: "agent:agent-1:main", - model: null, - thinkingLevel: "low", - position: { x: 400, y: 300 }, - size: { width: 420, height: 520 }, - }, - ], - }, - ], -}; - -test.beforeEach(async ({ page }) => { - await page.route("**/api/projects", async (route, request) => { - if (request.method() !== "GET" && request.method() !== "PUT") { - await route.fallback(); - return; - } - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify(store), - }); - }); -}); - -test("new agent tile shows avatar and input without transcript", async ({ page }) => { - await page.goto("/"); - - await expect(page.getByAltText("Avatar for Agent A")).toBeVisible(); - await expect(page.getByPlaceholder("Send a command")).toBeVisible(); - await expect(page.locator("[data-testid='agent-transcript']")).toHaveCount(0); - - await page.locator("[data-tile]").click({ force: true }); - await page.getByTestId("agent-options-toggle").dispatchEvent("click"); - await expect(page.getByText("Model")).toBeVisible(); - await expect(page.getByText("Thinking")).toBeVisible(); -}); diff --git a/tests/e2e/canvas-smoke.spec.ts b/tests/e2e/canvas-smoke.spec.ts deleted file mode 100644 index adb6382a..00000000 --- a/tests/e2e/canvas-smoke.spec.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { expect, test } from "@playwright/test"; - -test("loads canvas empty state", async ({ page }) => { - await page.route("**/api/projects", async (route, request) => { - if (request.method() !== "GET") { - await route.fallback(); - return; - } - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ version: 2, activeProjectId: null, projects: [] }), - }); - }); - await page.goto("/"); - await expect(page.getByText("Create a workspace to begin.")).toBeVisible(); -}); diff --git a/tests/e2e/canvas-zoom-pan.spec.ts b/tests/e2e/canvas-zoom-pan.spec.ts deleted file mode 100644 index a666cc95..00000000 --- a/tests/e2e/canvas-zoom-pan.spec.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { expect, test } from "@playwright/test"; - -const store = { - version: 2, - activeProjectId: "project-1", - projects: [ - { - id: "project-1", - name: "Demo Workspace", - repoPath: "/Users/demo", - createdAt: 1, - updatedAt: 1, - tiles: [ - { - id: "tile-1", - name: "Agent A", - agentId: "agent-1", - role: "coding", - sessionKey: "agent:agent-1:main", - model: null, - thinkingLevel: "low", - position: { x: 400, y: 300 }, - size: { width: 560, height: 440 }, - }, - ], - }, - ], -}; - -test.beforeEach(async ({ page }) => { - await page.route("**/api/projects", async (route, request) => { - if (request.method() !== "GET" && request.method() !== "PUT") { - await route.fallback(); - return; - } - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify(store), - }); - }); -}); - -test("wheel zoom updates zoom readout", async ({ page }) => { - await page.goto("/"); - - const pane = page.locator(".react-flow__pane"); - await expect(pane).toBeVisible(); - - const zoomReadout = page.locator("[data-zoom-readout]"); - await expect(zoomReadout).toBeVisible(); - const beforeText = (await zoomReadout.textContent()) ?? ""; - - const box = await pane.boundingBox(); - expect(box).not.toBeNull(); - const clientX = box!.x + box!.width / 2; - const clientY = box!.y + box!.height / 2; - - await page.dispatchEvent(".react-flow__pane", "wheel", { - deltaY: 240, - deltaMode: 0, - clientX, - clientY, - }); - - await expect(zoomReadout).not.toHaveText(beforeText); -}); - -test("wheel changes tile bounds on the canvas", async ({ page }) => { - await page.goto("/"); - - const pane = page.locator(".react-flow__pane"); - await expect(pane).toBeVisible(); - - const tile = page.locator("[data-tile]"); - await expect(tile).toBeVisible(); - - const beforeBox = await tile.boundingBox(); - expect(beforeBox).not.toBeNull(); - const beforeWidth = beforeBox!.width; - - const paneBox = await pane.boundingBox(); - expect(paneBox).not.toBeNull(); - const clientX = paneBox!.x + paneBox!.width / 2; - const clientY = paneBox!.y + paneBox!.height / 2; - - await page.dispatchEvent(".react-flow__pane", "wheel", { - deltaY: 240, - deltaMode: 0, - clientX, - clientY, - }); - - await page.waitForFunction( - ({ selector, previousWidth }) => { - const el = document.querySelector(selector); - if (!el) return false; - const rect = el.getBoundingClientRect(); - return Math.abs(rect.width - previousWidth) > 0.5; - }, - { selector: "[data-tile]", previousWidth: beforeWidth } - ); -}); - -test("pan-drag-shifts-tiles", async ({ page }) => { - await page.goto("/"); - - const pane = page.locator(".react-flow__pane"); - await expect(pane).toBeVisible(); - - const tile = page.locator("[data-tile]"); - await expect(tile).toBeVisible(); - - const beforeBox = await tile.boundingBox(); - expect(beforeBox).not.toBeNull(); - - const paneBox = await pane.boundingBox(); - expect(paneBox).not.toBeNull(); - - const startX = paneBox!.x + 40; - const startY = paneBox!.y + 40; - - await page.mouse.move(startX, startY); - await page.mouse.down(); - await page.mouse.move(startX + 160, startY + 120, { steps: 10 }); - await page.mouse.up(); - - await page.waitForFunction( - ({ selector, startX, startY }) => { - const el = document.querySelector(selector); - if (!el) return false; - const rect = el.getBoundingClientRect(); - return Math.abs(rect.x - startX) > 5 || Math.abs(rect.y - startY) > 5; - }, - { selector: "[data-tile]", startX: beforeBox!.x, startY: beforeBox!.y } - ); -}); - -test("resize-handle-updates-tile-size", async ({ page }) => { - await page.goto("/"); - - const tile = page.locator("[data-tile]"); - await expect(tile).toBeVisible(); - await tile.click(); - - const handle = page.locator(".tile-resize-handle.bottom.right"); - await expect(handle).toBeVisible(); - - const beforeBox = await tile.boundingBox(); - expect(beforeBox).not.toBeNull(); - - const handleBox = await handle.boundingBox(); - expect(handleBox).not.toBeNull(); - - const startX = handleBox!.x + handleBox!.width / 2; - const startY = handleBox!.y + handleBox!.height / 2; - - await page.mouse.move(startX, startY); - await page.mouse.down(); - await page.mouse.move(startX + 120, startY + 100, { steps: 10 }); - await page.mouse.up(); - - await page.waitForFunction( - ({ selector, previousWidth, previousHeight }) => { - const el = document.querySelector(selector); - if (!el) return false; - const rect = el.getBoundingClientRect(); - return rect.width > previousWidth + 10 && rect.height > previousHeight + 10; - }, - { - selector: "[data-tile]", - previousWidth: beforeBox!.width, - previousHeight: beforeBox!.height, - } - ); -}); diff --git a/tests/e2e/connection-settings.spec.ts b/tests/e2e/connection-settings.spec.ts new file mode 100644 index 00000000..29511c1c --- /dev/null +++ b/tests/e2e/connection-settings.spec.ts @@ -0,0 +1,53 @@ +import { expect, test } from "@playwright/test"; + +test("connection settings persist to the studio settings API", async ({ page }) => { + await page.route("**/api/studio", async (route, request) => { + if (request.method() === "GET") { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + settings: { version: 1, gateway: null, focused: {}, sessions: {} }, + }), + }); + return; + } + if (request.method() === "PUT") { + const payload = JSON.parse(request.postData() ?? "{}") as Record<string, unknown>; + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + settings: { + version: 1, + gateway: payload.gateway ?? null, + focused: {}, + sessions: {}, + }, + }), + }); + return; + } + await route.fallback(); + }); + + await page.goto("/"); + + await page.getByLabel("Gateway URL").fill("ws://gateway.example:18789"); + await page.getByLabel("Token").fill("token-123"); + + const request = await page.waitForRequest((req) => { + if (!req.url().includes("/api/studio") || req.method() !== "PUT") { + return false; + } + const payload = JSON.parse(req.postData() ?? "{}") as Record<string, unknown>; + const gateway = (payload.gateway ?? {}) as { url?: string; token?: string }; + return gateway.url === "ws://gateway.example:18789" && gateway.token === "token-123"; + }); + + const payload = JSON.parse(request.postData() ?? "{}") as Record<string, unknown>; + const gateway = (payload.gateway ?? {}) as { url?: string; token?: string }; + expect(gateway.url).toBe("ws://gateway.example:18789"); + expect(gateway.token).toBe("token-123"); + await expect(page.getByRole("button", { name: "Connect", exact: true })).toBeEnabled(); +}); diff --git a/tests/e2e/fleet-sidebar.spec.ts b/tests/e2e/fleet-sidebar.spec.ts new file mode 100644 index 00000000..ef0d9e49 --- /dev/null +++ b/tests/e2e/fleet-sidebar.spec.ts @@ -0,0 +1,240 @@ +import { expect, test } from "@playwright/test"; + +type StudioSettingsFixture = { + version: 1; + gateway: { url: string; token: string } | null; + focused: Record<string, { mode: "focused"; filter: string; selectedAgentId: string | null }>; + sessions: Record<string, string>; +}; + +const DEFAULT_SETTINGS: StudioSettingsFixture = { + version: 1, + gateway: null, + focused: {}, + sessions: {}, +}; + +const createStudioRoute = ( + initial: StudioSettingsFixture = DEFAULT_SETTINGS +) => { + let settings: StudioSettingsFixture = { + version: 1, + gateway: initial.gateway ?? null, + focused: { ...(initial.focused ?? {}) }, + sessions: { ...(initial.sessions ?? {}) }, + }; + return async (route: { fulfill: (args: Record<string, unknown>) => Promise<void>; fallback: () => Promise<void> }, request: { method: () => string; postData: () => string | null }) => { + if (request.method() === "GET") { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ settings }), + }); + return; + } + if (request.method() !== "PUT") { + await route.fallback(); + return; + } + const patch = JSON.parse(request.postData() ?? "{}") as Record<string, unknown>; + const next = { + ...settings, + }; + if ("gateway" in patch) { + next.gateway = (patch.gateway as StudioSettingsFixture["gateway"]) ?? null; + } + if (patch.focused && typeof patch.focused === "object") { + const focusedPatch = patch.focused as Record<string, Record<string, unknown>>; + const focusedNext = { ...next.focused }; + for (const [key, value] of Object.entries(focusedPatch)) { + const existing = focusedNext[key] ?? { + mode: "focused" as const, + filter: "all", + selectedAgentId: null, + }; + focusedNext[key] = { + mode: (value.mode as "focused") ?? existing.mode, + filter: (value.filter as string) ?? existing.filter, + selectedAgentId: + "selectedAgentId" in value + ? ((value.selectedAgentId as string | null) ?? null) + : existing.selectedAgentId, + }; + } + next.focused = focusedNext; + } + if (patch.sessions && typeof patch.sessions === "object") { + const sessionsPatch = patch.sessions as Record<string, string | null>; + const sessionsNext = { ...next.sessions }; + for (const [key, value] of Object.entries(sessionsPatch)) { + if (value === null) { + delete sessionsNext[key]; + continue; + } + if (typeof value === "string" && value.trim()) { + sessionsNext[key] = value.trim(); + } + } + next.sessions = sessionsNext; + } + settings = next; + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ settings }), + }); + }; +}; + +test("switches_active_agent_from_sidebar", async ({ page }) => { + await page.route("**/api/studio", createStudioRoute()); + await page.goto("/"); + + await expect(page.getByTestId("fleet-sidebar")).toBeVisible(); + await expect(page.getByTestId("focused-agent-panel")).toBeVisible(); + await expect( + page.getByTestId("fleet-sidebar").getByText("No agents available.") + ).toBeVisible(); +}); + +test("applies_attention_filters", async ({ page }) => { + await page.route("**/api/studio", createStudioRoute()); + await page.goto("/"); + + await page.getByTestId("fleet-filter-needs-attention").click(); + await expect(page.getByTestId("fleet-filter-needs-attention")).toHaveAttribute( + "aria-pressed", + "true" + ); + + await page.getByTestId("fleet-filter-running").click(); + await expect(page.getByTestId("fleet-filter-running")).toHaveAttribute( + "aria-pressed", + "true" + ); + + await page.getByTestId("fleet-filter-idle").click(); + await expect(page.getByTestId("fleet-filter-idle")).toHaveAttribute( + "aria-pressed", + "true" + ); +}); + +test("focused_preferences_persist_across_reload", async ({ page }) => { + let settings: StudioSettingsFixture = { + version: 1, + gateway: null, + focused: {}, + sessions: {}, + }; + await page.route("**/api/studio", async (route, request) => { + if (request.method() === "GET") { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ settings }), + }); + return; + } + if (request.method() !== "PUT") { + await route.fallback(); + return; + } + const patch = JSON.parse(request.postData() ?? "{}") as Record<string, unknown>; + const next = { + ...settings, + }; + if ("gateway" in patch) { + next.gateway = (patch.gateway as StudioSettingsFixture["gateway"]) ?? null; + } + if (patch.focused && typeof patch.focused === "object") { + const focusedPatch = patch.focused as Record<string, Record<string, unknown>>; + const focusedNext = { ...next.focused }; + for (const [key, value] of Object.entries(focusedPatch)) { + const existing = focusedNext[key] ?? { + mode: "focused" as const, + filter: "all", + selectedAgentId: null, + }; + focusedNext[key] = { + mode: (value.mode as "focused") ?? existing.mode, + filter: (value.filter as string) ?? existing.filter, + selectedAgentId: + "selectedAgentId" in value + ? ((value.selectedAgentId as string | null) ?? null) + : existing.selectedAgentId, + }; + } + next.focused = focusedNext; + } + if (patch.sessions && typeof patch.sessions === "object") { + const sessionsPatch = patch.sessions as Record<string, string | null>; + const sessionsNext = { ...next.sessions }; + for (const [key, value] of Object.entries(sessionsPatch)) { + if (value === null) { + delete sessionsNext[key]; + continue; + } + if (typeof value === "string" && value.trim()) { + sessionsNext[key] = value.trim(); + } + } + next.sessions = sessionsNext; + } + settings = next; + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ settings }), + }); + }); + const initialSettingsLoad = page.waitForResponse( + (response) => + response.url().includes("/api/studio") && + response.request().method() === "GET" + ); + await page.goto("/"); + await initialSettingsLoad; + await page.waitForResponse( + (response) => + response.url().includes("/api/studio") && + response.request().method() === "PUT" + ); + + const focusedPersist = page.waitForResponse((response) => { + if (!response.url().includes("/api/studio")) return false; + if (response.request().method() !== "PUT") return false; + const body = response.request().postData() ?? ""; + return body.includes("\"focused\"") && body.includes("\"running\""); + }); + await page.getByTestId("fleet-filter-running").click(); + await expect(page.getByTestId("fleet-filter-running")).toHaveAttribute( + "aria-pressed", + "true" + ); + await focusedPersist; + expect(Object.values(settings.focused).some((entry) => entry.filter === "running")).toBe( + true + ); + + const reloadSettingsLoad = page.waitForResponse( + (response) => + response.url().includes("/api/studio") && + response.request().method() === "GET" + ); + await page.reload(); + await reloadSettingsLoad; + + await expect(page.getByTestId("fleet-filter-running")).toHaveAttribute( + "aria-pressed", + "true" + ); +}); + +test("clears_unseen_indicator_on_focus", async ({ page }) => { + await page.route("**/api/studio", createStudioRoute()); + await page.goto("/"); + + await page.getByTestId("fleet-filter-all").click(); + await expect(page.getByText(/^Attention$/)).toHaveCount(0); +}); diff --git a/tests/e2e/focused-smoke.spec.ts b/tests/e2e/focused-smoke.spec.ts new file mode 100644 index 00000000..23d6b325 --- /dev/null +++ b/tests/e2e/focused-smoke.spec.ts @@ -0,0 +1,32 @@ +import { expect, test } from "@playwright/test"; + +test("loads focused studio empty state", async ({ page }) => { + await page.route("**/api/studio", async (route, request) => { + if (request.method() === "PUT") { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + settings: { version: 1, gateway: null, focused: {}, sessions: {} }, + }), + }); + return; + } + if (request.method() !== "GET") { + await route.fallback(); + return; + } + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + settings: { version: 1, gateway: null, focused: {}, sessions: {} }, + }), + }); + }); + + await page.goto("/"); + + await expect(page.getByTestId("fleet-sidebar")).toBeVisible(); + await expect(page.getByTestId("focused-agent-panel")).toBeVisible(); +}); diff --git a/tests/unit/agentStore.test.ts b/tests/unit/agentStore.test.ts new file mode 100644 index 00000000..0b9a1676 --- /dev/null +++ b/tests/unit/agentStore.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, it } from "vitest"; + +import { + agentStoreReducer, + getAttentionForAgent, + getFilteredAgents, + initialAgentStoreState, + type AgentStoreSeed, +} from "@/features/agents/state/store"; + +describe("agent store", () => { + it("hydrates agents with defaults and selection", () => { + const seed: AgentStoreSeed = { + agentId: "agent-1", + name: "Agent One", + sessionKey: "agent:agent-1:main", + }; + const next = agentStoreReducer(initialAgentStoreState, { + type: "hydrateAgents", + agents: [seed], + }); + expect(next.loading).toBe(false); + expect(next.selectedAgentId).toBe("agent-1"); + expect(next.agents).toHaveLength(1); + expect(next.agents[0].status).toBe("idle"); + expect(next.agents[0].sessionCreated).toBe(false); + expect(next.agents[0].outputLines).toEqual([]); + }); + + it("preserves_session_created_state_across_hydration", () => { + const seed: AgentStoreSeed = { + agentId: "agent-1", + name: "Agent One", + sessionKey: "agent:agent-1:main", + }; + let state = agentStoreReducer(initialAgentStoreState, { + type: "hydrateAgents", + agents: [seed], + }); + state = agentStoreReducer(state, { + type: "updateAgent", + agentId: "agent-1", + patch: { sessionCreated: true }, + }); + state = agentStoreReducer(state, { + type: "hydrateAgents", + agents: [seed], + }); + expect(state.agents[0]?.sessionCreated).toBe(true); + }); + + it("tracks_unseen_activity_for_non_selected_agents", () => { + const seeds: AgentStoreSeed[] = [ + { + agentId: "agent-1", + name: "Agent One", + sessionKey: "agent:agent-1:main", + }, + { + agentId: "agent-2", + name: "Agent Two", + sessionKey: "agent:agent-2:main", + }, + ]; + const hydrated = agentStoreReducer(initialAgentStoreState, { + type: "hydrateAgents", + agents: seeds, + }); + const withActivity = agentStoreReducer(hydrated, { + type: "markActivity", + agentId: "agent-2", + at: 1700000000000, + }); + const second = withActivity.agents.find((agent) => agent.agentId === "agent-2"); + expect(second?.hasUnseenActivity).toBe(true); + expect(second?.lastActivityAt).toBe(1700000000000); + expect(getAttentionForAgent(second!, withActivity.selectedAgentId)).toBe( + "needs-attention" + ); + + const selected = agentStoreReducer(withActivity, { + type: "selectAgent", + agentId: "agent-2", + }); + const cleared = selected.agents.find((agent) => agent.agentId === "agent-2"); + expect(cleared?.hasUnseenActivity).toBe(false); + }); + + it("filters_agents_by_attention_and_status", () => { + const seeds: AgentStoreSeed[] = [ + { + agentId: "agent-1", + name: "Agent One", + sessionKey: "agent:agent-1:main", + }, + { + agentId: "agent-2", + name: "Agent Two", + sessionKey: "agent:agent-2:main", + }, + { + agentId: "agent-3", + name: "Agent Three", + sessionKey: "agent:agent-3:main", + }, + ]; + let state = agentStoreReducer(initialAgentStoreState, { + type: "hydrateAgents", + agents: seeds, + }); + state = agentStoreReducer(state, { + type: "updateAgent", + agentId: "agent-1", + patch: { awaitingUserInput: true }, + }); + state = agentStoreReducer(state, { + type: "updateAgent", + agentId: "agent-2", + patch: { status: "running" }, + }); + state = agentStoreReducer(state, { + type: "updateAgent", + agentId: "agent-3", + patch: { status: "error" }, + }); + state = agentStoreReducer(state, { + type: "markActivity", + agentId: "agent-2", + at: 1700000000001, + }); + + expect(getFilteredAgents(state, "all").map((agent) => agent.agentId)).toEqual([ + "agent-1", + "agent-2", + "agent-3", + ]); + expect( + getFilteredAgents(state, "needs-attention").map((agent) => agent.agentId) + ).toEqual(["agent-1", "agent-2", "agent-3"]); + expect(getFilteredAgents(state, "running").map((agent) => agent.agentId)).toEqual([ + "agent-2", + ]); + expect(getFilteredAgents(state, "idle").map((agent) => agent.agentId)).toEqual([ + "agent-1", + ]); + }); + + it("clears_unseen_indicator_on_focus", () => { + const seeds: AgentStoreSeed[] = [ + { + agentId: "agent-1", + name: "Agent One", + sessionKey: "agent:agent-1:main", + }, + { + agentId: "agent-2", + name: "Agent Two", + sessionKey: "agent:agent-2:main", + }, + ]; + let state = agentStoreReducer(initialAgentStoreState, { + type: "hydrateAgents", + agents: seeds, + }); + state = agentStoreReducer(state, { + type: "markActivity", + agentId: "agent-2", + at: 1700000000100, + }); + + const before = state.agents.find((agent) => agent.agentId === "agent-2"); + expect(before?.hasUnseenActivity).toBe(true); + expect(getAttentionForAgent(before!, state.selectedAgentId)).toBe( + "needs-attention" + ); + + state = agentStoreReducer(state, { + type: "selectAgent", + agentId: "agent-2", + }); + const after = state.agents.find((agent) => agent.agentId === "agent-2"); + expect(after?.hasUnseenActivity).toBe(false); + expect(getAttentionForAgent(after!, state.selectedAgentId)).toBe("normal"); + }); +}); diff --git a/tests/unit/agentSummary.test.ts b/tests/unit/agentSummary.test.ts new file mode 100644 index 00000000..a9166398 --- /dev/null +++ b/tests/unit/agentSummary.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; + +import { getAgentSummaryPatch, getChatSummaryPatch } from "@/features/agents/state/summary"; + +describe("agent summary reducer", () => { + it("updates preview and activity from assistant chat", () => { + const patch = getChatSummaryPatch( + { + runId: "run-1", + sessionKey: "agent:main:studio:agent-1", + state: "final", + message: { role: "assistant", content: "Hello" }, + }, + 123 + ); + + expect(patch?.latestPreview).toBe("Hello"); + expect(patch?.lastActivityAt).toBe(123); + }); + + it("updates status from agent lifecycle events", () => { + const patch = getAgentSummaryPatch( + { + runId: "run-2", + stream: "lifecycle", + data: { phase: "start" }, + }, + 456 + ); + + expect(patch?.status).toBe("running"); + expect(patch?.lastActivityAt).toBe(456); + }); +}); diff --git a/tests/unit/canvasTransform.test.ts b/tests/unit/canvasTransform.test.ts deleted file mode 100644 index 94ab04d6..00000000 --- a/tests/unit/canvasTransform.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - clampZoom, - screenToWorld, - worldToScreen, - zoomAtScreenPoint, -} from "@/features/canvas/lib/transform"; - -describe("canvas transform math", () => { - it("round-trips screen/world coordinates", () => { - const transform = { zoom: 1.5, offsetX: 120, offsetY: -80 }; - const world = { x: 300, y: -200 }; - - const screen = worldToScreen(transform, world); - const roundTrip = screenToWorld(transform, screen); - - expect(roundTrip.x).toBeCloseTo(world.x, 6); - expect(roundTrip.y).toBeCloseTo(world.y, 6); - }); - - it("keeps the world point under the cursor pinned during zoom", () => { - const transform = { zoom: 1, offsetX: 50, offsetY: 25 }; - const screenPoint = { x: 200, y: 150 }; - const worldPoint = screenToWorld(transform, screenPoint); - - const next = zoomAtScreenPoint(transform, 2, screenPoint); - const nextScreenPoint = worldToScreen(next, worldPoint); - - expect(nextScreenPoint.x).toBeCloseTo(screenPoint.x, 6); - expect(nextScreenPoint.y).toBeCloseTo(screenPoint.y, 6); - }); - - it("clamps zoom within the allowed range", () => { - expect(clampZoom(0.1)).toBeCloseTo(0.25, 6); - expect(clampZoom(5)).toBeCloseTo(3, 6); - expect(clampZoom(1.2)).toBeCloseTo(1.2, 6); - }); -}); diff --git a/tests/unit/canvasZoomToFit.test.ts b/tests/unit/canvasZoomToFit.test.ts deleted file mode 100644 index f7ebb3c7..00000000 --- a/tests/unit/canvasZoomToFit.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { worldToScreen, zoomToFit } from "@/features/canvas/lib/transform"; -import type { AgentTile } from "@/features/canvas/state/store"; - -type Bounds = { minX: number; minY: number; maxX: number; maxY: number }; - -const makeTile = (id: string, position: { x: number; y: number }, size: { width: number; height: number }): AgentTile => ({ - id, - name: `Tile ${id}`, - agentId: `agent-${id}`, - role: "coding", - sessionKey: `agent:${id}:main`, - model: null, - thinkingLevel: "low", - position, - size, - status: "idle", - outputLines: [], - lastResult: null, - lastDiff: null, - runId: null, - streamText: null, - thinkingTrace: null, - draft: "", - sessionSettingsSynced: false, -}); - -const getBounds = (tiles: AgentTile[]): Bounds => { - const minX = Math.min(...tiles.map((tile) => tile.position.x)); - const minY = Math.min(...tiles.map((tile) => tile.position.y)); - const maxX = Math.max( - ...tiles.map((tile) => tile.position.x + tile.size.width) - ); - const maxY = Math.max( - ...tiles.map((tile) => tile.position.y + tile.size.height) - ); - return { minX, minY, maxX, maxY }; -}; - -describe("zoomToFit", () => { - it("fits tile bounds within the viewport with padding", () => { - const tiles = [ - makeTile("1", { x: 120, y: 80 }, { width: 400, height: 300 }), - makeTile("2", { x: 700, y: 500 }, { width: 240, height: 200 }), - ]; - const viewportSize = { width: 1200, height: 800 }; - const padding = 60; - const currentTransform = { zoom: 1, offsetX: 0, offsetY: 0 }; - - const transform = zoomToFit(tiles, viewportSize, padding, currentTransform); - const bounds = getBounds(tiles); - const topLeft = worldToScreen(transform, { x: bounds.minX, y: bounds.minY }); - const bottomRight = worldToScreen(transform, { - x: bounds.maxX, - y: bounds.maxY, - }); - - expect(topLeft.x).toBeGreaterThanOrEqual(padding - 0.5); - expect(topLeft.y).toBeGreaterThanOrEqual(padding - 0.5); - expect(bottomRight.x).toBeLessThanOrEqual(viewportSize.width - padding + 0.5); - expect(bottomRight.y).toBeLessThanOrEqual(viewportSize.height - padding + 0.5); - }); - - it("returns the current transform when there are no tiles", () => { - const currentTransform = { zoom: 1.2, offsetX: 40, offsetY: -20 }; - const viewportSize = { width: 900, height: 700 }; - - expect(zoomToFit([], viewportSize, 40, currentTransform)).toEqual(currentTransform); - }); -}); diff --git a/tests/unit/chatItems.test.ts b/tests/unit/chatItems.test.ts new file mode 100644 index 00000000..24eeeb6c --- /dev/null +++ b/tests/unit/chatItems.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; + +import { buildAgentChatItems } from "@/features/agents/components/chatItems"; +import { formatThinkingMarkdown } from "@/lib/text/message-extract"; + +describe("buildAgentChatItems", () => { + it("keeps thinking traces aligned with each assistant turn", () => { + const items = buildAgentChatItems({ + outputLines: [ + "> first question", + formatThinkingMarkdown("first plan"), + "first answer", + "> second question", + formatThinkingMarkdown("second plan"), + "second answer", + ], + streamText: null, + liveThinkingTrace: "", + showThinkingTraces: true, + toolCallingEnabled: true, + }); + + expect(items.map((item) => item.kind)).toEqual([ + "user", + "thinking", + "assistant", + "user", + "thinking", + "assistant", + ]); + expect(items[1]).toMatchObject({ kind: "thinking", text: "_first plan_" }); + expect(items[4]).toMatchObject({ kind: "thinking", text: "_second plan_" }); + }); + + it("does not include saved traces when thinking traces are disabled", () => { + const items = buildAgentChatItems({ + outputLines: [ + "> first question", + formatThinkingMarkdown("first plan"), + "first answer", + ], + streamText: null, + liveThinkingTrace: "live plan", + showThinkingTraces: false, + toolCallingEnabled: true, + }); + + expect(items.map((item) => item.kind)).toEqual(["user", "assistant"]); + }); + + it("adds a live trace before the live assistant stream", () => { + const items = buildAgentChatItems({ + outputLines: ["first answer"], + streamText: "stream answer", + liveThinkingTrace: "first plan", + showThinkingTraces: true, + toolCallingEnabled: true, + }); + + expect(items.map((item) => item.kind)).toEqual(["assistant", "thinking", "assistant"]); + expect(items[1]).toMatchObject({ kind: "thinking", text: "_first plan_", live: true }); + }); + + it("merges adjacent thinking traces into a single item", () => { + const items = buildAgentChatItems({ + outputLines: [formatThinkingMarkdown("first plan"), formatThinkingMarkdown("second plan"), "answer"], + streamText: null, + liveThinkingTrace: "", + showThinkingTraces: true, + toolCallingEnabled: true, + }); + + expect(items.map((item) => item.kind)).toEqual(["thinking", "assistant"]); + expect(items[0]).toMatchObject({ + kind: "thinking", + text: "_first plan_\n\n_second plan_", + }); + }); +}); diff --git a/tests/unit/clawdbotConfig.test.ts b/tests/unit/clawdbotConfig.test.ts new file mode 100644 index 00000000..adbce985 --- /dev/null +++ b/tests/unit/clawdbotConfig.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; + +import { + readAgentList, + writeAgentList, + type AgentEntry, +} from "@/lib/clawdbot/config"; + +describe("clawdbot config agent list helpers", () => { + it("reads an empty list when agents.list is missing", () => { + expect(readAgentList({})).toEqual([]); + }); + + it("preserves extra fields like heartbeat when writing list", () => { + const list: AgentEntry[] = [ + { + id: "agent-1", + name: "Agent One", + workspace: "/tmp/agent-1", + heartbeat: { every: "30m", target: "last" }, + }, + ]; + const config: Record<string, unknown> = {}; + + writeAgentList(config, list); + + expect(readAgentList(config)).toEqual(list); + }); +}); + +describe("clawdbot config boundaries", () => { + it("does not expose legacy mutation wrapper", async () => { + const mod = await import("@/lib/clawdbot/config"); + expect("updateClawdbotConfig" in mod).toBe(false); + }); +}); diff --git a/tests/unit/configList.test.ts b/tests/unit/configList.test.ts new file mode 100644 index 00000000..5e1fafc7 --- /dev/null +++ b/tests/unit/configList.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; + +import { + readConfigAgentList, + upsertConfigAgentEntry, + writeConfigAgentList, + type ConfigAgentEntry, +} from "@/lib/agents/configList"; + +describe("config list helpers", () => { + it("reads an empty list when agents.list is missing", () => { + expect(readConfigAgentList({})).toEqual([]); + }); + + it("filters invalid list entries and keeps id-based entries", () => { + const config = { + agents: { + list: [ + null, + { id: "agent-1", name: "One" }, + { id: "" }, + { name: "missing-id" }, + { id: "agent-2", heartbeat: { every: "30m" } }, + ], + }, + }; + expect(readConfigAgentList(config)).toEqual([ + { id: "agent-1", name: "One" }, + { id: "agent-2", heartbeat: { every: "30m" } }, + ]); + }); + + it("writes agents.list immutably", () => { + const initial: Record<string, unknown> = { + agents: { defaults: { heartbeat: { every: "1h" } } }, + bindings: [{ agentId: "agent-1" }], + }; + const list: ConfigAgentEntry[] = [{ id: "agent-1", name: "One" }]; + const next = writeConfigAgentList(initial, list); + + expect(next).not.toBe(initial); + expect(next.agents).not.toBe(initial.agents); + expect((next.agents as Record<string, unknown>).list).toEqual(list); + expect((next.agents as Record<string, unknown>).defaults).toEqual({ + heartbeat: { every: "1h" }, + }); + expect(next.bindings).toEqual([{ agentId: "agent-1" }]); + }); + + it("upserts agent entries", () => { + const list: ConfigAgentEntry[] = [ + { id: "agent-1", name: "One" }, + { id: "agent-2", name: "Two" }, + ]; + + const updated = upsertConfigAgentEntry(list, "agent-2", (entry) => ({ + ...entry, + name: "Two Updated", + })); + expect(updated.list).toEqual([ + { id: "agent-1", name: "One" }, + { id: "agent-2", name: "Two Updated" }, + ]); + expect(updated.entry).toEqual({ id: "agent-2", name: "Two Updated" }); + + const inserted = upsertConfigAgentEntry(updated.list, "agent-3", (entry) => ({ + ...entry, + name: "Three", + })); + expect(inserted.list).toEqual([ + { id: "agent-1", name: "One" }, + { id: "agent-2", name: "Two Updated" }, + { id: "agent-3", name: "Three" }, + ]); + expect(inserted.entry).toEqual({ id: "agent-3", name: "Three" }); + }); +}); diff --git a/tests/unit/extractThinking.test.ts b/tests/unit/extractThinking.test.ts index 61db994d..442b0048 100644 --- a/tests/unit/extractThinking.test.ts +++ b/tests/unit/extractThinking.test.ts @@ -2,10 +2,12 @@ import { describe, expect, it } from "vitest"; import { extractThinking, + extractThinkingFromTaggedStream, + extractThinkingFromTaggedText, formatThinkingMarkdown, isTraceMarkdown, stripTraceMarkdown, -} from "@/lib/text/extractThinking"; +} from "@/lib/text/message-extract"; describe("extractThinking", () => { it("extracts thinking blocks from content arrays", () => { @@ -68,3 +70,15 @@ describe("formatThinkingMarkdown", () => { expect(stripTraceMarkdown(formatted)).toBe("_Line 1_\n_Line 2_"); }); }); + +describe("extractThinkingFromTaggedText", () => { + it("extracts from closed thinking tags", () => { + expect(extractThinkingFromTaggedText("<thinking>Plan A</thinking>\nOk")).toBe("Plan A"); + }); +}); + +describe("extractThinkingFromTaggedStream", () => { + it("extracts partial thinking from an open thinking tag", () => { + expect(extractThinkingFromTaggedStream("Hello <think>Plan A so far")).toBe("Plan A so far"); + }); +}); diff --git a/tests/unit/gatewayBrowserClient.test.ts b/tests/unit/gatewayBrowserClient.test.ts new file mode 100644 index 00000000..eba45049 --- /dev/null +++ b/tests/unit/gatewayBrowserClient.test.ts @@ -0,0 +1,87 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { GatewayBrowserClient } from "@/lib/gateway/openclaw/GatewayBrowserClient"; + +class MockWebSocket { + static OPEN = 1; + static CLOSED = 3; + static instances: MockWebSocket[] = []; + static sent: string[] = []; + + readyState = MockWebSocket.OPEN; + onopen: (() => void) | null = null; + onmessage: ((event: MessageEvent) => void) | null = null; + onclose: ((event: CloseEvent) => void) | null = null; + onerror: (() => void) | null = null; + + constructor(public url: string) { + MockWebSocket.instances.push(this); + } + + send(data: string) { + MockWebSocket.sent.push(String(data)); + } + + close(code?: number, reason?: string) { + this.readyState = MockWebSocket.CLOSED; + this.onclose?.({ code: code ?? 1000, reason: reason ?? "" } as CloseEvent); + } +} + +describe("GatewayBrowserClient", () => { + const originalWebSocket = globalThis.WebSocket; + const originalSubtle = globalThis.crypto?.subtle; + + beforeEach(() => { + MockWebSocket.instances = []; + MockWebSocket.sent = []; + globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket; + if (globalThis.crypto) { + Object.defineProperty(globalThis.crypto, "subtle", { + value: undefined, + configurable: true, + }); + } + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + globalThis.WebSocket = originalWebSocket; + if (globalThis.crypto) { + Object.defineProperty(globalThis.crypto, "subtle", { + value: originalSubtle, + configurable: true, + }); + } + }); + + it("sends connect when connect.challenge arrives", async () => { + const client = new GatewayBrowserClient({ url: "ws://example.com" }); + client.start(); + + const ws = MockWebSocket.instances[0]; + if (!ws) { + throw new Error("WebSocket not created"); + } + + ws.onopen?.(); + + expect(MockWebSocket.sent).toHaveLength(0); + + ws.onmessage?.({ + data: JSON.stringify({ + type: "event", + event: "connect.challenge", + payload: { nonce: "abc" }, + }), + } as MessageEvent); + + await vi.runAllTicks(); + + expect(MockWebSocket.sent).toHaveLength(1); + const frame = JSON.parse(MockWebSocket.sent[0] ?? "{}"); + expect(frame.type).toBe("req"); + expect(frame.method).toBe("connect"); + }); +}); diff --git a/tests/unit/gatewayConfigPatch.test.ts b/tests/unit/gatewayConfigPatch.test.ts new file mode 100644 index 00000000..bf278af5 --- /dev/null +++ b/tests/unit/gatewayConfigPatch.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + renameGatewayAgent, + resolveHeartbeatSettings, + updateGatewayHeartbeat, +} from "@/lib/gateway/agentConfig"; +import type { GatewayClient } from "@/lib/gateway/GatewayClient"; + +describe("gateway config patch helpers", () => { + it("renames an agent in the config patch", async () => { + const client = { + call: vi.fn(async (method: string) => { + if (method === "config.get") { + return { + exists: true, + hash: "hash-1", + config: { + agents: { list: [{ id: "agent-1", name: "Old Name" }] }, + }, + }; + } + if (method === "config.patch") { + return { ok: true }; + } + throw new Error("unexpected method"); + }), + } as unknown as GatewayClient; + + await renameGatewayAgent({ client, agentId: "agent-1", name: "New Name" }); + + const patchCall = (client.call as ReturnType<typeof vi.fn>).mock.calls.find( + ([method]) => method === "config.patch" + ); + expect(patchCall).toBeTruthy(); + const params = patchCall?.[1] as { raw?: string; baseHash?: string }; + const raw = params?.raw ?? ""; + const parsed = JSON.parse(raw) as { agents?: { list?: Array<{ name?: string }> } }; + expect(params.baseHash).toBe("hash-1"); + expect(parsed.agents?.list?.[0]?.name).toBe("New Name"); + }); + + it("resolves heartbeat defaults and overrides", () => { + const config = { + agents: { + defaults: { + heartbeat: { + every: "2h", + target: "last", + includeReasoning: false, + ackMaxChars: 200, + }, + }, + list: [ + { + id: "agent-1", + heartbeat: { every: "30m", target: "none", includeReasoning: true }, + }, + ], + }, + }; + const result = resolveHeartbeatSettings(config, "agent-1"); + expect(result.heartbeat.every).toBe("30m"); + expect(result.heartbeat.target).toBe("none"); + expect(result.heartbeat.includeReasoning).toBe(true); + expect(result.hasOverride).toBe(true); + }); + + it("updates heartbeat overrides via config.patch", async () => { + const client = { + call: vi.fn(async (method: string) => { + if (method === "config.get") { + return { + exists: true, + hash: "hash-2", + config: { + agents: { + defaults: { + heartbeat: { + every: "1h", + target: "last", + includeReasoning: false, + ackMaxChars: 300, + }, + }, + list: [{ id: "agent-1" }], + }, + }, + }; + } + if (method === "config.patch") { + return { ok: true }; + } + throw new Error("unexpected method"); + }), + } as unknown as GatewayClient; + + const result = await updateGatewayHeartbeat({ + client, + agentId: "agent-1", + payload: { + override: true, + heartbeat: { + every: "15m", + target: "none", + includeReasoning: true, + ackMaxChars: 120, + activeHours: { start: "08:00", end: "18:00" }, + }, + }, + }); + + expect(result.heartbeat.every).toBe("15m"); + expect(result.heartbeat.target).toBe("none"); + expect(result.heartbeat.includeReasoning).toBe(true); + expect(result.hasOverride).toBe(true); + }); +}); diff --git a/tests/unit/gatewayFrames.test.ts b/tests/unit/gatewayFrames.test.ts new file mode 100644 index 00000000..86b45472 --- /dev/null +++ b/tests/unit/gatewayFrames.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; + +import { parseGatewayFrame } from "@/lib/gateway/frames"; + +describe("gateway frames", () => { + it("parses event stateVersion objects", () => { + const raw = JSON.stringify({ + type: "event", + event: "presence", + payload: { presence: [] }, + stateVersion: { presence: 2, health: 5 }, + }); + + const frame = parseGatewayFrame(raw); + + expect(frame?.type).toBe("event"); + if (frame?.type !== "event") { + throw new Error("Expected event frame"); + } + expect(frame.stateVersion?.presence).toBe(2); + expect(frame.stateVersion?.health).toBe(5); + }); +}); diff --git a/tests/unit/messageExtract.test.ts b/tests/unit/messageExtract.test.ts new file mode 100644 index 00000000..0fdaeb41 --- /dev/null +++ b/tests/unit/messageExtract.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; + +import { + extractText, + extractTextCached, + extractThinking, + extractThinkingCached, + extractToolLines, +} from "@/lib/text/message-extract"; + +describe("message-extract", () => { + it("strips envelope headers from user messages", () => { + const message = { + role: "user", + content: + "[Discord Guild #openclaw-studio channel id:123 +0s 2026-02-01 00:00 UTC] hello there", + }; + + expect(extractText(message)).toBe("hello there"); + }); + + it("removes <thinking>/<analysis> blocks from assistant-visible text", () => { + const message = { + role: "assistant", + content: "<thinking>Plan A</thinking>\n<analysis>Details</analysis>\nOk.", + }; + + expect(extractText(message)).toBe("Ok."); + }); + + it("extractTextCached matches extractText and is consistent", () => { + const message = { role: "user", content: "plain text" }; + + expect(extractTextCached(message)).toBe(extractText(message)); + expect(extractTextCached(message)).toBe("plain text"); + expect(extractTextCached(message)).toBe("plain text"); + }); + + it("extractThinkingCached matches extractThinking and is consistent", () => { + const message = { + role: "assistant", + content: [{ type: "thinking", thinking: "Plan A" }], + }; + + expect(extractThinkingCached(message)).toBe(extractThinking(message)); + expect(extractThinkingCached(message)).toBe("Plan A"); + expect(extractThinkingCached(message)).toBe("Plan A"); + }); + + it("formats tool call + tool result lines", () => { + const callMessage = { + role: "assistant", + content: [ + { + type: "toolCall", + id: "call-1", + name: "functions.exec", + arguments: { command: "echo hi" }, + }, + ], + }; + + const resultMessage = { + role: "toolResult", + toolCallId: "call-1", + toolName: "functions.exec", + details: { status: "ok", exitCode: 0 }, + content: "hi\n", + }; + + const callLines = extractToolLines(callMessage).join("\n"); + expect(callLines).toContain("[[tool]] functions.exec (call-1)"); + expect(callLines).toContain("\"command\": \"echo hi\""); + + const resultLines = extractToolLines(resultMessage).join("\n"); + expect(resultLines).toContain("[[tool-result]] functions.exec (call-1)"); + expect(resultLines).toContain("ok"); + expect(resultLines).toContain("hi"); + }); +}); diff --git a/tests/unit/messageHelpers.test.ts b/tests/unit/messageHelpers.test.ts new file mode 100644 index 00000000..585138e7 --- /dev/null +++ b/tests/unit/messageHelpers.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; + +import { buildAgentInstruction } from "@/lib/text/message-metadata"; + +describe("buildAgentInstruction", () => { + it("returns trimmed message for normal prompts", () => { + const message = buildAgentInstruction({ + message: " Ship it ", + }); + expect(message).toBe("Ship it"); + }); + + it("returns command messages untouched", () => { + const message = buildAgentInstruction({ + message: "/help", + }); + expect(message).toBe("/help"); + }); +}); diff --git a/tests/unit/messageMetadata.test.ts b/tests/unit/messageMetadata.test.ts new file mode 100644 index 00000000..5269270b --- /dev/null +++ b/tests/unit/messageMetadata.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; + +import { + buildAgentInstruction, + isUiMetadataPrefix, + stripUiMetadata, +} from "@/lib/text/message-metadata"; + +describe("message-metadata", () => { + it("returns plain messages without metadata", () => { + const built = buildAgentInstruction({ + message: "hello", + }); + + expect(isUiMetadataPrefix(built)).toBe(false); + expect(stripUiMetadata(built)).toBe("hello"); + }); +}); diff --git a/tests/unit/pathAutocomplete.test.ts b/tests/unit/pathAutocomplete.test.ts new file mode 100644 index 00000000..f7b596e2 --- /dev/null +++ b/tests/unit/pathAutocomplete.test.ts @@ -0,0 +1,77 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; + +import { listPathAutocompleteEntries } from "@/lib/fs.server"; + +let tempHome: string | null = null; + +const setupHome = () => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-home-")); + fs.mkdirSync(path.join(tempHome, "Documents"), { recursive: true }); + fs.mkdirSync(path.join(tempHome, "Downloads"), { recursive: true }); + fs.writeFileSync(path.join(tempHome, "Doc.txt"), "doc", "utf8"); + fs.writeFileSync(path.join(tempHome, "Notes.txt"), "notes", "utf8"); + fs.writeFileSync(path.join(tempHome, ".secret"), "hidden", "utf8"); +}; + +const cleanupHome = () => { + if (!tempHome) return; + fs.rmSync(tempHome, { recursive: true, force: true }); + tempHome = null; +}; + +beforeEach(setupHome); +afterEach(cleanupHome); + +describe("listPathAutocompleteEntries", () => { + it("returns non-hidden entries for home", () => { + const result = listPathAutocompleteEntries({ + query: "~/", + homedir: () => tempHome ?? "", + maxResults: 10, + }); + + expect(result.entries.map((entry) => entry.displayPath)).toEqual([ + "~/Documents/", + "~/Downloads/", + "~/Doc.txt", + "~/Notes.txt", + ]); + }); + + it("filters by prefix within the current directory", () => { + const result = listPathAutocompleteEntries({ + query: "~/Doc", + homedir: () => tempHome ?? "", + maxResults: 10, + }); + + expect(result.entries.map((entry) => entry.displayPath)).toEqual([ + "~/Documents/", + "~/Doc.txt", + ]); + }); + + it("rejects paths outside the home directory", () => { + expect(() => + listPathAutocompleteEntries({ + query: "~/../", + homedir: () => tempHome ?? "", + maxResults: 10, + }) + ).toThrow(/home/i); + }); + + it("rejects missing directories", () => { + expect(() => + listPathAutocompleteEntries({ + query: "~/Missing/", + homedir: () => tempHome ?? "", + maxResults: 10, + }) + ).toThrow(/does not exist/i); + }); +}); diff --git a/tests/unit/projectFs.test.ts b/tests/unit/projectFs.test.ts deleted file mode 100644 index 33a8547f..00000000 --- a/tests/unit/projectFs.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { afterEach, describe, expect, it } from "vitest"; - -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -import { - deleteDirIfExists, - resolveClawdbotStateDir, - resolveHomePath, -} from "@/lib/projects/fs.server"; - -let tempDir: string | null = null; - -const cleanup = () => { - if (!tempDir) return; - fs.rmSync(tempDir, { recursive: true, force: true }); - tempDir = null; -}; - -afterEach(cleanup); - -describe("projectFs", () => { - it("resolvesHomePathVariants", () => { - expect(resolveHomePath("~")).toBe(os.homedir()); - expect(resolveHomePath("~/foo")).toBe(path.join(os.homedir(), "foo")); - expect(resolveHomePath("/tmp/x")).toBe("/tmp/x"); - }); - - it("resolvesClawdbotStateDirFromEnv", () => { - const prev = process.env.CLAWDBOT_STATE_DIR; - process.env.CLAWDBOT_STATE_DIR = "~/state-test"; - try { - expect(resolveClawdbotStateDir()).toBe(path.join(os.homedir(), "state-test")); - } finally { - if (prev === undefined) { - delete process.env.CLAWDBOT_STATE_DIR; - } else { - process.env.CLAWDBOT_STATE_DIR = prev; - } - } - }); - - it("deleteDirIfExistsRemovesDirectory", () => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-projectfs-")); - const warnings: string[] = []; - deleteDirIfExists(tempDir, "Temp dir", warnings); - expect(fs.existsSync(tempDir)).toBe(false); - expect(warnings).toEqual([]); - }); -}); diff --git a/tests/unit/runtimeEventBridge.test.ts b/tests/unit/runtimeEventBridge.test.ts new file mode 100644 index 00000000..f795ef93 --- /dev/null +++ b/tests/unit/runtimeEventBridge.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; + +import { + dedupeRunLines, + mergeRuntimeStream, + resolveLifecyclePatch, + shouldPublishAssistantStream, +} from "@/features/agents/state/runtimeEventBridge"; + +describe("runtime event bridge helpers", () => { + it("merges assistant stream text deterministically", () => { + expect(mergeRuntimeStream("", "delta")).toBe("delta"); + expect(mergeRuntimeStream("hello", "hello world")).toBe("hello world"); + expect(mergeRuntimeStream("hello", " world")).toBe("hello world"); + expect(mergeRuntimeStream("hello", "hello")).toBe("hello"); + }); + + it("dedupes tool lines per run", () => { + const first = dedupeRunLines(new Set<string>(), ["a", "b", "a"]); + expect(first.appended).toEqual(["a", "b"]); + const second = dedupeRunLines(first.nextSeen, ["b", "c"]); + expect(second.appended).toEqual(["c"]); + }); + + it("resolves lifecycle transitions with run guards", () => { + const started = resolveLifecyclePatch({ + phase: "start", + incomingRunId: "run-1", + currentRunId: null, + lastActivityAt: 123, + }); + expect(started.kind).toBe("start"); + if (started.kind !== "start") throw new Error("Expected start transition"); + expect(started.patch.status).toBe("running"); + expect(started.patch.runId).toBe("run-1"); + + const ignored = resolveLifecyclePatch({ + phase: "end", + incomingRunId: "run-2", + currentRunId: "run-1", + lastActivityAt: 456, + }); + expect(ignored.kind).toBe("ignore"); + + const ended = resolveLifecyclePatch({ + phase: "end", + incomingRunId: "run-1", + currentRunId: "run-1", + lastActivityAt: 789, + }); + expect(ended.kind).toBe("terminal"); + if (ended.kind !== "terminal") throw new Error("Expected terminal transition"); + expect(ended.patch.status).toBe("idle"); + expect(ended.patch.runId).toBeNull(); + expect(ended.clearRunTracking).toBe(true); + }); + + it("suppresses assistant stream publish when chat stream already owns it", () => { + expect( + shouldPublishAssistantStream({ + mergedRaw: "hello", + rawText: "", + hasChatEvents: true, + currentStreamText: "already streaming", + }) + ).toBe(false); + expect( + shouldPublishAssistantStream({ + mergedRaw: "hello", + rawText: "", + hasChatEvents: false, + currentStreamText: "already streaming", + }) + ).toBe(true); + expect( + shouldPublishAssistantStream({ + mergedRaw: "", + rawText: "", + hasChatEvents: false, + currentStreamText: null, + }) + ).toBe(false); + }); +}); diff --git a/tests/unit/sessionKey.test.ts b/tests/unit/sessionKey.test.ts new file mode 100644 index 00000000..27133026 --- /dev/null +++ b/tests/unit/sessionKey.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; + +import { + buildAgentMainSessionKey, + isSameSessionKey, + parseAgentIdFromSessionKey, +} from "@/lib/gateway/sessionKeys"; + +describe("sessionKey helpers", () => { + it("buildAgentMainSessionKey formats agent session key", () => { + expect(buildAgentMainSessionKey("agent-1", "main")).toBe("agent:agent-1:main"); + }); + + it("parseAgentIdFromSessionKey extracts agent id", () => { + expect(parseAgentIdFromSessionKey("agent:agent-1:main")).toBe("agent-1"); + }); + + it("parseAgentIdFromSessionKey returns null when missing", () => { + expect(parseAgentIdFromSessionKey("")).toBeNull(); + }); + + it("isSameSessionKey requires exact session key match", () => { + expect(isSameSessionKey("agent:main:studio:one", "agent:main:studio:one")).toBe(true); + expect(isSameSessionKey("agent:main:studio:one", "agent:main:discord:one")).toBe(false); + }); + + it("isSameSessionKey trims whitespace", () => { + expect(isSameSessionKey(" agent:main:studio:one ", "agent:main:studio:one")).toBe(true); + }); +}); diff --git a/tests/unit/slugifyName.test.ts b/tests/unit/slugifyName.test.ts new file mode 100644 index 00000000..7f9f7394 --- /dev/null +++ b/tests/unit/slugifyName.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; + +import { slugifyName } from "@/lib/ids/slugify"; + +describe("slugifyName", () => { + it("slugifies names", () => { + expect(slugifyName("My Project")).toBe("my-project"); + }); + + it("throws on empty slugs", () => { + expect(() => slugifyName("!!!")).toThrow("Name produced an empty folder name."); + }); +}); diff --git a/tests/unit/slugifyProjectName.test.ts b/tests/unit/slugifyProjectName.test.ts deleted file mode 100644 index 880bbecc..00000000 --- a/tests/unit/slugifyProjectName.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { slugifyProjectName } from "@/lib/ids/slugify"; - -describe("slugifyProjectName", () => { - it("slugifies project names", () => { - expect(slugifyProjectName("My Project")).toBe("my-project"); - }); - - it("throws on empty slugs", () => { - expect(() => slugifyProjectName("!!!")).toThrow( - "Workspace name produced an empty folder name." - ); - }); -}); diff --git a/tests/unit/studioSettings.test.ts b/tests/unit/studioSettings.test.ts new file mode 100644 index 00000000..550d5696 --- /dev/null +++ b/tests/unit/studioSettings.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "vitest"; + +import { + mergeStudioSettings, + normalizeStudioSettings, + resolveStudioSessionId, +} from "@/lib/studio/settings"; +import { toGatewayHttpUrl } from "@/lib/gateway/url"; + +describe("studio settings normalization", () => { + it("returns defaults for empty input", () => { + const normalized = normalizeStudioSettings(null); + expect(normalized.version).toBe(1); + expect(normalized.gateway).toBeNull(); + expect(normalized.focused).toEqual({}); + expect(normalized.sessions).toEqual({}); + }); + + it("normalizes gateway entries", () => { + const normalized = normalizeStudioSettings({ + gateway: { url: " ws://localhost:18789 ", token: " token " }, + }); + + expect(normalized.gateway?.url).toBe("ws://localhost:18789"); + expect(normalized.gateway?.token).toBe("token"); + }); + + it("normalizes_dual_mode_preferences", () => { + const normalized = normalizeStudioSettings({ + focused: { + " ws://localhost:18789 ": { + mode: "focused", + selectedAgentId: " agent-2 ", + filter: "running", + }, + bad: { + mode: "nope", + selectedAgentId: 12, + filter: "bad-filter", + }, + }, + }); + + expect(normalized.focused["ws://localhost:18789"]).toEqual({ + mode: "focused", + selectedAgentId: "agent-2", + filter: "running", + }); + expect(normalized.focused.bad).toEqual({ + mode: "focused", + selectedAgentId: null, + filter: "all", + }); + }); + + it("merges_dual_mode_preferences", () => { + const current = normalizeStudioSettings({ + focused: { + "ws://localhost:18789": { + mode: "focused", + selectedAgentId: "main", + filter: "all", + }, + }, + }); + + const merged = mergeStudioSettings(current, { + focused: { + "ws://localhost:18789": { + filter: "needs-attention", + }, + }, + }); + + expect(merged.focused["ws://localhost:18789"]).toEqual({ + mode: "focused", + selectedAgentId: "main", + filter: "needs-attention", + }); + }); + + it("normalizes_studio_sessions", () => { + const normalized = normalizeStudioSettings({ + sessions: { + " ws://localhost:18789 ": " abc-123 ", + bad: "", + }, + }); + expect(normalized.sessions).toEqual({ + "ws://localhost:18789": "abc-123", + }); + }); + + it("merges_studio_sessions", () => { + const current = normalizeStudioSettings({ + sessions: { "ws://localhost:18789": "session-1" }, + }); + const merged = mergeStudioSettings(current, { + sessions: { + "ws://localhost:18789": "session-2", + "ws://localhost:18790": "session-3", + }, + }); + expect(merged.sessions).toEqual({ + "ws://localhost:18789": "session-2", + "ws://localhost:18790": "session-3", + }); + }); + + it("resolves_studio_session_id_by_gateway", () => { + const settings = normalizeStudioSettings({ + sessions: { "ws://localhost:18789": "session-1" }, + }); + expect(resolveStudioSessionId(settings, "ws://localhost:18789")).toBe("session-1"); + expect(resolveStudioSessionId(settings, "ws://localhost:18790")).toBeNull(); + }); +}); + +describe("gateway url conversion", () => { + it("converts ws urls to http", () => { + expect(toGatewayHttpUrl("ws://localhost:18789")).toBe("http://localhost:18789"); + expect(toGatewayHttpUrl("wss://gw.example")).toBe("https://gw.example"); + }); + + it("leaves http urls unchanged", () => { + expect(toGatewayHttpUrl("http://localhost:18789")).toBe("http://localhost:18789"); + expect(toGatewayHttpUrl("https://gw.example")) + .toBe("https://gw.example"); + }); +}); diff --git a/tests/unit/studioSettingsCoordinator.test.ts b/tests/unit/studioSettingsCoordinator.test.ts new file mode 100644 index 00000000..f29fef1a --- /dev/null +++ b/tests/unit/studioSettingsCoordinator.test.ts @@ -0,0 +1,91 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { defaultStudioSettings } from "@/lib/studio/settings"; +import { StudioSettingsCoordinator } from "@/lib/studio/coordinator"; + +describe("StudioSettingsCoordinator", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("coalesces multiple scheduled patches into one update", async () => { + const fetchSettings = vi.fn(async () => ({ settings: defaultStudioSettings() })); + const updateSettings = vi.fn(async () => ({ settings: defaultStudioSettings() })); + const coordinator = new StudioSettingsCoordinator({ fetchSettings, updateSettings }, 300); + + coordinator.schedulePatch({ + gateway: { url: "ws://localhost:18789", token: "abc" }, + }); + coordinator.schedulePatch({ + focused: { + "ws://localhost:18789": { + mode: "focused", + filter: "running", + selectedAgentId: null, + }, + }, + }); + + await vi.advanceTimersByTimeAsync(300); + + expect(updateSettings).toHaveBeenCalledTimes(1); + expect(updateSettings).toHaveBeenCalledWith({ + gateway: { url: "ws://localhost:18789", token: "abc" }, + focused: { + "ws://localhost:18789": { + mode: "focused", + filter: "running", + selectedAgentId: null, + }, + }, + }); + + coordinator.dispose(); + }); + + it("flushPending persists queued patch immediately", async () => { + const fetchSettings = vi.fn(async () => ({ settings: defaultStudioSettings() })); + const updateSettings = vi.fn(async () => ({ settings: defaultStudioSettings() })); + const coordinator = new StudioSettingsCoordinator({ fetchSettings, updateSettings }, 1000); + + coordinator.schedulePatch({ + sessions: { "ws://localhost:18789": "session-a" }, + }); + + await coordinator.flushPending(); + + expect(updateSettings).toHaveBeenCalledTimes(1); + expect(updateSettings).toHaveBeenCalledWith({ + sessions: { "ws://localhost:18789": "session-a" }, + }); + + await vi.advanceTimersByTimeAsync(2000); + expect(updateSettings).toHaveBeenCalledTimes(1); + + coordinator.dispose(); + }); + + it("dispose clears pending timer without writing", async () => { + const fetchSettings = vi.fn(async () => ({ settings: defaultStudioSettings() })); + const updateSettings = vi.fn(async () => ({ settings: defaultStudioSettings() })); + const coordinator = new StudioSettingsCoordinator({ fetchSettings, updateSettings }, 200); + + coordinator.schedulePatch({ + focused: { + "ws://localhost:18789": { + mode: "focused", + filter: "idle", + selectedAgentId: null, + }, + }, + }); + coordinator.dispose(); + + await vi.advanceTimersByTimeAsync(500); + + expect(updateSettings).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/themeToggle.test.ts b/tests/unit/themeToggle.test.ts new file mode 100644 index 00000000..2d33afbc --- /dev/null +++ b/tests/unit/themeToggle.test.ts @@ -0,0 +1,80 @@ +import { createElement } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; + +import { ThemeToggle } from "@/components/theme-toggle"; + +const buildMatchMedia = (matches: boolean) => + vi.fn().mockImplementation((query: string) => ({ + matches, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })); + +const createLocalStorageMock = () => { + let store: Record<string, string> = {}; + + return { + getItem: (key: string) => (key in store ? store[key] : null), + setItem: (key: string, value: string) => { + store[key] = String(value); + }, + removeItem: (key: string) => { + delete store[key]; + }, + clear: () => { + store = {}; + }, + key: (index: number) => Object.keys(store)[index] ?? null, + get length() { + return Object.keys(store).length; + }, + }; +}; + +describe("ThemeToggle", () => { + beforeEach(() => { + // Some JS DOM shims expose a partial localStorage; tests need a full Storage-like API. + Object.defineProperty(window, "localStorage", { + value: createLocalStorageMock(), + configurable: true, + }); + + document.documentElement.classList.remove("dark"); + window.localStorage.clear(); + vi.stubGlobal("matchMedia", buildMatchMedia(false)); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + it("applies and persists theme when toggled", () => { + render(createElement(ThemeToggle)); + + fireEvent.click(screen.getByRole("button", { name: "Switch to dark mode" })); + expect(document.documentElement).toHaveClass("dark"); + expect(window.localStorage.getItem("theme")).toBe("dark"); + + fireEvent.click(screen.getByRole("button", { name: "Switch to light mode" })); + expect(document.documentElement).not.toHaveClass("dark"); + expect(window.localStorage.getItem("theme")).toBe("light"); + }); + + it("reads and applies stored theme on mount", async () => { + window.localStorage.setItem("theme", "dark"); + + render(createElement(ThemeToggle)); + + await waitFor(() => { + expect(document.documentElement).toHaveClass("dark"); + }); + expect(screen.getByRole("button", { name: "Switch to light mode" })).toBeInTheDocument(); + }); +}); diff --git a/tests/unit/toolsInvokeUrl.test.ts b/tests/unit/toolsInvokeUrl.test.ts new file mode 100644 index 00000000..3d94fabe --- /dev/null +++ b/tests/unit/toolsInvokeUrl.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; + +import { toGatewayHttpUrl } from "@/lib/gateway/url"; + +describe("toGatewayHttpUrl", () => { + it("converts wss to https", () => { + expect(toGatewayHttpUrl("wss://example.com")).toBe("https://example.com"); + }); + + it("converts ws to http", () => { + expect(toGatewayHttpUrl("ws://127.0.0.1:18789")).toBe("http://127.0.0.1:18789"); + }); + + it("passes through http urls", () => { + expect(toGatewayHttpUrl("http://localhost:18789")).toBe("http://localhost:18789"); + }); +}); diff --git a/tests/unit/worktreeHelpers.test.ts b/tests/unit/worktreeHelpers.test.ts new file mode 100644 index 00000000..5863b53a --- /dev/null +++ b/tests/unit/worktreeHelpers.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; + +import { buildAgentInstruction } from "@/lib/text/message-metadata"; + +describe("buildAgentInstruction", () => { + it("returns plain message text", () => { + const message = buildAgentInstruction({ + message: "Ship it", + }); + + expect(message).toBe("Ship it"); + }); +});