Add configuration and testing infrastructure for Clawdbot Agent UI

- Introduced components.json for UI configuration with Tailwind and icon library settings.
- Updated ESLint configuration to include Prettier for consistent code formatting.
- Added Playwright and Vitest configurations for end-to-end and unit testing.
- Implemented environment variable validation using Zod and added logging utilities.
- Refactored API routes to improve error handling and logging.
- Enhanced global styles and markdown rendering capabilities.
- Established a new directory structure for better organization of features and components.
This commit is contained in:
George Pickett
2026-01-27 12:41:25 -08:00
parent 0cb5727877
commit af7554f9cd
50 changed files with 3829 additions and 385 deletions
+199
View File
@@ -0,0 +1,199 @@
# 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<T>(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<T>(input: RequestInfo | URL, init?: RequestInit): Promise<T>` that throws an `Error` with the response bodys `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.
+108 -72
View File
@@ -1,150 +1,186 @@
# 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, and commit frequently.
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 projects 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: <title>" --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.
- [ ] Example incomplete step.
- [ ] Example partially completed step (completed: X; remaining: Y).
Use timestamps to measure rates of progress.
- [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 <projects 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.
@@ -0,0 +1,5 @@
Recent session logs on 2026-01-26 show tool calls failing when XAI_API_KEY is missing and gateway configuration is unavailable, while this UI only reads gateway config via /api/gateway (src/app/api/gateway/route.ts) and relies on optional envs in src/lib/env.ts. The result is that users see generic errors or a silent failure without actionable guidance, especially when the config file is missing in the locations resolved by src/lib/clawdbot/config.ts.
Add a server route such as /api/config/health that returns the resolved config path, gateway URL/token presence, and missing env keys, using the same resolution logic in src/lib/clawdbot/config.ts and src/lib/clawdbot/gateway.ts. Update the HeaderBar or page UI to show a dismissible diagnostic banner with clear actions (copy config path, retry load, open settings) and plumb error codes into state so the UI can differentiate missing config vs missing env vs gateway auth.
Acceptance criteria: when config is missing, the UI displays the exact resolved path and a next action; when env keys are missing, a warning is shown but the app still loads; unit tests cover the new API response shape. Open questions: which env keys are required for a healthy UI, and should we attempt a lightweight gateway handshake to confirm reachability or keep the check config-only.
@@ -0,0 +1,5 @@
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.
@@ -0,0 +1,5 @@
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.
@@ -0,0 +1,5 @@
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.
@@ -0,0 +1,5 @@
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.
File diff suppressed because one or more lines are too long
@@ -0,0 +1,7 @@
{
"telemetry": {
"notifiedAt": "1769545120941",
"anonymousId": "3f54b52ec8bbe85520879d44e931326f6fcd173b5a609a81139c9da573ca3142",
"salt": "22ea424b2c776e6b5015e872856b1ff8"
}
}
+22
View File
@@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "zinc",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}
+2
View File
@@ -1,6 +1,7 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
import prettier from "eslint-config-prettier/flat";
const eslintConfig = defineConfig([
...nextVitals,
@@ -13,6 +14,7 @@ const eslintConfig = defineConfig([
"build/**",
"next-env.d.ts",
]),
prettier,
]);
export default eslintConfig;
+2578 -4
View File
File diff suppressed because it is too large Load Diff
+21 -3
View File
@@ -6,23 +6,41 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"test": "vitest",
"e2e": "playwright test"
},
"dependencies": {
"@radix-ui/react-slot": "^1.2.4",
"@vercel/otel": "^2.1.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.563.0",
"next": "16.1.4",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1"
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.4.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@playwright/test": "^1.58.0",
"@tailwindcss/postcss": "^4",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.4",
"eslint-config-prettier": "^10.1.8",
"jsdom": "^27.4.0",
"prettier": "^3.8.1",
"tailwindcss": "^4",
"typescript": "^5"
"tw-animate-css": "^1.4.0",
"typescript": "^5",
"vitest": "^4.0.18"
}
}
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./tests/e2e",
use: {
baseURL: "http://127.0.0.1:3000",
},
webServer: {
command: "npm run dev",
port: 3000,
reuseExistingServer: !process.env.CI,
},
});
+1 -1
View File
@@ -1,4 +1,4 @@
import { createDiscordChannelForAgent } from "../src/lib/discord/discordChannel";
import { createDiscordChannelForAgent } from "@/lib/discord/discordChannel";
const args = new Map<string, string>();
for (const entry of process.argv.slice(2)) {
+11 -83
View File
@@ -1,94 +1,22 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
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";
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 = process.env.MOLTBOT_STATE_DIR ?? process.env.CLAWDBOT_STATE_DIR;
if (raw?.trim()) {
return resolveUserPath(raw);
}
return path.join(os.homedir(), LEGACY_STATE_DIRNAME);
};
const resolveConfigPathCandidates = () => {
const explicit = process.env.MOLTBOT_CONFIG_PATH ?? process.env.CLAWDBOT_CONFIG_PATH;
if (explicit?.trim()) {
return [resolveUserPath(explicit)];
}
const candidates: string[] = [];
if (process.env.MOLTBOT_STATE_DIR?.trim()) {
candidates.push(path.join(resolveUserPath(process.env.MOLTBOT_STATE_DIR), CONFIG_FILENAME));
}
if (process.env.CLAWDBOT_STATE_DIR?.trim()) {
candidates.push(path.join(resolveUserPath(process.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 parseJsonLoose = (raw: string) => {
try {
return JSON.parse(raw) as Record<string, unknown>;
} catch {
const cleaned = raw.replace(/,(\s*[}\]])/g, "$1");
return JSON.parse(cleaned) as Record<string, unknown>;
}
};
const resolveGatewayUrl = (config: Record<string, unknown>) => {
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";
return `ws://${host}:${port}`;
};
const resolveGatewayToken = (config: Record<string, unknown>) => {
const gateway = (config.gateway ?? {}) as Record<string, unknown>;
const auth = (gateway.auth ?? {}) as Record<string, unknown>;
return typeof auth.token === "string" ? auth.token : "";
};
export async function GET() {
try {
const candidates = resolveConfigPathCandidates();
const fallbackPath = path.join(resolveStateDir(), CONFIG_FILENAME);
const configPath = candidates.find((candidate) => fs.existsSync(candidate)) ?? fallbackPath;
if (!fs.existsSync(configPath)) {
return NextResponse.json(
{ error: `Missing config at ${configPath}.` },
{ status: 404 }
);
}
const raw = fs.readFileSync(configPath, "utf8");
const config = parseJsonLoose(raw);
return NextResponse.json({
gatewayUrl: resolveGatewayUrl(config),
token: resolveGatewayToken(config),
});
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 });
}
}
@@ -1,7 +1,8 @@
import { NextResponse } from "next/server";
import { createDiscordChannelForAgent } from "../../../../../src/lib/discord/discordChannel";
import { resolveAgentWorkspaceDir } from "../../../../../src/lib/projects/agentWorkspace";
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";
@@ -50,7 +51,7 @@ export async function POST(
return NextResponse.json(result);
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to create Discord channel.";
console.error(message);
logger.error(message);
return NextResponse.json({ error: message }, { status: 500 });
}
}
+4 -3
View File
@@ -4,12 +4,13 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { logger } from "@/lib/logger";
import {
loadClawdbotConfig,
removeAgentEntry,
saveClawdbotConfig,
} from "../../../../src/lib/clawdbot/config";
import { resolveAgentWorkspaceDir } from "../../../../src/lib/projects/agentWorkspace";
} from "@/lib/clawdbot/config";
import { resolveAgentWorkspaceDir } from "@/lib/projects/agentWorkspace";
import { loadStore, saveStore } from "../store";
export const runtime = "nodejs";
@@ -67,7 +68,7 @@ export async function DELETE(
return NextResponse.json({ store: nextStore, warnings });
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to delete workspace.";
console.error(message);
logger.error(message);
return NextResponse.json({ error: message }, { status: 500 });
}
}
@@ -4,16 +4,17 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { ProjectTileRenamePayload } from "../../../../../../src/lib/projects/types";
import { resolveAgentWorkspaceDir } from "../../../../../../src/lib/projects/agentWorkspace";
import { logger } from "@/lib/logger";
import type { ProjectTileRenamePayload } from "@/lib/projects/types";
import { resolveAgentWorkspaceDir } from "@/lib/projects/agentWorkspace";
import {
loadClawdbotConfig,
removeAgentEntry,
renameAgentEntry,
saveClawdbotConfig,
upsertAgentEntry,
} from "../../../../../../src/lib/clawdbot/config";
import { generateAgentId } from "../../../../../../src/lib/ids/agentId";
} from "@/lib/clawdbot/config";
import { generateAgentId } from "@/lib/ids/agentId";
import { loadStore, saveStore } from "../../../store";
export const runtime = "nodejs";
@@ -77,7 +78,7 @@ export async function DELETE(
return NextResponse.json({ store: nextStore, warnings });
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to delete tile.";
console.error(message);
logger.error(message);
return NextResponse.json({ error: message }, { status: 500 });
}
}
@@ -208,7 +209,7 @@ export async function PATCH(
return NextResponse.json({ store: nextStore, warnings });
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to rename tile.";
console.error(message);
logger.error(message);
return NextResponse.json({ error: message }, { status: 500 });
}
}
@@ -5,20 +5,21 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { logger } from "@/lib/logger";
import type {
ProjectTile,
ProjectTileCreatePayload,
ProjectTileCreateResult,
ProjectTileRole,
ProjectsStore,
} from "../../../../../src/lib/projects/types";
import { resolveAgentWorkspaceDir } from "../../../../../src/lib/projects/agentWorkspace";
} from "@/lib/projects/types";
import { resolveAgentWorkspaceDir } from "@/lib/projects/agentWorkspace";
import {
loadClawdbotConfig,
saveClawdbotConfig,
upsertAgentEntry,
} from "../../../../../src/lib/clawdbot/config";
import { generateAgentId } from "../../../../../src/lib/ids/agentId";
} from "@/lib/clawdbot/config";
import { generateAgentId } from "@/lib/ids/agentId";
import { loadStore, saveStore } from "../../store";
export const runtime = "nodejs";
@@ -219,7 +220,7 @@ export async function POST(
warnings.push(`Agent config not updated: ${message}`);
}
if (warnings.length > 0) {
console.warn(`Tile created with warnings: ${warnings.join(" ")}`);
logger.warn(`Tile created with warnings: ${warnings.join(" ")}`);
}
const result: ProjectTileCreateResult = {
@@ -230,7 +231,7 @@ export async function POST(
return NextResponse.json(result);
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to create tile.";
console.error(message);
logger.error(message);
return NextResponse.json({ error: message }, { status: 500 });
}
}
+4 -3
View File
@@ -5,12 +5,13 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { logger } from "@/lib/logger";
import type {
Project,
ProjectOpenPayload,
ProjectOpenResult,
ProjectsStore,
} from "../../../../src/lib/projects/types";
} from "@/lib/projects/types";
import { loadStore, saveStore } from "../store";
export const runtime = "nodejs";
@@ -109,7 +110,7 @@ export async function POST(request: Request) {
saveStore(nextStore);
if (warnings.length > 0) {
console.warn(`Workspace opened with warnings: ${warnings.join(" ")}`);
logger.warn(`Workspace opened with warnings: ${warnings.join(" ")}`);
}
const result: ProjectOpenResult = {
@@ -120,7 +121,7 @@ export async function POST(request: Request) {
return NextResponse.json(result);
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to open workspace.";
console.error(message);
logger.error(message);
return NextResponse.json({ error: message }, { status: 500 });
}
}
+8 -7
View File
@@ -5,14 +5,15 @@ 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 "../../../src/lib/projects/types";
import { ensureGitRepo } from "../../../src/lib/fs/git";
import { slugifyProjectName } from "../../../src/lib/ids/slugify";
} 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";
@@ -37,7 +38,7 @@ export async function GET() {
return NextResponse.json(store);
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to load workspaces.";
console.error(message);
logger.error(message);
return NextResponse.json({ error: message }, { status: 500 });
}
}
@@ -84,7 +85,7 @@ export async function POST(request: Request) {
saveStore(nextStore);
if (warnings.length > 0) {
console.warn(`Workspace created with warnings: ${warnings.join(" ")}`);
logger.warn(`Workspace created with warnings: ${warnings.join(" ")}`);
}
const result: ProjectCreateResult = {
@@ -95,7 +96,7 @@ export async function POST(request: Request) {
return NextResponse.json(result);
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to create workspace.";
console.error(message);
logger.error(message);
return NextResponse.json({ error: message }, { status: 500 });
}
}
@@ -111,7 +112,7 @@ export async function PUT(request: Request) {
return NextResponse.json(normalized);
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to save workspaces.";
console.error(message);
logger.error(message);
return NextResponse.json({ error: message }, { status: 500 });
}
}
+1 -1
View File
@@ -2,7 +2,7 @@ import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import type { Project, ProjectsStore } from "../../../src/lib/projects/types";
import type { Project, ProjectsStore } from "@/lib/projects/types";
const STORE_VERSION: ProjectsStore["version"] = 2;
const STORE_DIR = path.join(os.homedir(), ".clawdbot", "agent-canvas");
+115 -109
View File
@@ -1,13 +1,45 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "./styles/markdown.css";
@custom-variant dark (&:is(.dark *));
:root {
--background: #f4efe7;
--foreground: #201b16;
--panel: rgba(255, 255, 255, 0.82);
--panel-border: rgba(25, 20, 16, 0.12);
--accent: #3b82f6;
--accent: oklch(0.967 0.001 286.375);
--accent-strong: #2563eb;
--muted: #6f655c;
--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);
}
@theme inline {
@@ -15,6 +47,42 @@
--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);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
}
body {
@@ -22,7 +90,6 @@ body {
background: radial-gradient(1000px circle at 10% -10%, #f7dac1 0%, transparent 55%),
radial-gradient(900px circle at 90% -20%, #cfe2f5 0%, transparent 48%),
#f4efe7;
color: var(--foreground);
font-family: var(--font-display), sans-serif;
}
@@ -55,110 +122,6 @@ body {
animation: fadeUp 600ms ease-out 120ms both;
}
.agent-markdown {
display: block;
white-space: pre-wrap;
word-break: break-word;
}
.agent-markdown p {
margin: 0;
}
.agent-markdown p + p,
.agent-markdown ul,
.agent-markdown ol,
.agent-markdown pre,
.agent-markdown blockquote,
.agent-markdown table {
margin-top: 0.35rem;
}
.agent-markdown h1,
.agent-markdown h2,
.agent-markdown h3,
.agent-markdown h4,
.agent-markdown h5,
.agent-markdown h6 {
margin: 0 0 0.25rem 0;
}
.agent-markdown h1 + p,
.agent-markdown h2 + p,
.agent-markdown h3 + p,
.agent-markdown h4 + p,
.agent-markdown h5 + p,
.agent-markdown h6 + p,
.agent-markdown h1 + ul,
.agent-markdown h2 + ul,
.agent-markdown h3 + ul,
.agent-markdown h4 + ul,
.agent-markdown h5 + ul,
.agent-markdown h6 + ul,
.agent-markdown h1 + ol,
.agent-markdown h2 + ol,
.agent-markdown h3 + ol,
.agent-markdown h4 + ol,
.agent-markdown h5 + ol,
.agent-markdown h6 + ol {
margin-top: 0.15rem;
}
.agent-markdown ul,
.agent-markdown ol {
padding-left: 1.1rem;
}
.agent-markdown li {
margin-top: 0.15rem;
}
.agent-markdown code {
font-family: var(--font-code), 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;
}
.agent-markdown pre {
background: rgba(15, 23, 42, 0.06);
border-radius: 0.5rem;
padding: 0.6rem 0.75rem;
overflow-x: auto;
}
.agent-markdown pre code {
background: transparent;
padding: 0;
font-size: 0.85em;
}
.agent-markdown blockquote {
border-left: 3px solid rgba(15, 23, 42, 0.2);
padding-left: 0.75rem;
color: rgba(15, 23, 42, 0.7);
}
.agent-markdown table {
width: 100%;
border-collapse: collapse;
font-size: 0.85em;
}
.agent-markdown th,
.agent-markdown td {
border: 1px solid rgba(15, 23, 42, 0.15);
padding: 0.3rem 0.5rem;
text-align: left;
}
.agent-markdown a {
color: var(--accent-strong);
text-decoration: underline;
}
@keyframes fadeUp {
from {
opacity: 0;
@@ -169,3 +132,46 @@ body {
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;
}
}
+12 -11
View File
@@ -1,19 +1,19 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { CanvasViewport } from "../src/components/CanvasViewport";
import { HeaderBar } from "../src/components/HeaderBar";
import { extractText } from "../src/lib/text/extractText";
import { useGatewayConnection } from "../src/lib/gateway/useGatewayConnection";
import type { EventFrame } from "../src/lib/gateway/frames";
import { CanvasViewport } from "@/features/canvas/components/CanvasViewport";
import { HeaderBar } from "@/features/canvas/components/HeaderBar";
import { extractText } from "@/lib/text/extractText";
import { useGatewayConnection } from "@/lib/gateway/useGatewayConnection";
import type { EventFrame } from "@/lib/gateway/frames";
import {
AgentCanvasProvider,
getActiveProject,
useAgentCanvasStore,
} from "../src/state/store";
import { createProjectDiscordChannel } from "../src/lib/projects/client";
import type { AgentTile, ProjectRuntime } from "../src/state/store";
import { CANVAS_BASE_ZOOM } from "../src/lib/canvasDefaults";
} from "@/features/canvas/state/store";
import { createProjectDiscordChannel } from "@/lib/projects/client";
import type { AgentTile, ProjectRuntime } from "@/features/canvas/state/store";
import { CANVAS_BASE_ZOOM } from "@/lib/canvasDefaults";
type ChatEventPayload = {
runId: string;
@@ -363,11 +363,12 @@ const AgentCanvasPage = () => {
);
useEffect(() => {
const polls = historyPollsRef.current;
return () => {
for (const timeoutId of historyPollsRef.current.values()) {
for (const timeoutId of polls.values()) {
window.clearTimeout(timeoutId);
}
historyPollsRef.current.clear();
polls.clear();
};
}, []);
+103
View File
@@ -0,0 +1,103 @@
.agent-markdown {
display: block;
white-space: pre-wrap;
word-break: break-word;
}
.agent-markdown p {
margin: 0;
}
.agent-markdown p + p,
.agent-markdown ul,
.agent-markdown ol,
.agent-markdown pre,
.agent-markdown blockquote,
.agent-markdown table {
margin-top: 0.35rem;
}
.agent-markdown h1,
.agent-markdown h2,
.agent-markdown h3,
.agent-markdown h4,
.agent-markdown h5,
.agent-markdown h6 {
margin: 0 0 0.25rem 0;
}
.agent-markdown h1 + p,
.agent-markdown h2 + p,
.agent-markdown h3 + p,
.agent-markdown h4 + p,
.agent-markdown h5 + p,
.agent-markdown h6 + p,
.agent-markdown h1 + ul,
.agent-markdown h2 + ul,
.agent-markdown h3 + ul,
.agent-markdown h4 + ul,
.agent-markdown h5 + ul,
.agent-markdown h6 + ul,
.agent-markdown h1 + ol,
.agent-markdown h2 + ol,
.agent-markdown h3 + ol,
.agent-markdown h4 + ol,
.agent-markdown h5 + ol,
.agent-markdown h6 + ol {
margin-top: 0.15rem;
}
.agent-markdown ul,
.agent-markdown ol {
padding-left: 1.1rem;
}
.agent-markdown li {
margin-top: 0.15rem;
}
.agent-markdown code {
font-family: var(--font-code), 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;
}
.agent-markdown pre {
background: rgba(15, 23, 42, 0.06);
border-radius: 0.5rem;
padding: 0.6rem 0.75rem;
overflow-x: auto;
}
.agent-markdown pre code {
background: transparent;
padding: 0;
font-size: 0.85em;
}
.agent-markdown blockquote {
border-left: 3px solid rgba(15, 23, 42, 0.2);
padding-left: 0.75rem;
color: rgba(15, 23, 42, 0.7);
}
.agent-markdown table {
width: 100%;
border-collapse: collapse;
font-size: 0.85em;
}
.agent-markdown th,
.agent-markdown td {
border: 1px solid rgba(15, 23, 42, 0.15);
padding: 0.3rem 0.5rem;
text-align: left;
}
.agent-markdown a {
color: var(--accent-strong);
text-decoration: underline;
}
+64
View File
@@ -0,0 +1,64 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+1 -1
View File
@@ -2,7 +2,7 @@ import type React from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { useCallback, useEffect, useRef, useState } from "react";
import type { AgentTile as AgentTileType, TilePosition, TileSize } from "../state/store";
import type { AgentTile as AgentTileType, TilePosition, TileSize } from "@/features/canvas/state/store";
const MIN_SIZE = { width: 560, height: 440 };
@@ -1,6 +1,6 @@
import type React from "react";
import { useCallback, useMemo, useRef } from "react";
import type { AgentTile, CanvasTransform, TilePosition, TileSize } from "../state/store";
import type { AgentTile, CanvasTransform, TilePosition, TileSize } from "@/features/canvas/state/store";
import { AgentTile as AgentTileComponent } from "./AgentTile";
type CanvasViewportProps = {
@@ -1,4 +1,4 @@
import type { GatewayStatus } from "../lib/gateway/GatewayClient";
import type { GatewayStatus } from "@/lib/gateway/GatewayClient";
type ConnectionPanelProps = {
gatewayUrl: string;
+1 -1
View File
@@ -1,4 +1,4 @@
import type { GatewayStatus } from "../lib/gateway/GatewayClient";
import type { GatewayStatus } from "@/lib/gateway/GatewayClient";
type HeaderBarProps = {
projects: Array<{ id: string; name: string }>;
+3 -3
View File
@@ -11,8 +11,8 @@ import {
type ReactNode,
} from "react";
import type { Project, ProjectTile, ProjectsStore } from "../lib/projects/types";
import { CANVAS_BASE_ZOOM } from "../lib/canvasDefaults";
import type { Project, ProjectTile, ProjectsStore } from "@/lib/projects/types";
import { CANVAS_BASE_ZOOM } from "@/lib/canvasDefaults";
import {
createProjectTile as apiCreateProjectTile,
createProject as apiCreateProject,
@@ -22,7 +22,7 @@ import {
openProject as apiOpenProject,
renameProjectTile as apiRenameProjectTile,
saveProjectsStore,
} from "../lib/projects/client";
} from "@/lib/projects/client";
export type AgentStatus = "idle" | "running" | "error";
+5
View File
@@ -0,0 +1,5 @@
import { registerTracing } from "@/lib/tracing";
export const register = () => {
registerTracing();
};
+8 -6
View File
@@ -2,6 +2,8 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { env } from "@/lib/env";
type ClawdbotConfig = Record<string, unknown>;
type AgentEntry = {
@@ -25,7 +27,7 @@ const resolveUserPath = (input: string) => {
};
const resolveStateDir = () => {
const raw = process.env.MOLTBOT_STATE_DIR ?? process.env.CLAWDBOT_STATE_DIR;
const raw = env.MOLTBOT_STATE_DIR ?? env.CLAWDBOT_STATE_DIR;
if (raw?.trim()) {
return resolveUserPath(raw);
}
@@ -33,16 +35,16 @@ const resolveStateDir = () => {
};
const resolveConfigPathCandidates = () => {
const explicit = process.env.MOLTBOT_CONFIG_PATH ?? process.env.CLAWDBOT_CONFIG_PATH;
const explicit = env.MOLTBOT_CONFIG_PATH ?? env.CLAWDBOT_CONFIG_PATH;
if (explicit?.trim()) {
return [resolveUserPath(explicit)];
}
const candidates: string[] = [];
if (process.env.MOLTBOT_STATE_DIR?.trim()) {
candidates.push(path.join(resolveUserPath(process.env.MOLTBOT_STATE_DIR), CONFIG_FILENAME));
if (env.MOLTBOT_STATE_DIR?.trim()) {
candidates.push(path.join(resolveUserPath(env.MOLTBOT_STATE_DIR), CONFIG_FILENAME));
}
if (process.env.CLAWDBOT_STATE_DIR?.trim()) {
candidates.push(path.join(resolveUserPath(process.env.CLAWDBOT_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));
+16
View File
@@ -0,0 +1,16 @@
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 };
};
+11
View File
@@ -0,0 +1,11 @@
import { z } from "zod";
const envSchema = z.object({
MOLTBOT_STATE_DIR: z.string().optional(),
CLAWDBOT_STATE_DIR: z.string().optional(),
MOLTBOT_CONFIG_PATH: z.string().optional(),
CLAWDBOT_CONFIG_PATH: z.string().optional(),
NEXT_PUBLIC_GATEWAY_URL: z.string().optional(),
});
export const env = envSchema.parse(process.env);
+6 -5
View File
@@ -1,3 +1,4 @@
import { logger } from "@/lib/logger";
import { EventFrame, GatewayFrame, ReqFrame, ResFrame } from "./frames";
type PendingRequest = {
@@ -87,7 +88,7 @@ export class GatewayClient {
});
socket.addEventListener("error", () => {
console.error("Gateway socket error.");
logger.error("Gateway socket error.");
});
try {
@@ -116,7 +117,7 @@ export class GatewayClient {
await this.sendRequest("connect", connectParams);
this.updateStatus("connected");
console.info("Gateway connected.");
logger.info("Gateway connected.");
} catch (error) {
const reason =
error instanceof Error ? error : new Error("Gateway connect failed.");
@@ -138,7 +139,7 @@ export class GatewayClient {
this.lastChallenge = null;
this.clearPending(new Error("Gateway disconnected."));
this.updateStatus("disconnected");
console.info("Gateway disconnected.");
logger.info("Gateway disconnected.");
}
async call<T = unknown>(method: string, params: unknown): Promise<T> {
@@ -247,7 +248,7 @@ export class GatewayClient {
try {
parsed = JSON.parse(data) as GatewayFrame;
} catch {
console.error("Failed to parse gateway frame.");
logger.error("Failed to parse gateway frame.");
return;
}
@@ -296,7 +297,7 @@ export class GatewayClient {
this.lastChallenge = null;
this.clearPending(new Error("Gateway disconnected."));
this.updateStatus("disconnected");
console.info("Gateway socket closed.");
logger.info("Gateway socket closed.");
}
private clearPending(error: Error) {
+2 -1
View File
@@ -6,8 +6,9 @@ import {
GatewayResponseError,
GatewayStatus,
} from "./GatewayClient";
import { env } from "@/lib/env";
const DEFAULT_GATEWAY_URL = "ws://127.0.0.1:18789";
const DEFAULT_GATEWAY_URL = env.NEXT_PUBLIC_GATEWAY_URL ?? "ws://127.0.0.1:18789";
const formatGatewayError = (error: unknown) => {
if (error instanceof GatewayResponseError) {
return `Gateway error (${error.code}): ${error.message}`;
+23
View File
@@ -0,0 +1,23 @@
export const fetchJson = async <T>(
input: RequestInfo | URL,
init?: RequestInit
): Promise<T> => {
const res = await fetch(input, init);
const text = await res.text();
let data: unknown = null;
if (text) {
try {
data = JSON.parse(text);
} catch {
data = null;
}
}
if (!res.ok) {
const errorMessage =
data && typeof data === "object" && "error" in data && typeof data.error === "string"
? data.error
: `Request failed with status ${res.status}.`;
throw new Error(errorMessage);
}
return data as T;
};
+6
View File
@@ -0,0 +1,6 @@
export const logger = {
info: (...args: unknown[]) => console.info(...args),
warn: (...args: unknown[]) => console.warn(...args),
error: (...args: unknown[]) => console.error(...args),
debug: (...args: unknown[]) => console.debug(...args),
};
+12 -53
View File
@@ -13,111 +13,75 @@ import type {
ProjectTileRenameResult,
ProjectsStore,
} from "./types";
import { fetchJson } from "@/lib/http";
export const fetchProjectsStore = async (): Promise<ProjectsStore> => {
const res = await fetch("/api/projects", { cache: "no-store" });
if (!res.ok) {
throw new Error("Failed to load workspaces.");
}
return (await res.json()) as ProjectsStore;
return fetchJson<ProjectsStore>("/api/projects", { cache: "no-store" });
};
export const createProject = async (
payload: ProjectCreatePayload
): Promise<ProjectCreateResult> => {
const res = await fetch("/api/projects", {
return fetchJson<ProjectCreateResult>("/api/projects", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data?.error ?? "Failed to create workspace.");
}
return data as ProjectCreateResult;
};
export const openProject = async (
payload: ProjectOpenPayload
): Promise<ProjectOpenResult> => {
const res = await fetch("/api/projects/open", {
return fetchJson<ProjectOpenResult>("/api/projects/open", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data?.error ?? "Failed to open workspace.");
}
return data as ProjectOpenResult;
};
export const saveProjectsStore = async (store: ProjectsStore): Promise<ProjectsStore> => {
const res = await fetch("/api/projects", {
return fetchJson<ProjectsStore>("/api/projects", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(store),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data?.error ?? "Failed to save workspaces.");
}
return data as ProjectsStore;
};
export const deleteProject = async (projectId: string): Promise<ProjectDeleteResult> => {
const res = await fetch(`/api/projects/${projectId}`, { method: "DELETE" });
const data = await res.json();
if (!res.ok) {
throw new Error(data?.error ?? "Failed to delete workspace.");
}
return data as ProjectDeleteResult;
return fetchJson<ProjectDeleteResult>(`/api/projects/${projectId}`, {
method: "DELETE",
});
};
export const createProjectDiscordChannel = async (
projectId: string,
payload: ProjectDiscordChannelCreatePayload
): Promise<ProjectDiscordChannelCreateResult> => {
const res = await fetch(`/api/projects/${projectId}/discord`, {
return fetchJson<ProjectDiscordChannelCreateResult>(`/api/projects/${projectId}/discord`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data?.error ?? "Failed to create Discord channel.");
}
return data as ProjectDiscordChannelCreateResult;
};
export const createProjectTile = async (
projectId: string,
payload: ProjectTileCreatePayload
): Promise<ProjectTileCreateResult> => {
const res = await fetch(`/api/projects/${projectId}/tiles`, {
return fetchJson<ProjectTileCreateResult>(`/api/projects/${projectId}/tiles`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data?.error ?? "Failed to create tile.");
}
return data as ProjectTileCreateResult;
};
export const deleteProjectTile = async (
projectId: string,
tileId: string
): Promise<ProjectTileDeleteResult> => {
const res = await fetch(`/api/projects/${projectId}/tiles/${tileId}`, {
return fetchJson<ProjectTileDeleteResult>(`/api/projects/${projectId}/tiles/${tileId}`, {
method: "DELETE",
});
const data = await res.json();
if (!res.ok) {
throw new Error(data?.error ?? "Failed to delete tile.");
}
return data as ProjectTileDeleteResult;
};
export const renameProjectTile = async (
@@ -125,14 +89,9 @@ export const renameProjectTile = async (
tileId: string,
payload: ProjectTileRenamePayload
): Promise<ProjectTileRenameResult> => {
const res = await fetch(`/api/projects/${projectId}/tiles/${tileId}`, {
return fetchJson<ProjectTileRenameResult>(`/api/projects/${projectId}/tiles/${tileId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data?.error ?? "Failed to rename tile.");
}
return data as ProjectTileRenameResult;
};
+5
View File
@@ -0,0 +1,5 @@
import { registerOTel } from "@vercel/otel";
export const registerTracing = () => {
registerOTel({ serviceName: "clawdbot-agent-ui" });
};
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+4
View File
@@ -0,0 +1,4 @@
{
"status": "passed",
"failedTests": []
}
+17
View File
@@ -0,0 +1,17 @@
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();
});
+1
View File
@@ -0,0 +1 @@
import "@testing-library/jest-dom/vitest";
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it, vi, afterEach } from "vitest";
import { fetchJson } from "@/lib/http";
type MockResponse = {
ok: boolean;
status: number;
text: () => Promise<string>;
};
const createResponse = (body: string, ok: boolean, status: number): MockResponse => ({
ok,
status,
text: vi.fn().mockResolvedValue(body),
});
describe("fetchJson", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("throws when response is not ok", async () => {
const fetchMock = vi.fn().mockResolvedValue(
createResponse(JSON.stringify({ error: "Nope" }), false, 400)
);
globalThis.fetch = fetchMock as unknown as typeof fetch;
await expect(fetchJson("/api/test")).rejects.toThrow("Nope");
});
it("returns parsed JSON for ok responses", async () => {
const fetchMock = vi.fn().mockResolvedValue(
createResponse(JSON.stringify({ ok: true }), true, 200)
);
globalThis.fetch = fetchMock as unknown as typeof fetch;
await expect(fetchJson("/api/test")).resolves.toEqual({ ok: true });
});
});
+15
View File
@@ -0,0 +1,15 @@
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."
);
});
});
+1 -1
View File
@@ -19,7 +19,7 @@
}
],
"paths": {
"@/*": ["./*"]
"@/*": ["./src/*"]
}
},
"include": [
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from "vitest/config";
import { fileURLToPath } from "node:url";
export default defineConfig({
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
test: {
environment: "jsdom",
setupFiles: "./tests/setup.ts",
include: ["tests/unit/**/*.test.ts"],
exclude: ["tests/e2e/**"],
},
});