From eae98f240a2cad3a7669fa2fe8464ef7ebe7f80c Mon Sep 17 00:00:00 2001 From: Bobby Lin Date: Fri, 24 Jul 2026 11:05:23 +0800 Subject: [PATCH 01/32] chore: add mastra skills --- .agents/skills/mastra/SKILL.md | 128 +++++ .../skills/mastra/references/common-errors.md | 537 ++++++++++++++++++ .../skills/mastra/references/core-concepts.md | 17 + .../skills/mastra/references/create-mastra.md | 222 ++++++++ .../skills/mastra/references/embedded-docs.md | 103 ++++ .../skills/mastra/references/mastra-api.md | 201 +++++++ .../mastra/references/migration-guide.md | 180 ++++++ .../mastra/references/model-selection.md | 24 + .../skills/mastra/references/remote-docs.md | 193 +++++++ .../mastra/scripts/provider-registry.mjs | 180 ++++++ skills-lock.json | 204 +++---- 11 files changed, 1890 insertions(+), 99 deletions(-) create mode 100644 .agents/skills/mastra/SKILL.md create mode 100644 .agents/skills/mastra/references/common-errors.md create mode 100644 .agents/skills/mastra/references/core-concepts.md create mode 100644 .agents/skills/mastra/references/create-mastra.md create mode 100644 .agents/skills/mastra/references/embedded-docs.md create mode 100644 .agents/skills/mastra/references/mastra-api.md create mode 100644 .agents/skills/mastra/references/migration-guide.md create mode 100644 .agents/skills/mastra/references/model-selection.md create mode 100644 .agents/skills/mastra/references/remote-docs.md create mode 100755 .agents/skills/mastra/scripts/provider-registry.mjs diff --git a/.agents/skills/mastra/SKILL.md b/.agents/skills/mastra/SKILL.md new file mode 100644 index 0000000..e66994d --- /dev/null +++ b/.agents/skills/mastra/SKILL.md @@ -0,0 +1,128 @@ +--- +name: mastra +description: "Comprehensive Mastra framework guide for building agents, workflows, tools, memory, workspaces, and storage with current APIs. Use for documentation lookup, API verification, TypeScript setup, common errors, migrations, and `mastra api` CLI tasks: inspect or call resources on local, Mastra platform, or remote servers." +license: Apache-2.0 +metadata: + author: Mastra + version: "2.0.0" + repository: https://github.com/mastra-ai/skills +--- + +# Mastra Framework Guide + +Build AI applications with Mastra. This skill teaches you how to find current documentation and build agents and workflows. + +## Critical: Do not trust internal knowledge + +Everything you know about Mastra is likely outdated or wrong. Never rely on memory. Always verify against current documentation. + +Your training data contains obsolete APIs, deprecated patterns, and incorrect usage. Mastra evolves rapidly - APIs change between versions, constructor signatures shift, and patterns get refactored. + +## Prerequisites + +Before writing any Mastra code, check if packages are installed: + +```bash +ls node_modules/@mastra/ +``` + +- If packages exist: Use embedded docs first (most reliable) +- If no packages: Install first or use remote docs + +## Resources + +### References + +| User Question | First Check | How To | +| ----------------------------------- | ---------------------------------------------------------------- | ---------------------------------------------- | +| Create/install Mastra project | [`references/create-mastra.md`](references/create-mastra.md) | Setup guide with CLI and manual steps | +| Choose Agent/Workflow/Tool/Memory/Storage | [`references/core-concepts.md`](references/core-concepts.md) | Core concepts and when to use each primitive | +| How do I use Agent/Workflow/Tool? | [`references/embedded-docs.md`](references/embedded-docs.md) | Look up in `node_modules/@mastra/*/dist/docs/` | +| How do I use X? (no packages) | [`references/remote-docs.md`](references/remote-docs.md) | Fetch from `https://mastra.ai/llms.txt` | +| Choose or validate a model | [`references/model-selection.md`](references/model-selection.md) | Model format and provider registry lookup | +| I'm getting an error... | [`references/common-errors.md`](references/common-errors.md) | Common errors and solutions | +| Upgrade from v0.x to v1.x | [`references/migration-guide.md`](references/migration-guide.md) | Version upgrade workflows | +| Inspect/call server resources via CLI | [`references/mastra-api.md`](references/mastra-api.md) | `mastra api` CLI for local, Mastra platform, or remote servers | + +### Scripts + +- `scripts/provider-registry.mjs`: Look up current providers and models available in the model router. Always run this before using a model to verify provider keys and model names. + +## Priority order for writing code + +Never write code without checking current docs first. + +1. Embedded docs first (if packages installed) + + Look up current docs in `node_modules` for a package. This matches the exact installed version and is the most reliable source of truth. See [`references/embedded-docs.md`](references/embedded-docs.md). + +2. Source code second (if packages installed) + + If embedded docs don't cover the question, inspect the installed source and type definitions. This is the source of truth when docs are missing or unclear. See [`references/embedded-docs.md`](references/embedded-docs.md). + +3. Remote docs third (if packages not installed) + + Use the latest published docs when packages are not installed or when exploring new features. Remote docs may be ahead of the user's installed version. See [`references/remote-docs.md`](references/remote-docs.md). + +## Core concepts + +Use [`references/core-concepts.md`](references/core-concepts.md) when choosing between agents, workflows, tools, memory, and storage. + +- Agent: Use for open-ended tasks that make decisions and use tools. +- Workflow: Use for defined multi-step processes. + +## Mastra Studio + +Studio is the interactive UI for building, testing, and managing agents, workflows, and tools. Use Studio when advising a human to inspect or debug visually. + +Inside a Mastra project, run: + +```bash +npm run dev +``` + +Then open `http://localhost:4111` in a browser to show Mastra Studio to your human user. + +## Mastra API CLI + +Use `mastra api` to inspect or call resources on local dev servers, Mastra platform deployments, or remote Mastra endpoints. It is useful for agent-readable state, execution, traces, logs, scores, threads, and workflow operations. See [`references/mastra-api.md`](references/mastra-api.md) for usage patterns. + +## Critical requirements + +### TypeScript config + +Mastra requires ES2022 modules. CommonJS will fail. See [`references/create-mastra.md`](references/create-mastra.md) for setup and [`references/common-errors.md`](references/common-errors.md) for troubleshooting. + +### Model format + +Always use `"provider/model-name"` when defining models using Mastra's model router. + +When the user asks to use a model or provider, always run `scripts/provider-registry.mjs` first to verify the provider key and model name are valid. Do not guess model names from memory as they change frequently. See [`references/model-selection.md`](references/model-selection.md). + +## When you see errors + +Type errors often mean your knowledge is outdated. + +Common signs of outdated knowledge: + +- `Property X does not exist on type Y` +- `Cannot find module` +- `Type mismatch` errors +- Constructor parameter errors + +What to do: + +1. Check [`references/common-errors.md`](references/common-errors.md) +2. Verify current API in embedded docs +3. Don't assume the error is a user mistake - it might be your outdated knowledge + +## Development workflow + +Always verify before writing code: + +1. Check whether Mastra packages are installed +2. Look up current API + - If installed: Use embedded docs [`references/embedded-docs.md`](references/embedded-docs.md) + - If not: Use remote docs [`references/remote-docs.md`](references/remote-docs.md) +3. Write code based on current docs +4. Test with the project scripts or Studio when available diff --git a/.agents/skills/mastra/references/common-errors.md b/.agents/skills/mastra/references/common-errors.md new file mode 100644 index 0000000..d81dc08 --- /dev/null +++ b/.agents/skills/mastra/references/common-errors.md @@ -0,0 +1,537 @@ +# Common errors and troubleshooting + +Comprehensive guide to common Mastra errors and their solutions. + +## Quickstart + +In a lot of cases, debugging errors can be greatly simplified by first checking the behavior in Mastra Studio. This allows you to interactively test agents and workflows, inspect logs, and see real-time error messages. + +```bash +npm run dev +``` + +Open `http://localhost:4111` in your browser to access Mastra Studio. + +## Build and configuration errors + +### "Cannot find module" or import errors + +**Symptoms**: + +```bash +Error: Cannot find module '@mastra/core' +SyntaxError: Cannot use import statement outside a module +``` + +**Causes**: + +- CommonJS configuration in `tsconfig.json` +- Missing `"type": "module"` in `package.json` +- Incorrect module resolution + +**Solutions**: + +1. Update `tsconfig.json`: + + ```json + { + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler" + } + } + ``` + +2. Add to `package.json`: + + ```json + { + "type": "module" + } + ``` + +3. Ensure imports use `.js` extensions for local files (if needed by your bundler) + +### "Property X does not exist on type Y" + +**Symptoms**: + +```bash +Property 'tools' does not exist on type 'Agent' +Property 'memory' does not exist on type 'AgentConfig' +``` + +**Causes**: + +- Outdated API usage (Mastra is actively developed) +- Incorrect import or type +- Version mismatch between docs and installed package + +**Solutions**: + +1. Check embedded docs (see `embedded-docs.md`) to check current API +2. Check `node_modules/@mastra/core/dist/docs/assets/SOURCE_MAP.json` for current exports +3. Verify package versions: `npm list @mastra/core` +4. Update dependencies: `npm update @mastra/core` + +## Agent errors + +### Agent not using assigned tools + +**Symptoms**: + +- Agent responds "I don't have access to that tool" +- Tools never get called despite being relevant + +**Causes**: + +- Tools not registered in Mastra instance +- Tools not passed to Agent constructor +- Tool IDs don't match + +**Solutions**: + +**Correct pattern**: + +```typescript +// 1. Create tool +const weatherTool = createTool({ + id: "get-weather", + // ... tool config +}); + +// 2. Register in Mastra instance +const mastra = new Mastra({ + tools: { + weatherTool, // or 'weatherTool': weatherTool + }, +}); + +// 3. Assign to agent +const agent = new Agent({ + id: "weather-agent", + tools: { weatherTool }, // Reference the tool + // ... other config +}); +``` + +**Alternative pattern (direct assignment)**: + +```typescript +const agent = new Agent({ + id: "weather-agent", + tools: { + weatherTool: createTool({ id: "get-weather" /* ... */ }), + }, +}); +``` + +### Agent memory not persisting + +**Symptoms**: + +- Agent doesn't remember previous messages +- Conversation history is lost between calls + +**Causes**: + +- No storage backend configured +- Missing or inconsistent `threadId` +- Memory not assigned to agent + +**Solutions**: + +```typescript +// 1. Configure storage +const storage = new PostgresStore({ + connectionString: process.env.DATABASE_URL, +}); + +// 2. Create memory with storage +const memory = new Memory({ + id: "chat-memory", + storage, + options: { + lastMessages: 10, // How many messages to retrieve + }, +}); + +// 3. Assign memory to agent +const agent = new Agent({ + id: "chat-agent", + memory, +}); + +// 4. Use consistent threadId +await agent.generate("Hello", { + threadId: "user-123-conversation", // Same threadId for entire conversation + resourceId: "user-123", +}); +``` + +## Workflow errors + +### "Cannot read property 'then' of undefined" + +**Symptoms**: + +```bash +TypeError: Cannot read property 'then' of undefined +Workflow execution fails immediately +``` + +**Causes**: + +- Forgot to call `.commit()` on workflow +- Step returns undefined + +**Solutions**: + +**Correct pattern**: + +```typescript +const workflow = createWorkflow({ + id: "my-workflow", + inputSchema: z.object({ data: z.string() }), + outputSchema: z.object({ result: z.string() }), +}) + .then(step1) + .then(step2) + .commit(); // REQUIRED! + +// Then execute +const run = await workflow.createRun(); +const result = await run.start({ inputData: { data: "test" } }); +``` + +### Workflow state not updating + +**Symptoms**: + +- State changes don't persist across steps +- `getStepResult()` returns undefined + +**Causes**: + +- Not using `setState` to update state +- Accessing state before step completes + +**Solutions**: + +```typescript +const step1 = createStep({ + id: "step1", + execute: async ({ state, setState }) => { + // Update state + await setState({ ...state, counter: (state.counter || 0) + 1 }); + return { result: "done" }; + }, +}); + +// Access state in subsequent steps +const step2 = createStep({ + id: "step2", + execute: async ({ state }) => { + console.log(state.counter); // Access updated state + return { result: "complete" }; + }, +}); +``` + +## Memory errors + +### "Storage is required for Memory" + +**Symptoms**: + +```bash +Error: Storage is required for Memory +Memory instantiation fails +``` + +**Causes**: + +- Memory created without storage backend + +**Solutions**: + +```typescript +// Always provide storage when creating Memory +const memory = new Memory({ + id: "my-memory", + storage: postgresStore, // REQUIRED + options: { + lastMessages: 10, + }, +}); +``` + +### Semantic recall not working + +**Symptoms**: + +- Memory doesn't retrieve semantically similar messages +- Only recent messages are returned + +**Causes**: + +- No vector store configured +- No embedder configured +- `semanticRecall` not enabled + +**Solutions**: + +```typescript +const memory = new Memory({ + id: "semantic-memory", + storage: postgresStore, + vector: chromaVectorStore, // REQUIRED for semantic recall + embedder: openaiEmbedder, // REQUIRED for semantic recall + options: { + lastMessages: 10, + semanticRecall: true, // REQUIRED + }, +}); +``` + +## Tool errors + +### "Tool validation failed" + +**Symptoms**: + +```bash +Error: Input validation failed for tool 'my-tool' +ZodError: Expected string, received number +``` + +**Causes**: + +- Input doesn't match inputSchema +- Missing required fields +- Type mismatch + +**Solutions**: + +```typescript +const tool = createTool({ + id: "my-tool", + inputSchema: z.object({ + name: z.string(), + age: z.number().optional(), // Make optional fields explicit + }), + execute: async (input) => { + // input is validated and typed + return { result: `Hello ${input.name}` }; + }, +}); + +// Correct usage +await tool.execute({ name: "Alice" }); // Works +await tool.execute({ name: "Bob", age: 30 }); // Works +await tool.execute({ age: 30 }); // ERROR: name is required +``` + +### Tool suspension not resuming + +**Symptoms**: + +- Tool suspends but never resumes +- resumeData is undefined + +**Causes**: + +- Not calling workflow.resume() or agent.generate() with resumeData +- Incorrect resumeSchema + +**Solutions**: + +```typescript +const approvalTool = createTool({ + id: "approval", + inputSchema: z.object({ request: z.string() }), + outputSchema: z.object({ approved: z.boolean() }), + suspendSchema: z.object({ requestId: z.string() }), + resumeSchema: z.object({ approved: z.boolean() }), + execute: async (input, context) => { + if (!context.resumeData) { + // First call - suspend + const requestId = generateId(); + context.suspend({ requestId }); + return; // Execution pauses here + } + + // Resumed - use resumeData + return { approved: context.resumeData.approved }; + }, +}); + +// Resume the workflow/agent +await run.resume({ + resumeData: { approved: true }, +}); +``` + +## Storage errors + +### "Connection refused" or "Database does not exist" + +**Symptoms**: + +```bash +Error: connect ECONNREFUSED 127.0.0.1:5432 +Error: database "mastra" does not exist +``` + +**Causes**: + +- Database not running +- Incorrect connection string +- Database not created + +**Solutions**: + +1. Start database (Postgres example): + +```bash +docker run -d \ + --name mastra-postgres \ + -e POSTGRES_PASSWORD=password \ + -e POSTGRES_DB=mastra \ + -p 5432:5432 \ + postgres:16 +``` + +2. Verify connection string: + +```env +DATABASE_URL=postgresql://postgres:password@localhost:5432/mastra +``` + +3. Initialize storage: + +```typescript +const storage = new PostgresStore({ + connectionString: process.env.DATABASE_URL, +}); +await storage.init(); // Creates tables if needed +``` + +## Environment variable errors + +### "API key not found" + +**Symptoms**: + +```bash +Error: OPENAI_API_KEY environment variable is not set +401 Unauthorized +``` + +**Causes**: + +- Missing .env file +- Environment variables not loaded +- Incorrect variable name + +**Solutions**: + +1. Create .env file: + +```env +OPENAI_API_KEY=sk-... +ANTHROPIC_API_KEY=sk-ant-... +GOOGLE_GENERATIVE_AI_API_KEY=... +``` + +2. Load environment variables (for Node.js): + +```typescript +import "dotenv/config"; // At top of entry file +``` + +3. Verify variable is loaded: + +```typescript +if (!process.env.OPENAI_API_KEY) { + throw new Error("OPENAI_API_KEY is required"); +} +``` + +## Model errors + +### "Model not found" or "Invalid model" + +**Symptoms**: + +```bash +Error: Model 'gpt-4' not found +Error: Invalid model format +``` + +**Causes**: + +- Incorrect model format (should be `provider/model`) +- Unsupported model +- Missing provider API key + +**Solutions**: + +**Correct model format**: + +```typescript +const agent = new Agent({ + model: "openai/gpt-5.4", // ✅ Correct + // NOT: model: 'gpt-5.4' // ❌ Missing provider +}); +``` + +**Common models**: + +- OpenAI: `openai/gpt-5.4`, `openai/gpt-5-mini` +- Anthropic: `anthropic/claude-sonnet-4-5`, `anthropic/claude-haiku-4-5`, `anthropic/claude-opus-4-6` +- Google: `google/gemini-2.5-pro`, `google/gemini-2.5-flash` + +**Use embedded docs to verify**: + +```bash +# Check supported models +ls node_modules/@mastra/core/dist/docs/ +# See embedded-docs.md for lookup instructions +``` + +## Debugging tips + +### Enable verbose logging + +```typescript +const mastra = new Mastra({ + logger: new PinoLogger({ + name: "mastra", + level: "debug", // or 'trace' for even more detail + }), +}); +``` + +### Check package versions + +```bash +npm list @mastra/core +npm list @mastra/memory +npm list @mastra/rag +``` + +### Validate TypeScript config + +```bash +npx tsc --showConfig +# Verify target: ES2022, module: ES2022 +``` + +## Getting help + +1. **Check embedded docs**: Check embedded docs (see `embedded-docs.md`) +2. **Search documentation**: [mastra.ai/docs](https://mastra.ai/docs) +3. **Check version compatibility**: Ensure all @mastra packages are same version +4. **File an issue**: [github.com/mastra-ai/mastra](https://github.com/mastra-ai/mastra) diff --git a/.agents/skills/mastra/references/core-concepts.md b/.agents/skills/mastra/references/core-concepts.md new file mode 100644 index 0000000..e43c0af --- /dev/null +++ b/.agents/skills/mastra/references/core-concepts.md @@ -0,0 +1,17 @@ +# Core Concepts Reference + +Use this reference when deciding which Mastra primitive to use or when explaining the high-level shape of a Mastra application. + +## Agents vs workflows + +Agent: Autonomous, makes decisions, uses tools. +Use for open-ended tasks such as support, research, analysis, and tool-using assistants. + +Workflow: Structured sequence of steps. +Use for defined processes such as pipelines, approvals, ETL, multi-step business logic, and resumable processes. + +## Key components + +- Tools: Extend agent capabilities through APIs, databases, external services, and deterministic functions. +- Memory: Maintain context through message history, working memory, semantic recall, and observational memory. +- Storage: Persist data with providers such as Postgres, LibSQL, and MongoDB. diff --git a/.agents/skills/mastra/references/create-mastra.md b/.agents/skills/mastra/references/create-mastra.md new file mode 100644 index 0000000..a4ee7d9 --- /dev/null +++ b/.agents/skills/mastra/references/create-mastra.md @@ -0,0 +1,222 @@ +# Create Mastra Reference + +Complete guide for creating new Mastra projects. Includes both quickstart CLI method and detailed manual installation. + +**Official documentation: [mastra.ai/docs](https://mastra.ai/docs)** + +## Get started + +Ask: **"How would you like to create your Mastra project?"** + +1. **Quick Setup**: Copy and run: `npm create mastra@latest` +2. **Guided Setup**: I walk you through each step, you approve commands +3. **Automatic Setup**: I create everything, just give me your API key + +> **For AI agents:** The CLI is interactive. Use **Automatic Setup** to create files using the steps in "Automatic Setup / Manual Installation" below. + +## Prerequisites + +- An API key from a supported model provider (OpenAI, Anthropic, Google, etc.) + +## Quick Setup (user runs CLI) + +Create a new Mastra project with one command: + +```bash +npm create mastra@latest +``` + +**Other package managers:** + +```bash +pnpm create mastra@latest +yarn create mastra@latest +bun create mastra@latest +``` + +## CLI flags + +**Skip the example agent:** + +```bash +npm create mastra@latest --no-example +``` + +**Use a specific template:** + +```bash +npm create mastra@latest --template +``` + +## Automatic setup / manual installation + +**Use this for automatic setup** (AI creates all files) or when you prefer manual control. + +Follow these steps to create a complete Mastra project: + +### Step 1: Create project directory + +```bash +mkdir my-first-agent && cd my-first-agent +npm init -y +``` + +### Step 2: Install dependencies + +```bash +npm install -D typescript @types/node mastra@latest +npm install @mastra/core@latest zod@^4 +``` + +### Step 3: Configure package scripts + +Add to `package.json`: + +```json +{ + "scripts": { + "dev": "mastra dev", + "build": "mastra build" + } +} +``` + +### Step 4: Configure TypeScript + +Create `tsconfig.json`: + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "outDir": "dist" + }, + "include": ["src/**/*"] +} +``` + +**Important:** Mastra requires `"module": "ES2022"` and `"moduleResolution": "bundler"`. CommonJS will cause errors. + +### Step 5: Create environment file + +Create `.env` with your API key: + +```env +GOOGLE_GENERATIVE_AI_API_KEY= +``` + +Or use `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, etc. + +### Step 6: Create weather tool + +Create `src/mastra/tools/weather-tool.ts`: + +```typescript +import { createTool } from "@mastra/core/tools"; +import { z } from "zod"; + +export const weatherTool = createTool({ + id: "get-weather", + description: "Get current weather for a location", + inputSchema: z.object({ + location: z.string().describe("City name"), + }), + outputSchema: z.object({ + output: z.string(), + }), + execute: async () => { + return { output: "The weather is sunny" }; + }, +}); +``` + +### Step 7: Create weather agent + +Create `src/mastra/agents/weather-agent.ts`: + +```typescript +import { Agent } from "@mastra/core/agent"; +import { weatherTool } from "../tools/weather-tool"; + +export const weatherAgent = new Agent({ + id: "weather-agent", + name: "Weather Agent", + instructions: ` + You are a helpful weather assistant that provides accurate weather information. + + Your primary function is to help users get weather details for specific locations. When responding: + - Always ask for a location if none is provided + - If the location name isn't in English, please translate it + - If giving a location with multiple parts (e.g. "New York, NY"), use the most relevant part (e.g. "New York") + - Include relevant details like humidity, wind conditions, and precipitation + - Keep responses concise but informative + + Use the weatherTool to fetch current weather data. +`, + model: "google/gemini-2.5-pro", + tools: { weatherTool }, +}); +``` + +**Note:** Model format is `"provider/model-name"`. Examples: + +- `"google/gemini-2.5-pro"` +- `"openai/gpt-5.4"` +- `"anthropic/claude-sonnet-4-5"` + +### Step 8: Create mastra entry point + +Create `src/mastra/index.ts`: + +```typescript +import { Mastra } from "@mastra/core"; +import { weatherAgent } from "./agents/weather-agent"; + +export const mastra = new Mastra({ + agents: { weatherAgent }, +}); +``` + +### Step 9: Launch Mastra Studio + +Launch the development server: + +```bash +npm run dev +``` + +Access Studio at `http://localhost:4111` to test your agent. + +## Next steps + +After creating your project with `create mastra`: + +- **Customize the example agent** in `src/mastra/agents/weather-agent.ts` +- **Add new agents** - see [Agents documentation](https://mastra.ai/docs/agents/overview) +- **Create workflows** - see [Workflows documentation](https://mastra.ai/docs/workflows/overview) +- **Add more tools** to extend agent capabilities +- **Integrate into your app** - see framework guides at [mastra.ai/docs](https://mastra.ai/docs) + +## Troubleshooting + +| Issue | Solution | +| ------------------ | ------------------------------------------------------------------------------------ | +| API key not found | Make sure your `.env` file has the correct key | +| Studio won't start | Check that port 4111 is available | +| CommonJS errors | Ensure `tsconfig.json` uses `"module": "ES2022"` and `"moduleResolution": "bundler"` | +| Command not found | Ensure you're using Node.js 20+ | + +## Resources + +- [Docs](https://mastra.ai/docs) +- [Installation](https://mastra.ai/docs/getting-started/installation) +- [Agents](https://mastra.ai/docs/agents/overview) +- [Workflows](https://mastra.ai/docs/workflows/overview) +- [GitHub](https://github.com/mastra-ai/mastra) diff --git a/.agents/skills/mastra/references/embedded-docs.md b/.agents/skills/mastra/references/embedded-docs.md new file mode 100644 index 0000000..64e58dc --- /dev/null +++ b/.agents/skills/mastra/references/embedded-docs.md @@ -0,0 +1,103 @@ +# Embedded Docs Reference + +Look up API signatures from embedded docs in `node_modules/@mastra/*/dist/docs/` - these match the installed version. + +**Use this FIRST** when Mastra packages are installed locally. Embedded docs are always accurate for the installed version. + +## Why use embedded docs + +- **Version accuracy**: Embedded docs match the exact installed version +- **No network required**: All docs are local in `node_modules/` +- **Mastra evolves quickly**: APIs change rapidly, embedded docs stay in sync +- **TypeScript definitions**: Includes JSDoc, type signatures, and examples +- **Training data may be outdated**: Claude's knowledge cutoff may not reflect latest APIs + +## Documentation structure + +``` +node_modules/@mastra/core/dist/docs/ +├── SKILL.md # Package overview, exports +├── assets/ +│ └── SOURCE_MAP.json # Export -> file mappings +└── references/ # Individual topic docs +``` + +## Lookup process + +### 1. Check if packages are installed + +```bash +ls node_modules/@mastra/ +``` + +If you see packages like `core`, `memory`, `rag`, etc., proceed with embedded docs lookup. + +### 2. Look through topic docs + +Use `grep` to find relevant docs in `references/`: + +```bash +grep -r "Agent" node_modules/@mastra/core/dist/docs/references +``` + +### Naming convention + +Documents are typically formatted as `-.md` where category is one of: `"docs", "reference", "guides", "models"`. + +### Optional: Check source code for type definitions / additional details + +Look at the `SOURCE_MAP.json` to find the file path for the export: + +```bash +cat node_modules/@mastra/core/dist/docs/assets/SOURCE_MAP.json | grep '"Agent"' +``` + +Returns: `{ "Agent": { "types": "dist/agent/agent.d.ts", ... } }` + +Read the type definition for exact constructor parameters, types, and JSDoc: + +```bash +cat node_modules/@mastra/core/dist/agent/agent.d.ts +``` + +## Common packages + +| Package | Path | Contains | +| ---------------- | ---------------------------------------- | ----------------------------------------- | +| `@mastra/core` | `node_modules/@mastra/core/dist/docs/` | Agents, Workflows, Tools, Mastra instance | +| `@mastra/memory` | `node_modules/@mastra/memory/dist/docs/` | Memory systems, conversation history | +| `@mastra/rag` | `node_modules/@mastra/rag/dist/docs/` | RAG features, vector stores | +| `@mastra/pg` | `node_modules/@mastra/pg/dist/docs/` | PostgreSQL storage | +| `@mastra/libsql` | `node_modules/@mastra/libsql/dist/docs/` | LibSQL/SQLite storage | + +## Quick commands reference + +```bash +# List installed @mastra packages +ls node_modules/@mastra/ + +# List available topic documentation +ls node_modules/@mastra/core/dist/docs/references/ + +# Find specific export in SOURCE_MAP +cat node_modules/@mastra/core/dist/docs/assets/SOURCE_MAP.json | grep '"ExportName"' + +# Read type definition from path +cat node_modules/@mastra/core/dist/[path-from-source-map] + +# View package overview +cat node_modules/@mastra/core/dist/docs/SKILL.md +``` + +## When embedded docs are not available + +If packages aren't installed or `dist/docs/` doesn't exist: + +1. **Recommend installation**: Suggest installing packages to access embedded docs +2. **Fall back to remote docs**: See `references/remote-docs.md` + +## Best Practices + +1. **Check topic docs** for conceptual understanding and patterns +2. **Search source code** if docs don't answer the question +3. **Verify imports** match what's exported in the type definitions diff --git a/.agents/skills/mastra/references/mastra-api.md b/.agents/skills/mastra/references/mastra-api.md new file mode 100644 index 0000000..3762958 --- /dev/null +++ b/.agents/skills/mastra/references/mastra-api.md @@ -0,0 +1,201 @@ +# Mastra API CLI Reference + +How to use the `mastra api` CLI to interact with Mastra servers. Prefer fast, focused commands and compact JSON projections. Treat the installed CLI and server schema as the source of truth when discovery is needed. + +Use this reference when the user asks to inspect or call agents, workflows, tools, MCP servers, memory threads, traces, logs, metrics, scores, datasets, experiments, or to debug/test `mastra api` commands. + +## Setup + +The CLI can interact with any reachable Mastra server: + +- Local dev server: `http://localhost:4111` from `npm run dev` +- Mastra platform deployment: Use the deployment URL +- Remote/self-hosted server: Use the server URL +- Hosted Mastra Platform Observability: `https://observability.mastra.ai` (auto-targeted by `trace`, `log`, `score`, and `metric` commands) + +For local servers, `mastra api` defaults to `http://localhost:4111`: + +```bash +npx mastra api agent list +``` + +For Mastra platform or remote servers, pass `--url`. For the sake of brevity in examples, `$MASTRA_URL` is used as a placeholder for the actual server URL which you need to set yourself: + +```bash +npx mastra api --url $MASTRA_URL agent list +``` + +Verify the server once with a cheap check before resource calls: + +```bash +MASTRA_URL="${MASTRA_URL:-http://localhost:4111}" +curl -fsS "$MASTRA_URL/api/system/api-schema" >/dev/null +``` + +If `$MASTRA_URL` is not reachable, the user may be using a Mastra platform deployment or remote URL. Ask for the correct server URL and set `--url` accordingly. If authentication is required, ask the user for the necessary token or credentials and set them in the environment for subsequent commands. + +For authenticated servers, pass repeatable headers: + +```bash +npx mastra api --url "$MASTRA_URL" --header "Authorization: Bearer $TOKEN" agent list +``` + +### Target resolution + +Runtime commands (`agent`, `workflow`, `tool`, `mcp`, `thread`, `memory`, `dataset`, `experiment`) resolve the target in this order: + +1. `--url ` for an explicit remote or self-hosted server. +2. `http://localhost:4111` for a local `mastra dev` server. +3. `.mastra-project.json` for a Mastra platform project. + +Observability commands (`trace`, `log`, `score`, `metric`) target `https://observability.mastra.ai` by default instead of a project deployment URL. The CLI resolves credentials in this order: + +1. Explicit `Authorization` and `X-Mastra-Project-Id` headers passed with `--header`. +2. `MASTRA_PLATFORM_ACCESS_TOKEN` and `MASTRA_PROJECT_ID` from the environment. +3. Project metadata from `.mastra-project.json` for the project ID. +4. The Mastra CLI login token as an auth fallback. + +For observability calls, no `--url` or `--header` is required if `MASTRA_PLATFORM_ACCESS_TOKEN` and `MASTRA_PROJECT_ID` are set, or if `.mastra-project.json` is present: + +```bash +npx mastra api trace list '{"page":0,"perPage":10}' +npx mastra api metric names +``` + +Pass `--url` and `--header` only when overriding the hosted observability target or credentials. + +## Decision flow + +1. Clear read-only request (`list X`, `latest X`, `get X`, `summarize recent X`): infer the resource and use the fast path first. +2. Mutating request (`create`, `update`, `delete`, `run`, `resume`, `execute`), unclear resource/action, failed fast path, or exact syntax requested: use narrow CLI discovery. +3. JSON input uncertain: use command-specific `--schema`. +4. Route behavior confusing: inspect `/api/system/api-schema`. + +Start with these command groups when present; verify with `mastra api --help` if the group fails. + +```text +agent workflow tool mcp thread memory trace log metric score dataset experiment +``` + +## Fast path for read-only requests + +Use conventional `list`/`get` commands first. Keep pages small and pipe through `jq` immediately. + +Latest item: + +```bash +npx mastra api list '{"page":0,"perPage":1}' \ + | jq '.data[0]' +``` + +Recent items: + +```bash +npx mastra api list '{"page":0,"perPage":10}' \ + | jq '.data[]' +``` + +When the shape is known, project only the fields needed for the task: + +```bash +npx mastra api list '{"page":0,"perPage":10}' \ + | jq '.data[] | {id, name, createdAt, status}' +``` + +Get details: + +```bash +npx mastra api get \ + | jq '.data' +``` + +When the shape is known, project only the fields needed for the task: + +```bash +npx mastra api get \ + | jq '.data | {id, name, createdAt, status}' +``` + +If a resource does not support the conventional shape, fall back to narrow `--help` for that resource/action. + +## Output control + +- Do not use unfiltered `--pretty` during exploration. +- Always project list/get output with `jq` before reading details. +- Use `perPage:1` for latest and `perPage:10` or less for recent lists. +- If output is truncated or noisy, rerun with a narrower `jq` projection. Do not increase terminal output just to see more raw JSON. +- Fetch full JSON only when the user asks for raw output or compact projections are insufficient. + +## Fallback discovery + +Use the narrowest discovery command that can answer the question. Example for traces: + +```bash +npx mastra api trace --help +npx mastra api trace list --help +npx mastra api trace list --schema +``` + +Use top-level help only when the resource is unknown: + +```bash +npx mastra api --help +``` + +Read `--schema` output as the contract: + +- `command`: usage string +- `examples`: known-good examples +- `positionals`: required path/identity arguments +- `input.required`: whether JSON input is required +- `input.schema`: accepted CLI JSON input, including query/body fields +- `schemas`: raw server route schemas for deeper debugging + +## JSON and output contract + +`mastra api` accepts at most one inline JSON object as input. Do not use stdin or files unless the user explicitly asks. + +For non-GET routes, the CLI splits the one JSON object into query parameters and request body according to the server route schema. + +Output envelopes: + +```json +{ "data": {} } +{ "data": [], "page": { "total": 0, "page": 0, "perPage": 0, "hasMore": false } } +{ "error": { "code": "...", "message": "...", "details": {} } } +``` + +## Error handling + +- `INVALID_JSON`: fix shell quoting; input must be one JSON object. +- `MISSING_INPUT`: run the same command with `--schema` and supply required JSON. +- `MISSING_ARGUMENT`: provide the positional shown by `--help` / `--schema`. +- `HTTP_ERROR`: inspect `error.details`, then compare against `--schema` or route schema. +- `REQUEST_TIMEOUT`: retry with larger `--timeout`, especially for workflow execution. +- `SERVER_UNREACHABLE`: verify the URL and the server check. If localhost is not running, ask whether the user wants to use a Mastra platform deployment or another remote server URL. + +## Route-level debugging + +If CLI behavior seems wrong, inspect the route-derived schema manifest instead of guessing. + +Find routes by path: + +```bash +curl -fsS "$MASTRA_URL/api/system/api-schema" \ + | jq '.routes[] | select(.path | contains("/memory"))' +``` + +Inspect one route: + +```bash +curl -fsS "$MASTRA_URL/api/system/api-schema" \ + | jq '.routes[] | select(.method == "POST" and .path == "/tools/:toolId/execute") | {pathParamSchema, queryParamSchema, bodySchema, responseShape}' +``` + +## Known notes + +- Tool and MCP tool execution accept raw tool input; explicit `{ "data": ... }` also works. +- Workflow resume only works for suspended workflow runs. +- Working memory update requires the agent's memory to have working memory enabled. +- Empty lists may simply mean the server has no matching stored data yet. +- `trace list` and `trace get` return lightweight payloads by default (no span input, output, attributes, or metadata). Pass `--verbose` to fetch full span records, or use `trace span ` to fetch one specific span in full. diff --git a/.agents/skills/mastra/references/migration-guide.md b/.agents/skills/mastra/references/migration-guide.md new file mode 100644 index 0000000..de4e998 --- /dev/null +++ b/.agents/skills/mastra/references/migration-guide.md @@ -0,0 +1,180 @@ +# Migration Guide + +Guide for upgrading Mastra versions using official documentation and current API verification. + +## Migration strategy + +For version upgrades, follow this process: + +### 1. Check official migration docs + +**Always start with the official migration documentation:** `https://mastra.ai/llms.txt` + +Look for the **Migrations** or **Guides** section, which will have: + +- Breaking changes for each version +- Automated migration tools +- Step-by-step upgrade instructions + +**Example sections to look for:** + +- `/guides/migrations/upgrade-to-v1/` +- `/guides/migrations/upgrade-to-v2/` +- Breaking changes lists + +### 2. Use embedded docs for current APIs + +After identifying breaking changes, verify the new APIs: + +**Check your installed version:** + +```bash +cat node_modules/@mastra/core/dist/docs/assets/SOURCE_MAP.json | grep '"ApiName"' +cat node_modules/@mastra/core/dist/[path-from-source-map] +``` + +See [`embedded-docs.md`](embedded-docs.md) for detailed lookup instructions. + +### 3. Use remote docs for latest info + +If packages aren't updated yet, check what APIs will look like: `https://mastra.ai/reference/[topic]` + +See [`remote-docs.md`](remote-docs.md) for detailed lookup instructions. + +## Quick migration workflow + +```bash +# 1. Check current version +npm list @mastra/core + +# 2. Fetch migration guide from official docs +# Use WebFetch: https://mastra.ai/llms.txt +# Find relevant migration section + +# 3. Update dependencies +npm install @mastra/core@latest @mastra/memory@latest @mastra/rag@latest mastra@latest + +# 4. Run automated migration (if available) +npx @mastra/codemod@latest v1 # or whatever version + +# 5. Check embedded docs for new APIs +cat node_modules/@mastra/core/dist/docs/assets/SOURCE_MAP.json + +# 6. Fix breaking changes using embedded docs lookup +# See embedded-docs.md for how to look up each API + +# 7. Test +npm run dev +npm test +``` + +## Common migration patterns + +### Finding what changed + +**Check official migration docs:** `https://mastra.ai/guides/migrations/upgrade-to-v1/overview.md` + +This will list: + +- Breaking changes +- Deprecated APIs +- New features +- Migration tools + +### Updating API usage + +**For each breaking change:** + +1. **Find the old API** in your code +2. **Look up the new API** using embedded docs: + ```bash + cat node_modules/@mastra/core/dist/docs/assets/SOURCE_MAP.json | grep '"NewApi"' + cat node_modules/@mastra/core/dist/[path] + ``` +3. **Update your code** based on the type signatures +4. **Test** the change + +### Example: Tool execute signature change + +**Official docs say:** "Tool execute signature changed" + +**Look up current signature:** + +```bash +cat node_modules/@mastra/core/dist/docs/assets/SOURCE_MAP.json | grep '"createTool"' +cat node_modules/@mastra/core/dist/tools/tool.d.ts +``` + +**Update based on type definition:** + +```typescript +// Old (from docs) +execute: async (input) => { ... } + +// New (from embedded docs) +execute: async (inputData, context) => { ... } +``` + +## Pre-migration checklist + +- [ ] Backup code (git commit) +- [ ] Check official migration docs: `https://mastra.ai/llms.txt` +- [ ] Note current version: `npm list @mastra/core` +- [ ] Read breaking changes list +- [ ] Tests are passing + +## Post-migration checklist + +- [ ] All dependencies updated together +- [ ] TypeScript compiles: `npx tsc --noEmit` +- [ ] Tests pass: `npm test` +- [ ] Studio works: `npm run dev` +- [ ] No console warnings +- [ ] APIs verified against embedded docs + +## Migration resources + +| Resource | Use For | +| -------------------------------------- | --------------------------------------------- | +| `https://mastra.ai/llms.txt` | Finding migration guides and breaking changes | +| [`embedded-docs.md`](embedded-docs.md) | Looking up new API signatures after updating | +| [`remote-docs.md`](remote-docs.md) | Checking latest docs before updating | +| [`common-errors.md`](common-errors.md) | Fixing migration errors | + +## Version-specific notes + +### General principles + +1. **Always update all @mastra packages together** + + ```bash + npm install @mastra/core@latest @mastra/memory@latest @mastra/rag@latest mastra@latest + ``` + +2. **Check for automated migration tools** + + ```bash + npx @mastra/codemod@latest [version] + ``` + +3. **Verify Node.js version requirements** + - Check official migration docs for minimum Node version + +4. **Run database migrations if using storage** + - Follow storage migration guide in official docs + +## Getting help + +1. **Check official migration docs**: `https://mastra.ai/llms.txt` → Migrations section +2. **Look up new APIs**: See [`embedded-docs.md`](embedded-docs.md) +3. **Check for errors**: See [`common-errors.md`](common-errors.md) +4. **Ask in Discord**: https://discord.gg/BTYqqHKUrf +5. **File issues**: https://github.com/mastra-ai/mastra/issues + +## Key principles + +1. **Official docs are source of truth** - Start with `https://mastra.ai/llms.txt` +2. **Verify with embedded docs** - Check installed version APIs +3. **Update incrementally** - Don't skip major versions +4. **Test thoroughly** - Run tests after each change +5. **Use automation** - Use codemods when available diff --git a/.agents/skills/mastra/references/model-selection.md b/.agents/skills/mastra/references/model-selection.md new file mode 100644 index 0000000..665a54e --- /dev/null +++ b/.agents/skills/mastra/references/model-selection.md @@ -0,0 +1,24 @@ +# Model Selection Reference + +Use this reference when choosing or validating Mastra model strings. + +## Model format + +Always use `"provider/model-name"` when defining models with Mastra's model router. + +## Verify provider keys and model names + +Use the provider registry script to look up available providers and models: + +```bash +# List all available providers +node scripts/provider-registry.mjs --list + +# List all models for a specific provider, sorted newest first +node scripts/provider-registry.mjs --provider openai +node scripts/provider-registry.mjs --provider anthropic +``` + +When the user asks to use a model or provider, run the script first to verify the provider key and model name are valid. Do not guess model names from memory because they change frequently. + +If you need examples in a new-project scaffold, see [`create-mastra.md`](create-mastra.md), then verify the chosen model with the provider registry script. diff --git a/.agents/skills/mastra/references/remote-docs.md b/.agents/skills/mastra/references/remote-docs.md new file mode 100644 index 0000000..dbd587b --- /dev/null +++ b/.agents/skills/mastra/references/remote-docs.md @@ -0,0 +1,193 @@ +# Remote Docs Reference + +How to look up current documentation from https://mastra.ai when local packages aren't available or you need conceptual guidance. + +**Use this when:** + +- Mastra packages aren't installed locally +- You need conceptual explanations or guides +- You want the latest documentation (may be ahead of installed version) + +## Documentation site structure + +Mastra docs are organized at **https://mastra.ai**: + +- **Docs**: Core documentation covering concepts, features, and implementation details +- **Models**: Mastra provides a unified interface for working with LLMs across multiple providers +- **Guides**: Step-by-step tutorials for building specific applications +- **Reference**: API reference documentation + +## Finding relevant documentation + +### Method 1: Use llms.txt (Recommended) + +The main llms.txt file provides an agent-friendly overview of all documentation: https://mastra.ai/llms.txt + +This returns a structured markdown document with: + +- Documentation organization and hierarchy +- All available topics and sections +- Direct links to relevant documentation +- Agent-optimized content structure + +**Use this first** to understand what documentation is available and where to find specific topics. + +### Method 2: Direct URL patterns + +Documentation follows predictable URL patterns: + +- Overview pages: `https://mastra.ai/docs/{topic}/overview` +- API reference: `https://mastra.ai/reference/{topic}/` +- Guides: `https://mastra.ai/guides/{topic}/` + +**Examples:** + +- `https://mastra.ai/docs/agents/overview` +- `https://mastra.ai/docs/workflows/overview` +- `https://mastra.ai/reference/workflows/workflow-methods/` + +## Agent-friendly documentation + +**Critical feature**: Send the `text-markdown` request header or add `.md` to any documentation URL to get clean, agent-friendly markdown. + +### Standard URL: + +``` +https://mastra.ai/reference/workflows/workflow-methods/then +``` + +### Agent-friendly URL (Markdown): + +``` +https://mastra.ai/reference/workflows/workflow-methods/then.md +``` + +The `.md` version: + +- Removes navigation, headers, footers +- Returns pure markdown content +- Optimized for LLM consumption +- Includes all code examples and explanations + +## Lookup Workflow + +### 1. Check the main documentation index + +**Start here** to understand what's available: + +``` +https://mastra.ai/llms.txt +``` + +This provides: + +- Complete documentation structure +- Available topics and sections +- Links to relevant documentation pages + +### 2. Find relevant documentation + +**Option A: Use information from llms.txt** +The main llms.txt will guide you to the right section. + +**Option B: Construct URL directly** + +``` +https://mastra.ai/docs/{topic}/overview +https://mastra.ai/reference/{topic}/ +``` + +### 3. Fetch agent-friendly version + +Add `.md` to the end of any documentation URL: + +``` +https://mastra.ai/reference/workflows/workflow-methods/then.md +``` + +### 4. Extract relevant information + +The markdown will include: + +- Function signatures +- Parameter descriptions +- Return types +- Usage examples +- Best practices + +## Common documentation paths + +### Agents + +- Overview: `https://mastra.ai/docs/agents/overview` +- Creating agents: `https://mastra.ai/docs/agents/creating-agents` +- Agent tools: `https://mastra.ai/docs/agents/tools` +- Memory: `https://mastra.ai/docs/agents/memory` + +### Workflows + +- Overview: `https://mastra.ai/docs/workflows/overview` +- Creating workflows: `https://mastra.ai/docs/workflows/creating-workflows` +- Workflow methods: `https://mastra.ai/reference/workflows/workflow-methods/` + +### Tools + +- Overview: `https://mastra.ai/docs/tools/overview` +- Creating tools: `https://mastra.ai/docs/tools/creating-tools` + +### Memory + +- Overview: `https://mastra.ai/docs/memory/overview` +- Configuration: `https://mastra.ai/docs/memory/configuration` + +### RAG + +- Overview: `https://mastra.ai/docs/rag/overview` +- Vector stores: `https://mastra.ai/docs/rag/vector-stores` + +## Example: Looking up workflow .then() method + +### 1. Check main documentation index + +``` +WebFetch({ + url: "https://mastra.ai/llms.txt", + prompt: "Where can I find documentation about workflow methods like .then()?" +}) +``` + +This will point you to the workflows reference section. + +### 2. Fetch specific method documentation + +``` +https://mastra.ai/reference/workflows/workflow-methods/then.md +``` + +### 3. Use WebFetch tool + +``` +WebFetch({ + url: "https://mastra.ai/reference/workflows/workflow-methods/then.md", + prompt: "What are the parameters for the .then() method and how do I use it?" +}) +``` + +## When to use remote vs embedded docs + +| Situation | Use | +| -------------------------- | --------------------------------------------------- | +| Packages installed locally | **Embedded docs** (guaranteed version match) | +| Packages not installed | **Remote docs** | +| Need conceptual guides | **Remote docs** | +| Need exact API signatures | **Embedded docs** (if available) | +| Exploring new features | **Remote docs** (may be ahead of installed version) | +| Need working examples | **Both** (embedded for types, remote for guides) | + +## Best practices + +1. **Always use .md** for fetching documentation +2. **Check sitemap.xml** when unsure about URL structure +3. **Prefer embedded docs** when packages are installed (version accuracy) +4. **Use remote docs** for conceptual understanding and guides +5. **Combine both** for comprehensive understanding diff --git a/.agents/skills/mastra/scripts/provider-registry.mjs b/.agents/skills/mastra/scripts/provider-registry.mjs new file mode 100755 index 0000000..11dee37 --- /dev/null +++ b/.agents/skills/mastra/scripts/provider-registry.mjs @@ -0,0 +1,180 @@ +#!/usr/bin/env node + +import { readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +function findRegistryPath() { + const rel = join('node_modules', '@mastra', 'core', 'dist', 'provider-registry.json'); + // Walk up from script location to find project root with node_modules + let dir = __dirname; + for (let i = 0; i < 10; i++) { + try { + const p = join(dir, rel); + readFileSync(p, "utf-8"); + return p; + } catch { + dir = dirname(dir); + } + } + // Fall back to cwd + return join(process.cwd(), rel); +} + +function loadRegistry() { + const path = findRegistryPath(); + try { + return JSON.parse(readFileSync(path, "utf-8")); + } catch (e) { + console.error(`Error: Could not load provider registry at ${path}`); + console.error(e.message); + process.exit(1); + } +} + +/** + * Extract version numbers from a model name for sorting. + * Returns an array of numeric segments, e.g. "gpt-5.4" → [5, 4]. + * Handles dot-separated (3.5), hyphen-separated (3-7), and mixed formats. + * Models without detectable version numbers return null. + */ +function extractVersion(name) { + // Use named capture to grab version-like sequences along with their context. + // We capture digits separated by dots/hyphens, plus any trailing letter for filtering. + const regex = /(\d+(?:[.\-]\d+)*)([a-zA-Z])?/g; + const candidates = []; + let match; + while ((match = regex.exec(name)) !== null) { + const numStr = match[1]; + const suffix = match[2] || ""; + candidates.push({ numStr, suffix, index: match.index }); + } + if (candidates.length === 0) return null; + + // Process candidates: filter and clean up non-version parts + const processed = []; + for (const c of candidates) { + let parts = c.numStr.split(/[.\-]/).map(Number); + // If followed by a size suffix (b/B/k/K/m/M/t/T) — e.g. "8b", "70B", "1t" — + // strip the last numeric part (param count) but keep earlier parts as the version + if (/^[bBkKmMtT]$/.test(c.suffix)) { + parts = parts.slice(0, -1); + if (parts.length === 0) continue; + } + // Strip date-like segments (>= 2020 or YYYYMMDD-style 8-digit numbers) + parts = parts.filter((p) => p < 2020); + if (parts.length === 0) continue; + // Skip very large standalone numbers (parameter counts, IDs) + if (parts.length === 1 && parts[0] >= 100 && candidates.length > 1) continue; + // Skip trailing date-like patterns (MM-DD) in the latter half of the name + if ( + parts.length === 2 && + parts[0] >= 1 && parts[0] <= 12 && + parts[1] >= 1 && parts[1] <= 31 && + c.index > name.length / 2 && + candidates.length > 1 + ) continue; + processed.push(parts); + } + + if (processed.length === 0) return null; + + // Return the first valid version candidate (versions appear early in model names) + return processed[0]; +} + +function compareVersionsDesc(a, b) { + const va = extractVersion(a); + const vb = extractVersion(b); + + // Models without versions go to the end + if (!va && !vb) return a.localeCompare(b); + if (!va) return 1; + if (!vb) return -1; + + // Compare version tuples numerically, descending + const len = Math.max(va.length, vb.length); + for (let i = 0; i < len; i++) { + const ai = va[i] ?? 0; + const bi = vb[i] ?? 0; + if (bi !== ai) return bi - ai; + } + // Same version — secondary sort by full name descending + return b.localeCompare(a); +} + +function printUsage() { + console.log(`Usage: provider-registry.mjs [options] + +Options: + --list List all available model providers + --provider List all models for a provider (sorted newest first) + --help Show this help message + +Examples: + node provider-registry.mjs --list + node provider-registry.mjs --provider openai + node provider-registry.mjs --provider anthropic`); +} + +function listProviders(registry) { + const entries = Object.entries(registry.providers) + .map(([key, val]) => ({ key, name: val.name || key })) + .sort((a, b) => a.key.localeCompare(b.key)); + + const maxKey = Math.max(...entries.map((e) => e.key.length)); + const maxName = Math.max(...entries.map((e) => e.name.length)); + + console.log(`${"PROVIDER".padEnd(maxKey)} ${"NAME".padEnd(maxName)} MODELS`); + console.log(`${"─".repeat(maxKey)} ${"─".repeat(maxName)} ${"─".repeat(6)}`); + for (const entry of entries) { + const modelCount = registry.providers[entry.key].models.length; + console.log(`${entry.key.padEnd(maxKey)} ${entry.name.padEnd(maxName)} ${modelCount}`); + } + console.log(`\n${entries.length} providers`); +} + +function listModels(registry, providerName) { + const provider = registry.providers[providerName]; + if (!provider) { + console.error(`Error: Provider "${providerName}" not found.`); + console.error(`Run with --list to see available providers.`); + process.exit(1); + } + + const models = [...provider.models].sort(compareVersionsDesc); + + console.log(`${provider.name || providerName} — ${models.length} models\n`); + for (const model of models) { + console.log(` ${model}`); + } +} + +const args = process.argv.slice(2); + +if (args.includes("--help") || args.length === 0) { + printUsage(); + process.exit(0); +} + +if (args.includes("--list")) { + listProviders(loadRegistry()); + process.exit(0); +} + +const providerIdx = args.indexOf("--provider"); +if (providerIdx !== -1) { + const name = args[providerIdx + 1]; + if (!name) { + console.error("Error: --provider requires a provider name."); + process.exit(1); + } + listModels(loadRegistry(), name); + process.exit(0); +} + +console.error("Error: Unknown arguments:", args.join(" ")); +printUsage(); +process.exit(1); diff --git a/skills-lock.json b/skills-lock.json index 4375b62..7e5b0e0 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -1,101 +1,107 @@ { - "version": 1, - "skills": { - "ai-sdk": { - "source": "vercel/ai", - "sourceType": "github", - "skillPath": "skills/use-ai-sdk/SKILL.md", - "computedHash": "2fdbb3c2c9c0e19c64f03f2febd4fec134473e2fe7443f7ebe67870eba5b3c75" - }, - "better-auth-best-practices": { - "source": "better-auth/skills", - "sourceType": "github", - "skillPath": "better-auth/best-practices/SKILL.md", - "computedHash": "a4c830509e85557b59339d8d93a4e243e9e59c686e7678854d39230e12c2a6dc" - }, - "building-native-ui": { - "source": "expo/skills", - "sourceType": "github", - "skillPath": "plugins/expo/skills/building-native-ui/SKILL.md", - "computedHash": "bed8dace471bc68d4d6e588fa4d72eedac54afe9f4a34ed9921da3ff994463a0" - }, - "expo-cicd-workflows": { - "source": "expo/skills", - "sourceType": "github", - "skillPath": "plugins/expo/skills/expo-cicd-workflows/SKILL.md", - "computedHash": "6b5b10d32105e345a3494cf60f1b80165f971fd72e1a0ad355ca9b0b36af1f43" - }, - "expo-deployment": { - "source": "expo/skills", - "sourceType": "github", - "skillPath": "plugins/expo/skills/expo-deployment/SKILL.md", - "computedHash": "f70cd561a2d726f329f45eb0ad262f298c57676a80d7ecf288dc3919a6a7a50d" - }, - "expo-dev-client": { - "source": "expo/skills", - "sourceType": "github", - "skillPath": "plugins/expo/skills/expo-dev-client/SKILL.md", - "computedHash": "af7b1cc2824db64888aa10d5d8c009b960d2c649dd7be2b86ae6b9f092a73ae6" - }, - "expo-tailwind-setup": { - "source": "expo/skills", - "sourceType": "github", - "skillPath": "plugins/expo/skills/expo-tailwind-setup/SKILL.md", - "computedHash": "85d932863950a9837dd4ab528c2908eff7c119e8c7f59c47963815ee7341e826" - }, - "heroui-native": { - "source": "heroui-inc/heroui", - "sourceType": "github", - "skillPath": "skills/heroui-native/SKILL.md", - "computedHash": "d2fc03a13d03678d40799726a144ba72ba1cb08126176ddbf0a0461c901aef29" - }, - "hono": { - "source": "yusukebe/hono-skill", - "sourceType": "github", - "skillPath": "skills/hono/SKILL.md", - "computedHash": "220e5e1b12bbaeec49ec362b6e2262d77632d0536e9758bba9acbbc323fef990" - }, - "native-data-fetching": { - "source": "expo/skills", - "sourceType": "github", - "skillPath": "plugins/expo/skills/native-data-fetching/SKILL.md", - "computedHash": "21253dd72e2afa0cc0a6693295f043af3b149fa250c7aedef4d33144e69258ff" - }, - "shadcn": { - "source": "shadcn/ui", - "sourceType": "github", - "skillPath": "skills/shadcn/SKILL.md", - "computedHash": "d81caa0f86aabab65b25e302d454f23a3328760386ed9078345584c0d5c8058e" - }, - "turborepo": { - "source": "vercel/turborepo", - "sourceType": "github", - "skillPath": "skills/turborepo/SKILL.md", - "computedHash": "c8b2146fc973edd6ce85d94b2a0315b6a648fba44f58d73faf009255b77e1317" - }, - "vercel-composition-patterns": { - "source": "vercel-labs/agent-skills", - "sourceType": "github", - "skillPath": "skills/composition-patterns/SKILL.md", - "computedHash": "575757e3e25761c8c562d6e395d29f0b76c98b1273c0bd72d88e6ab1bc9c7d42" - }, - "vercel-react-best-practices": { - "source": "vercel-labs/agent-skills", - "sourceType": "github", - "skillPath": "skills/react-best-practices/SKILL.md", - "computedHash": "ca7b0c0c6e5f2750043f7f0cd72d16ac4e2abc48f9b5500d047a4b77a2506212" - }, - "vercel-react-native-skills": { - "source": "vercel-labs/agent-skills", - "sourceType": "github", - "skillPath": "skills/react-native-skills/SKILL.md", - "computedHash": "41d24eafa7c3d82e270439808f7cfbc4d51aeb2d14f2809a2267c16275784d06" - }, - "web-design-guidelines": { - "source": "vercel-labs/agent-skills", - "sourceType": "github", - "skillPath": "skills/web-design-guidelines/SKILL.md", - "computedHash": "f3bc47f890f42a44db1007ab390709ec368e4b8c089baee6b0007182236ac474" - } - } + "version": 1, + "skills": { + "ai-sdk": { + "source": "vercel/ai", + "sourceType": "github", + "skillPath": "skills/use-ai-sdk/SKILL.md", + "computedHash": "2fdbb3c2c9c0e19c64f03f2febd4fec134473e2fe7443f7ebe67870eba5b3c75" + }, + "better-auth-best-practices": { + "source": "better-auth/skills", + "sourceType": "github", + "skillPath": "better-auth/best-practices/SKILL.md", + "computedHash": "a4c830509e85557b59339d8d93a4e243e9e59c686e7678854d39230e12c2a6dc" + }, + "building-native-ui": { + "source": "expo/skills", + "sourceType": "github", + "skillPath": "plugins/expo/skills/building-native-ui/SKILL.md", + "computedHash": "bed8dace471bc68d4d6e588fa4d72eedac54afe9f4a34ed9921da3ff994463a0" + }, + "expo-cicd-workflows": { + "source": "expo/skills", + "sourceType": "github", + "skillPath": "plugins/expo/skills/expo-cicd-workflows/SKILL.md", + "computedHash": "6b5b10d32105e345a3494cf60f1b80165f971fd72e1a0ad355ca9b0b36af1f43" + }, + "expo-deployment": { + "source": "expo/skills", + "sourceType": "github", + "skillPath": "plugins/expo/skills/expo-deployment/SKILL.md", + "computedHash": "f70cd561a2d726f329f45eb0ad262f298c57676a80d7ecf288dc3919a6a7a50d" + }, + "expo-dev-client": { + "source": "expo/skills", + "sourceType": "github", + "skillPath": "plugins/expo/skills/expo-dev-client/SKILL.md", + "computedHash": "af7b1cc2824db64888aa10d5d8c009b960d2c649dd7be2b86ae6b9f092a73ae6" + }, + "expo-tailwind-setup": { + "source": "expo/skills", + "sourceType": "github", + "skillPath": "plugins/expo/skills/expo-tailwind-setup/SKILL.md", + "computedHash": "85d932863950a9837dd4ab528c2908eff7c119e8c7f59c47963815ee7341e826" + }, + "heroui-native": { + "source": "heroui-inc/heroui", + "sourceType": "github", + "skillPath": "skills/heroui-native/SKILL.md", + "computedHash": "d2fc03a13d03678d40799726a144ba72ba1cb08126176ddbf0a0461c901aef29" + }, + "hono": { + "source": "yusukebe/hono-skill", + "sourceType": "github", + "skillPath": "skills/hono/SKILL.md", + "computedHash": "220e5e1b12bbaeec49ec362b6e2262d77632d0536e9758bba9acbbc323fef990" + }, + "mastra": { + "source": "mastra-ai/skills", + "sourceType": "github", + "skillPath": "skills/mastra/SKILL.md", + "computedHash": "f0ca76d36d67a345064f471a9577e752beb2b20ab46acdf154ed223905e1d3a4" + }, + "native-data-fetching": { + "source": "expo/skills", + "sourceType": "github", + "skillPath": "plugins/expo/skills/native-data-fetching/SKILL.md", + "computedHash": "21253dd72e2afa0cc0a6693295f043af3b149fa250c7aedef4d33144e69258ff" + }, + "shadcn": { + "source": "shadcn/ui", + "sourceType": "github", + "skillPath": "skills/shadcn/SKILL.md", + "computedHash": "d81caa0f86aabab65b25e302d454f23a3328760386ed9078345584c0d5c8058e" + }, + "turborepo": { + "source": "vercel/turborepo", + "sourceType": "github", + "skillPath": "skills/turborepo/SKILL.md", + "computedHash": "c8b2146fc973edd6ce85d94b2a0315b6a648fba44f58d73faf009255b77e1317" + }, + "vercel-composition-patterns": { + "source": "vercel-labs/agent-skills", + "sourceType": "github", + "skillPath": "skills/composition-patterns/SKILL.md", + "computedHash": "575757e3e25761c8c562d6e395d29f0b76c98b1273c0bd72d88e6ab1bc9c7d42" + }, + "vercel-react-best-practices": { + "source": "vercel-labs/agent-skills", + "sourceType": "github", + "skillPath": "skills/react-best-practices/SKILL.md", + "computedHash": "ca7b0c0c6e5f2750043f7f0cd72d16ac4e2abc48f9b5500d047a4b77a2506212" + }, + "vercel-react-native-skills": { + "source": "vercel-labs/agent-skills", + "sourceType": "github", + "skillPath": "skills/react-native-skills/SKILL.md", + "computedHash": "41d24eafa7c3d82e270439808f7cfbc4d51aeb2d14f2809a2267c16275784d06" + }, + "web-design-guidelines": { + "source": "vercel-labs/agent-skills", + "sourceType": "github", + "skillPath": "skills/web-design-guidelines/SKILL.md", + "computedHash": "f3bc47f890f42a44db1007ab390709ec368e4b8c089baee6b0007182236ac474" + } + } } From a8e2a90f0238fb82f6b0fdd77d4fbfa8bbbc465b Mon Sep 17 00:00:00 2001 From: Bobby Lin Date: Fri, 24 Jul 2026 11:39:32 +0800 Subject: [PATCH 02/32] chore: update docs --- docs/DESIGN.md | 254 ++++++++++++++++++ docs/prd/v0.1.0-functions.md | 304 ++++++++++++++++++++++ docs/prd/v0.1.0-plan.md | 269 +++++++++++++++++++ docs/prd/v0.1.0-tech.md | 399 ++++++++++++++++++++++++++++ docs/prd/v0.1.0.md | 486 ----------------------------------- 5 files changed, 1226 insertions(+), 486 deletions(-) create mode 100644 docs/DESIGN.md create mode 100644 docs/prd/v0.1.0-functions.md create mode 100644 docs/prd/v0.1.0-plan.md create mode 100644 docs/prd/v0.1.0-tech.md delete mode 100644 docs/prd/v0.1.0.md diff --git a/docs/DESIGN.md b/docs/DESIGN.md new file mode 100644 index 0000000..62abc55 --- /dev/null +++ b/docs/DESIGN.md @@ -0,0 +1,254 @@ +## Overview + +Cursor's marketing site reads as a quietly-confident developer brand that believes in editorial calm over IDE-darkness. The base canvas is **warm cream** (`{colors.canvas}` — #f7f7f4) holding warm near-black ink (`{colors.ink}` — #26251e) for body and display alike. The single brand voltage is **Cursor Orange** (`{colors.primary}` — #f54e00) reserved for primary CTAs and the wordmark — used scarcely. + +Type runs **CursorGothic** as the single sans family. Display sits at weight 400 with negative letter-spacing — a magazine-editorial voice rather than tech-bombastic. JetBrains Mono carries every code surface (and code surfaces are roughly half the page). + +The brand's strongest visual signature is the **AI-timeline pill palette**: five pastel pills (peach `{colors.timeline-thinking}`, mint `{colors.timeline-grep}`, blue `{colors.timeline-read}`, lavender `{colors.timeline-edit}`, gold `{colors.timeline-done}`) marking AI-action stages inside in-product timeline visualizations. Used only in product UI — never as system action colors. + +**Key Characteristics:** +- Warm cream canvas, not white. Ink is warm (#26251e), not pure black. +- Single CTA color: `{colors.primary}` (Cursor Orange #f54e00). Used scarcely. +- Display weight stays at 400 — never bold. Magazine voice. +- AI timeline pastels: 5 dedicated tokens for in-product agent action stages. +- Compact 8px CTA radius — developer dialect. +- Hairline-only depth; no drop shadows. +- 80px section rhythm. + +## Colors + +### Brand & Accent +- **Cursor Orange** (`{colors.primary}` — #f54e00): Primary CTA pills, wordmark, hero accent. Used scarcely. +- **Cursor Orange Active** (`{colors.primary-active}` — #d04200): Press state. + +### Surface +- **Canvas** (`{colors.canvas}` — #f7f7f4): Warm cream page floor. +- **Canvas Soft** (`{colors.canvas-soft}` — #fafaf7): IDE-pane background inside mockups. +- **Surface Card** (`{colors.surface-card}` — #ffffff): Pure white card surface — slight contrast against the cream canvas. +- **Surface Strong** (`{colors.surface-strong}` — #e6e5e0): Badges, tag pills. + +### Hairlines +- **Hairline** (`{colors.hairline}` — #e6e5e0): 1px divider. +- **Hairline Soft** (`{colors.hairline-soft}` — #efeee8): Lighter divider. +- **Hairline Strong** (`{colors.hairline-strong}` — #cfcdc4): Stronger panel outline. + +### Text +- **Ink** (`{colors.ink}` — #26251e): Display, body emphasis. Warm near-black. +- **Body** (`{colors.body}` — #5a5852): Default running-text. +- **Body Strong** (`{colors.body-strong}` — #26251e): Same as ink. +- **Muted** (`{colors.muted}` — #807d72): Sub-titles. +- **Muted Soft** (`{colors.muted-soft}` — #a09c92): Disabled text. +- **On Primary** (`{colors.on-primary}` — #ffffff): White text on Cursor Orange. + +### Timeline (AI-action signature) +- **Thinking** (`{colors.timeline-thinking}` — #dfa88f): Peach. Used inside in-product agent timeline only. +- **Grep** (`{colors.timeline-grep}` — #9fc9a2): Mint. +- **Read** (`{colors.timeline-read}` — #9fbbe0): Pastel blue. +- **Edit** (`{colors.timeline-edit}` — #c0a8dd): Lavender. +- **Done** (`{colors.timeline-done}` — #c08532): Warm gold. + +### Semantic +- **Success** (`{colors.semantic-success}` — #1f8a65): Confirmation indicators. +- **Error** (`{colors.semantic-error}` — #cf2d56): Validation errors. + +## Typography + +### Font Family +**CursorGothic** is the licensed display + body family. Fallback: `system-ui, "Helvetica Neue", Helvetica, Arial, sans-serif`. Code surfaces switch to **JetBrains Mono**. + +### Hierarchy + +| Token | Size | Weight | Line Height | Letter Spacing | Use | +|---|---|---|---|---|---| +| `{typography.display-mega}` | 72px | 400 | 1.1 | -2.16px | Homepage hero h1 | +| `{typography.display-lg}` | 36px | 400 | 1.2 | -0.72px | Section heads | +| `{typography.display-md}` | 26px | 400 | 1.25 | -0.325px | Sub-section heads | +| `{typography.display-sm}` | 22px | 400 | 1.3 | -0.11px | Card group titles | +| `{typography.title-md}` | 18px | 600 | 1.4 | 0 | Component titles | +| `{typography.title-sm}` | 16px | 600 | 1.4 | 0 | List labels | +| `{typography.body-md}` | 16px | 400 | 1.5 | 0 | Default body | +| `{typography.body-tracked}` | 16px | 400 | 1.5 | 0.08px | Tracked editorial body | +| `{typography.body-sm}` | 14px | 400 | 1.5 | 0 | Footer body | +| `{typography.caption}` | 13px | 400 | 1.4 | 0 | Photo captions | +| `{typography.caption-uppercase}` | 11px | 600 | 1.4 | 0.88px | Section labels, timeline pill labels | +| `{typography.code}` | 13px | 400 | 1.5 | 0 | Code blocks — JetBrains Mono | +| `{typography.button}` | 14px | 500 | 1.0 | 0 | CTA pill labels | +| `{typography.nav-link}` | 14px | 500 | 1.4 | 0 | Top-nav menu | + +### Principles +- **Display weight stays at 400.** Magazine voice, never bold. +- **Negative letter-spacing on display only.** -0.11px to -2.16px tracking. +- **JetBrains Mono on every code surface.** + +### Note on Font Substitutes +CursorGothic is licensed. Open-source substitute: **Inter** at weight 400 with letter-spacing -1.5%. Or **GT Sectra** for a more editorial feel. + +## Layout + +### Spacing System +- **Base unit:** 4px. +- **Tokens:** `{spacing.xxs}` 4px · `{spacing.xs}` 8px · `{spacing.sm}` 12px · `{spacing.base}` 16px · `{spacing.md}` 20px · `{spacing.lg}` 24px · `{spacing.xl}` 32px · `{spacing.xxl}` 48px · `{spacing.section}` 80px. +- **Section padding:** 80px. + +### Grid & Container +- Max content width: ~1200px. +- Editorial body: 12-column grid. +- Feature card grids: 2-up at desktop for splits, 3-up for benefits. +- Footer: 5-column at desktop. + +### Whitespace Philosophy +Generous editorial pacing — closer to a print magazine than a tech site. The cream canvas has plenty of breathing room; cards within bands sit close (16-24px gap). + +## Elevation & Depth + +The system uses **hairline-only depth**. No drop shadows, no elevation tiers. Cards float above the canvas via 1px hairlines and the slight white-on-cream contrast. + +| Level | Treatment | Use | +|---|---|---| +| Flat (canvas) | `{colors.canvas}` (#f7f7f4) | Body bands, footer | +| Card | `{colors.surface-card}` (#ffffff) | Content cards | +| Hairline border | 1px `{colors.hairline}` | Card outlines, dividers | +| IDE pane | `{colors.canvas-soft}` (#fafaf7) | Inside IDE mockup cards | + +### Decorative Depth +- **IDE-mockup cards** are the only "elevated" element. White card on cream canvas with internal pane structure mimicking the actual Cursor editor. +- **Timeline pastel pills** add chromatic depth without surface elevation. + +## Shapes + +### Border Radius Scale + +| Token | Value | Use | +|---|---|---| +| `{rounded.none}` | 0px | Reserved | +| `{rounded.xs}` | 4px | Inline tags | +| `{rounded.sm}` | 6px | Compact rows | +| `{rounded.md}` | 8px | CTA buttons, form inputs | +| `{rounded.lg}` | 12px | Cards, IDE panes | +| `{rounded.xl}` | 16px | Larger feature cards (rare) | +| `{rounded.pill}` | 9999px | Timeline pills, badges | +| `{rounded.full}` | 9999px | Avatars (rare) | + +## Components + +### Top Navigation + +**`top-nav`** — Background `{colors.canvas}`, text `{colors.ink}`, height 64px. Layout: Cursor wordmark left, primary horizontal menu (Pricing / Features / Enterprise / Blog / Forum / Careers), Sign In + Download primary CTA right. + +### Buttons + +**`button-primary`** — The signature Cursor Orange CTA. Background `{colors.primary}`, text `{colors.on-primary}`, type `{typography.button}` (14px / 500), padding 10px × 18px, height 40px, rounded `{rounded.md}` (8px). + +**`button-primary-active`** — Press state. Background `{colors.primary-active}`. + +**`button-secondary`** — White card pill on cream canvas. Background `{colors.surface-card}`, text `{colors.ink}`, 1px `{colors.hairline-strong}` border. + +**`button-tertiary-text`** — Inline ink text link. + +**`button-download`** — Larger ink-canvas CTA. Background `{colors.ink}`, text `{colors.canvas}`, padding 12px × 20px, height 44px. Used for "Download for macOS" type CTAs. + +### Hero & IDE Mockups + +**`hero-band`** — Background `{colors.canvas}`, full-width display headline in `{typography.display-mega}` (72px / 400 / -2.16px), subhead in `{typography.body-md}`, two CTAs (`button-download` + `button-tertiary-text`), and a centered IDE-mockup card below the hero copy. + +**`ide-mockup-card`** — A white card containing a multi-pane IDE mockup (sidebar + main editor + chat panel + terminal). Background `{colors.surface-card}`, rounded `{rounded.lg}` (12px), 1px `{colors.hairline}` border, no padding (panes fill the card edge-to-edge). + +**`ide-pane`** — Individual IDE pane inside the mockup. Background `{colors.canvas-soft}`, text `{colors.body}` in `{typography.code}` (JetBrains Mono 13px), rounded `{rounded.md}` (8px), padding 16px. + +### Cards + +**`feature-card`** — Background `{colors.surface-card}`, text `{colors.ink}`, type `{typography.title-md}`, rounded `{rounded.lg}`, padding 24px. 1px `{colors.hairline}` border. + +**`comparison-card`** — Side-by-side "Cursor vs other tools" card. Same surface and rounding; internally split into 2 columns. + +**`testimonial-card`** — Quote card. Background `{colors.surface-card}`, text `{colors.body}`, rounded `{rounded.lg}`, padding 24px. + +### AI Timeline (signature) + +**`timeline-pill-thinking`** — Peach pill. Background `{colors.timeline-thinking}`, text `{colors.ink}`, type `{typography.caption-uppercase}` (11px / 600 / 0.88px tracking, uppercase), rounded `{rounded.pill}`, padding 4px × 10px. Marks "Thinking" stage in product timeline. + +**`timeline-pill-grep`** — Mint pill. Same shape, background `{colors.timeline-grep}`. Marks "Grepping" stage. + +**`timeline-pill-read`** — Pastel-blue pill. Background `{colors.timeline-read}`. Marks "Reading" stage. + +**`timeline-pill-edit`** — Lavender pill. Background `{colors.timeline-edit}`. Marks "Editing" stage. + +**`timeline-pill-done`** — Gold pill. Background `{colors.timeline-done}`, text `{colors.on-primary}` white. Marks "Done" stage. + +### Code + +**`code-block`** — Inline code block. Background `{colors.surface-card}`, text `{colors.ink}` in `{typography.code}`, rounded `{rounded.lg}`, padding 20px, 1px `{colors.hairline}` border. + +### Pricing + +**`pricing-tier-card`** — Background `{colors.surface-card}`, rounded `{rounded.lg}`, padding 32px, 1px `{colors.hairline}` border. + +**`pricing-tier-featured`** — Featured tier inverts to ink. Background `{colors.ink}`, text `{colors.canvas}`. Same shape, dark inversion signals "highlighted" without colored ribbon. + +### Forms & Tags + +**`text-input`** — Background `{colors.surface-card}`, text `{colors.ink}`, rounded `{rounded.md}` (8px), padding 12px × 16px, height 44px. + +**`badge-pill`** — Small uppercase pill. Background `{colors.surface-strong}`, text `{colors.ink}`, type `{typography.caption-uppercase}`, rounded `{rounded.pill}`, padding 4px × 10px. + +### CTA / Footer + +**`cta-band`** — Pre-footer "Try Cursor now" band. Background `{colors.canvas}`, centered display headline in `{typography.display-lg}`, single Cursor Orange CTA. 96px vertical padding. + +**`footer`** — Closing footer. Background `{colors.canvas}`, text `{colors.body}`. 5-column link list. 64×48px padding. + +**`footer-link`** — Background transparent, text `{colors.body}`, type `{typography.body-sm}`. + +## Do's and Don'ts + +### Do +- Reserve `{colors.primary}` (Cursor Orange) for primary CTAs and brand wordmark. +- Keep display weight at 400. The editorial voice depends on this. +- Use the cream `{colors.canvas}` page floor — never pure white. +- Render every code surface (inline, blocks, IDE panes) in JetBrains Mono. +- Use timeline pastels only inside in-product agent visualizations — never as system action colors. + +### Don't +- Don't introduce a secondary brand action color. Cursor Orange is the only one. +- Don't drop display to bold weights (700+). Magazine voice depends on 400. +- Don't add drop shadows. Hairlines + ink-on-cream contrast carry the depth. +- Don't use timeline pastels on non-timeline UI. They're scoped to the agent timeline only. +- Don't extract a CTA color from a third-party widget (cookie consent, OneTrust). The brand's CTA is what appears on actual product CTAs. + +## Responsive Behavior + +### Breakpoints + +| Name | Width | Key Changes | +|---|---|---| +| Mobile | < 640px | Hero h1 72→32px; IDE mockup collapses to single pane preview; feature grid 1-up; nav hamburger. | +| Tablet | 640–1024px | Hero h1 56px; IDE mockup compresses; feature grid 2-up. | +| Desktop | 1024–1280px | Full hero h1 72px; full multi-pane IDE mockup; feature grid 3-up. | +| Wide | > 1280px | Content caps at 1200px. | + +### Touch Targets +- Primary CTA at 40px height — at WCAG AA, padded for AAA. +- Download CTA at 44px — at AAA. + +### Collapsing Strategy +- Top nav switches to hamburger below 768px. +- IDE mockup multi-pane collapses to a single primary pane preview on mobile. +- Feature grid: 3-up → 2-up → 1-up. + +## Iteration Guide + +1. Focus on a single component at a time. +2. CTAs default to `{rounded.md}` (8px). Cards use `{rounded.lg}` (12px). +3. Variants live as separate entries inside `components:`. +4. Use `{token.refs}` everywhere — never inline hex. +5. Hover state never documented. +6. CursorGothic 400 for display, 400/500/600 for body. JetBrains Mono on every code surface. +7. Cursor Orange stays scarce. +8. Timeline pastels stay scoped to in-product agent visualizations. + +## Known Gaps + +- CursorGothic is a licensed typeface; Inter is the substitute. +- Animation timings (timeline pill entrance, IDE pane reveal) out of scope. +- In-app surfaces (code editor, chat panel, agent timeline) only partially captured via marketing IDE mockups. +- Form validation states beyond focus not visible on captured surfaces. diff --git a/docs/prd/v0.1.0-functions.md b/docs/prd/v0.1.0-functions.md new file mode 100644 index 0000000..74316b7 --- /dev/null +++ b/docs/prd/v0.1.0-functions.md @@ -0,0 +1,304 @@ +# Chestnut Code - Functional Specification (v0.1.0) + +| | | +| --- | --- | +| **Product** | Chestnut Code | +| **Version** | v0.1.0 | +| **Status** | Draft | +| **Doc type** | Functional spec (what the product does) | +| **Companion** | `docs/prd/v0.1.0-tech.md` (how it is built) | +| **Last updated** | 2026-07-24 | + +## 1. Summary + +Chestnut Code is a local-first coding-agent desktop app in the spirit of Cursor and +Codex. A developer opens a local folder as a workspace, chats with an AI agent, and +the agent reads and edits code in that workspace under human review. The agent is +powered by a user-supplied **DeepSeek** API key (BYOK). A right sidebar exposes +developer tools: Git, file preview, terminal, and browser. + +### 1.1 Primary user story + +> Open a local project -> chat with the agent -> agent reads files -> agent proposes +> code changes -> user reviews the diff -> user applies changes -> user runs a command +> in the terminal. + +### 1.2 Design language + +The UI follows **Cursor**: a dense, three-pane IDE layout with a left sidebar +(workspace + conversations + file tree), a center chat/agent surface, and a right +tool sidebar. Keyboard-friendly, low-chrome, monospace where code appears. Visual +styling is defined by `docs/DESIGN.md` (the source of truth for look-and-feel): a +warm cream canvas (`#f7f7f4`) with warm near-black ink, a single scarce Cursor +Orange (`#f54e00`) CTA, editorial 400-weight display type, JetBrains Mono on all code +surfaces, and hairline-only depth (no drop shadows). The agent's tool activity uses +the DESIGN.md **AI-timeline pastels** (thinking/grep/read/edit/done). + +### 1.3 Goals (v0.1.0) + +- Open a local folder as a workspace and browse its file tree. +- Create, switch, rename, and delete agent conversations, persisted locally. +- Stream agent responses with markdown + code rendering and visible tool activity. +- Let the agent read/search files and propose edits the user accepts or rejects. +- Provide Git, file preview, terminal, and browser panels in a right sidebar. +- Configure a DeepSeek provider (BYOK) with encrypted key storage and a test action. +- Persist all structured data locally; keep secrets in an encrypted file. + +### 1.4 Non-goals (v0.1.0) + +- Multi-user, cloud sync, or team collaboration. +- Mobile / React Native (`apps/native`) and the docs site (`apps/fumadocs`). +- Remote/hosted server deployment. +- Fully autonomous multi-step runs without user-visible edit review. +- Providers other than DeepSeek. +- MCP tool integration (scaffolding exists; deferred). + +## 2. Personas + +| Persona | Need | +| --- | --- | +| Solo developer | Wants an AI pair that can read/modify a local repo with reviewable diffs. | +| Privacy-conscious dev | Wants BYOK and local-only storage; no code leaves the machine except to the chosen model provider. | + +## 3. Target surfaces + +v0.1.0 ships one shared UI across two runnable surfaces (see tech doc for details): + +| Surface | Role | +| --- | --- | +| **Desktop** (`apps/desktop`, Electrobun) | Primary, full-featured target. | +| **Web + Server** (`apps/web` + `apps/server`) | Same UI in a browser, backed by the local Hono server. | + +Both surfaces expose identical features; the difference is transport, not behavior. + +## 4. Information architecture (UI layout) + +``` ++---------------------------------------------------------------------------+ +| Title bar (workspace name · active model · settings) | ++---------------+-------------------------------------+---------------------+ +| LEFT SIDEBAR | CENTER: Conversation | RIGHT SIDEBAR (tabs)| +| | | | +| Workspace | message stream (markdown/code) | [Git] | +| - name | inline tool activity | [File Preview] | +| - switcher | proposed-edit diffs (accept/reject) | [Terminal] | +| - recents | input box + send / stop | [Browser] | +| | | | +| Conversations | | | +| - list/CRUD | | | +| | | | +| File Explorer | | | +| - tree | | | ++---------------+-------------------------------------+---------------------+ +| Status bar (branch · agent status · errors) | ++---------------------------------------------------------------------------+ +``` + +- **Left sidebar:** collapsible sections for Workspace (name + switcher + recents), + Conversations (list with create/rename/delete), and File Explorer (lazy tree). +- **Center:** the active conversation: streaming responses, inline tool events, and + proposed diffs with accept/reject controls; a composer with send and stop. +- **Right sidebar:** tabbed dev tools (Git / File Preview / Terminal / Browser). +- **Status bar:** current branch, agent activity, and surfaced errors. + +## 5. Feature catalog + +Each feature lists requirements and acceptance criteria (AC). All filesystem and +command operations are **workspace-scoped**: paths resolve against the active +workspace root and anything resolving outside it is rejected. + +### F1. Workspace management + +Requirements: +- Open a local folder as a workspace (native folder picker on desktop; on web the + local server resolves a path the user provides/selects). +- Show the current workspace name in the left sidebar. +- Track recent workspaces and allow switching. +- Refresh the file tree on demand. +- Remove a workspace from the list without deleting files on disk. + +AC: +- Opening a folder registers it and sets it active. +- Recents persist across restarts, ordered by last-opened. +- Switching a workspace reloads its file tree and its conversation list. +- Removing a workspace deletes its record + recents entry but never touches disk. + +### F2. File explorer & preview + +Requirements: +- Render the workspace file tree with lazy directory expansion. +- Open a file into the right-sidebar **File Preview** tab. +- Search files by name within the workspace. +- Show a selected file's workspace-relative path. + +AC: +- The tree reflects on-disk state after a refresh. +- Selecting a file opens it in File Preview and shows its path. +- Name search returns matches scoped to the workspace root. +- Preview renders text files with syntax highlighting; binary/oversized files show a + clear "cannot preview" state. + +### F3. Conversations + +Requirements: +- Create a conversation scoped to the active workspace. +- List conversations and switch between them. +- Rename and delete conversations. +- Persist full conversation history (including tool activity). + +AC: +- A new conversation appears immediately and persists locally. +- Switching a conversation loads its full history, including tool calls/results. +- Rename/delete update storage and UI; delete removes messages and related logs. + +### F4. Chat with the agent + +Requirements: +- Send a message to the agent. +- Stream the response incrementally. +- Render markdown and code blocks. +- Show loading and error states. +- Stop generation in progress. + +AC: +- Responses stream token-by-token (reusing the existing AI SDK `useChat` UI pattern). +- Markdown/code render via the existing `Streamdown` component. +- A visible loading state shows while submitting/streaming; errors surface clearly + with a retry affordance. +- Stop cancels the in-flight request; already-streamed content is preserved and + persisted as a partial assistant message. + +### F5. Agent provider config (DeepSeek BYOK) + +Requirements: +- Add a DeepSeek API key. +- Select the active model (e.g. a chat model and a reasoner model). +- Test the connection. +- Save configuration locally. + +AC: +- The key is written to an encrypted local file, never to the database or logs. +- Selecting a model updates the active provider config. +- Test connection performs a minimal request and reports success/failure without + persisting generated content. +- The active model is used by the agent on the next request. +- If no key is configured, chat is disabled with a clear call-to-action to add one. + +### F6. Agent file reading + +Requirements: +- Agent can list workspace files. +- Agent can read a file. +- Agent can read several relevant files in one turn. +- Agent can search files by keyword/regex. + +AC: +- `list_files`, `read_file`, and `search_files` are available agent tools and are + logged. +- All reads are confined to the workspace root; traversal outside is rejected. +- Tool activity is visible inline in the chat (tool name + target + status). + +### F7. Agent code editing (review-gated) + +Requirements: +- Agent can propose changes to one or more files. +- Show a diff before anything is written. +- Accept applies the change; reject discards it. +- Apply writes to local files atomically. + +AC: +- Proposed edits render as a unified diff inline in chat (and viewable in File + Preview). +- No file is written until the user accepts; reject discards the proposal. +- Accept applies the patch atomically and the change reflects in the file tree and + Git panel. +- Each accept/reject decision is logged. + +### F8. Git panel + +Requirements: +- Show the current branch. +- Show changed files. +- Show a file's diff. +- Refresh git status. + +AC: +- Branch and changed-file list match `git status` for the workspace. +- Selecting a changed file shows its diff. +- Refresh re-reads git state; a non-git folder shows a clear empty/disabled state. + +### F9. Terminal + +Requirements: +- Open a terminal rooted in the workspace. +- Run a command. +- Stream command output live. +- Stop a running command. + +AC: +- A PTY session starts in the workspace root and renders via `xterm.js`. +- Output streams live; keyboard input is sent to the PTY. +- Stop terminates the running process/session cleanly. + +### F10. Browser panel + +Requirements: +- Render a preview URL (e.g. a local dev server). +- Allow entering/reloading a URL. + +AC: +- The panel loads a user-provided URL and can reload it. +- A sensible default/empty state is shown when no URL is set. + +### F11. Local persistence + +Requirements: +- Persist workspaces, conversations, messages, tool-call logs, and provider + selection. + +AC: +- All structured data survives restarts. +- Secrets persist only in the encrypted key file, never in the database or logs. +- Desktop and web read/write the same local data. + +## 6. Cross-cutting behavior + +### 6.1 Streaming, stop & errors + +- Agent chat streams incrementally; terminal I/O streams live. +- Stop aborts the in-flight model request; partial output is persisted. +- Provider auth failures, network errors, and tool errors surface inline with retry + and are never silently swallowed. + +### 6.2 Safety & approval + +- **Workspace sandboxing:** every FS/command op resolves against the workspace root; + out-of-root paths are rejected. +- **Edit approval:** the agent never writes to disk without an explicit accept; a + diff is always shown first. +- **Command approval:** `run_command` surfaces the command and requires confirmation + before execution (no silent shell). Long-running commands can be stopped. +- **Key handling:** DeepSeek keys live only in the encrypted key file, decrypted in + memory at request time; never logged or sent to the client. + +## 7. Acceptance checklist (v0.1.0 "done") + +- [ ] Open and switch local workspaces; recents persist across restarts. +- [ ] Browse the file tree, preview files, and search by name. +- [ ] Create / rename / delete / switch conversations with persisted history. +- [ ] Configure a DeepSeek key (encrypted), select a model, and test the connection. +- [ ] Stream agent chat with markdown/code; stop generation works. +- [ ] Agent reads and searches files within the workspace, with visible tool activity. +- [ ] Agent proposes edits shown as diffs; accept applies, reject discards. +- [ ] Git panel shows branch, changed files, and diffs. +- [ ] Terminal runs commands in the workspace root with streamed output and stop. +- [ ] Browser panel renders a preview URL. +- [ ] All structured data persists locally; keys persist only in the encrypted file. +- [ ] Desktop and web surfaces are both fully functional. + +## 8. Open questions + +- Browser panel: embedded webview vs simple iframe, and what default URL to load. +- `run_command` policy: per-command confirmation vs an allowlist for v0.1.0. +- DeepSeek model list: which models to expose by default (chat vs reasoner) and + whether to let users type an arbitrary model id. diff --git a/docs/prd/v0.1.0-plan.md b/docs/prd/v0.1.0-plan.md new file mode 100644 index 0000000..5dc68d9 --- /dev/null +++ b/docs/prd/v0.1.0-plan.md @@ -0,0 +1,269 @@ +# Chestnut Code - Implementation Plan (v0.1.0) + +| | | +| --- | --- | +| **Product** | Chestnut Code | +| **Version** | v0.1.0 | +| **Status** | Draft | +| **Doc type** | Implementation plan (build order & tasks) | +| **Companions** | `v0.1.0-functions.md`, `v0.1.0-tech.md`, `docs/DESIGN.md` | +| **Last updated** | 2026-07-24 | + +## 0. How to read this plan + +- Work is grouped into **phases**; each phase is independently demoable and ends with + a verification gate (`pnpm run check` + `pnpm run check-types`). +- Each task lists the **primary paths** it touches and its **done** condition. +- Feature IDs (F1-F11) reference `v0.1.0-functions.md`. Architecture references point + at `v0.1.0-tech.md`. +- Phases are ordered by dependency. Phase 0 (design system) and Phase 1 (core + + shell) unblock everything else. + +## Dependency & sequencing overview + +``` +Phase 0 Design system ─┐ + ├─► Phase 2 Agent chat + read ─► Phase 3 Edit + diff ─► Phase 4 Dev panels ─► Phase 5 Polish +Phase 1 Core + shell ──┘ +``` + +Phases 0 and 1 can proceed in parallel (different areas: UI tokens vs core/adapter). + +--- + +## Phase 0 - Design system foundation (DESIGN.md) + +**Goal:** encode `docs/DESIGN.md` as reusable tokens so every later screen is on-brand +by default. No product features yet. + +Tasks: +1. **Color tokens.** Rewrite `:root` in `packages/ui/src/styles/globals.css` to the + DESIGN.md palette (warm cream canvas `#f7f7f4`, ink `#26251e`, Cursor Orange + `#f54e00` primary, hairlines, body/muted text, success/error). Map shadcn token + names (`--background`, `--foreground`, `--primary`, `--border`, `--card`, ...) to + these values. Keep a `.dark` variant but make **light the default** (DESIGN.md is + light-first). + - Path: `packages/ui/src/styles/globals.css` + - Done: app renders on cream canvas with orange primary and hairline borders. +2. **AI-timeline pastel tokens.** Add 5 dedicated tokens (`--timeline-thinking` + `#dfa88f`, `--timeline-grep` `#9fc9a2`, `--timeline-read` `#9fbbe0`, + `--timeline-edit` `#c0a8dd`, `--timeline-done` `#c08532`) exposed via `@theme + inline` (e.g. `--color-timeline-*`). Scope: agent timeline only. + - Path: `packages/ui/src/styles/globals.css` + - Done: `bg-timeline-read` etc. are usable Tailwind utilities. +3. **Typography & fonts.** Keep Inter (weight 400 display substitute for CursorGothic, + with negative tracking on display sizes). Add **JetBrains Mono** as `--font-mono` + for all code surfaces. Add display/title/body/caption/code type scale. + - Paths: `packages/ui/src/styles/globals.css`, font import in web entry. + - Done: code surfaces render in JetBrains Mono; display uses weight 400. +4. **Radius & spacing.** Set radius scale to DESIGN.md (buttons 8px `md`, cards 12px + `lg`, pills 9999px). Confirm 4px spacing base. + - Path: `packages/ui/src/styles/globals.css` + - Done: buttons at 8px, cards at 12px. +5. **Primitive pass.** Verify existing `packages/ui` primitives (button, card, input, + badge/marker) match DESIGN.md (button-primary orange, hairline-only depth, no drop + shadows). Adjust variants as needed. Add a `timeline-pill` primitive. + - Paths: `packages/ui/src/components/*` + - Done: button/card/pill visually match DESIGN.md component specs. + +**Deliverable / demo:** a token showcase (existing routes restyled) rendering the +cream+orange system, JetBrains Mono code, and the 5 timeline pills. + +**New deps:** JetBrains Mono (fontsource or self-hosted). No runtime deps. + +--- + +## Phase 1 - Core foundation + app shell + workspace (F1, F2, F11) + +**Goal:** stand up `@chestnut-code/core`, the client-adapter seam, the three-pane +Cursor shell, and workspace open + file tree/preview. + +### 1A. `@chestnut-code/core` skeleton +1. Create the package (`packages/core/package.json`, `tsconfig`, `src/index.ts` + `createCore(config)` factory). Reference tech doc §4.1 module map. +2. **Config & paths:** `~/.chestnut-code/` resolution, runtime detection (Bun vs Node). +3. **DB layer:** `@libsql/client` single-file DB at `~/.chestnut-code/chestnut.db`; + schema + migrations for app tables (`workspaces`, `recent_workspaces`, + `conversations`, `tool_call_logs`, `provider_configs`, `edit_proposals`). Decide + Drizzle vs hand-written DAL (recommend Drizzle). +4. **Keystore:** AES-256-GCM over `~/.chestnut-code/config.enc` (encrypt/decrypt, + local master key file with restrictive perms). +5. **Workspace + FS:** open/register/switch/recents; scoped `list`/`read`/`stat` with + a centralized **path guard**; name + content search (ripgrep if present, JS + fallback). + - Paths: `packages/core/src/{config,db,keystore,workspace}/**` + - Done: `core` opens a folder, persists it, lists a scoped tree, blocks traversal. + +### 1B. Transport seam +1. **Server:** add tRPC procedures for `workspace.*` and `files.*` in `packages/api` + backed by `core`. Instantiate `core` in `apps/server`. +2. **Desktop:** instantiate `core` in `apps/desktop/src/bun/index.ts` and register + Electrobun RPC handlers mirroring the same methods. +3. **Client adapter:** `apps/web/src/lib/adapter/` with `ChestnutClient` interface, + `isElectrobun()` detection, `createServerClient` (tRPC) and + `createElectrobunClient` (RPC). + - Paths: `packages/api/src/routers/*`, `apps/server/src/index.ts`, + `apps/desktop/src/bun/index.ts`, `apps/web/src/lib/adapter/*` + - Done: the same adapter calls resolve on both web (tRPC) and desktop (RPC). + +### 1C. App shell + workspace UI +1. Replace the single-column layout with the **three-pane shell** (left sidebar, + center, right tabbed sidebar, status bar) using resizable panels. +2. **Left sidebar:** Workspace section (name + switcher + recents), File Explorer + (lazy tree, name search), placeholder Conversations section. +3. **Right sidebar:** tab scaffold (Git / File Preview / Terminal / Browser) with only + **File Preview** functional (syntax-highlighted read; binary/oversized fallback). + - Paths: `apps/web/src/routes/*`, `apps/web/src/components/*`, + shared primitives into `packages/ui` where reusable. + - Done: open a folder -> see the tree -> click a file -> preview it. + +**Deliverable / demo:** open a local project, browse the tree, preview files, switch +recents - identical on desktop and web. + +**New deps:** `@libsql/client`, `drizzle-orm` (optional), `simple-git` (declared here, +used in Phase 4), a resizable-panels primitive, a syntax highlighter for preview. + +--- + +## Phase 2 - Agent chat + file reading (F3, F4, F5, F6) + +**Goal:** Mastra agent wired to DeepSeek BYOK, streaming chat, conversation CRUD, and +read-only agent tools. + +Tasks: +1. **Provider config (F5).** `provider.*` in core + adapter: set/rotate DeepSeek key + (encrypted), select model, `test` (minimal request), `get`. Settings UI form + (TanStack Form). Chat disabled with CTA when no key. + - Paths: `packages/core/src/agent/provider.ts`, `packages/api`, adapter, + `apps/web/src/routes` settings. +2. **Mastra agent (F4/F6).** Define `codingAgent` (Mastra `Agent`) in + `packages/core/src/agent/mastra.ts`; model resolved at request time from active + provider config + decrypted key (DeepSeek via Mastra model router or + `@ai-sdk/deepseek` - verify against installed version). Memory via `@mastra/libsql` + into the same DB file. +3. **Read tools (F6).** Implement `list_files`, `read_file`, `search_files` as Mastra + tools with zod schemas + path guard; log each to `tool_call_logs`. + - Path: `packages/core/src/agent/tools.ts` +4. **Streaming chat endpoint.** Replace the hardcoded Gemini `POST /ai` in + `apps/server/src/index.ts` with a Mastra-driven handler returning a `UIMessage` + stream; add the desktop RPC streaming equivalent. Thread `AbortSignal` for stop. +5. **Conversations (F3).** `conversations.*` CRUD mapped 1:1 to Mastra threads + (`mastra_thread_id`); wire the left-sidebar conversation list (create/rename/delete/ + switch) and load history on switch. +6. **Chat UI.** Extend `apps/web/src/routes/ai.tsx` patterns into the center pane: + `useChat` + `Streamdown`, render tool-call parts using the **timeline pastels** + (thinking/grep/read), loading + error/retry, stop button. + - Paths: `apps/web/src/routes/*`, `packages/ui` message/timeline components. + +**Deliverable / demo:** configure a DeepSeek key, ask the agent about the repo, watch +it list/read/search files with a live timeline, stop mid-stream, switch conversations +with persisted history. + +**New deps:** `@mastra/core`, `@mastra/memory`, `@mastra/libsql`, DeepSeek provider +(model router or `@ai-sdk/deepseek`). Remove `@ai-sdk/google` usage from server. + +--- + +## Phase 3 - Agent code editing with diff review (F7) + +**Goal:** review-gated edits end to end. + +Tasks: +1. **Edit tools.** `propose_edit` (produces a unified diff, writes an `edit_proposals` + row, writes nothing to disk) and `apply_patch` (applies an approved proposal + atomically). Approval state tracked per turn. + - Paths: `packages/core/src/agent/{tools,edits}.ts` +2. **Apply/reject API.** `edits.apply` / `edits.reject` in core + adapter; log + decisions to `tool_call_logs`. +3. **Diff UI.** Render proposed edits inline in chat as a unified diff (also viewable + in File Preview) with **Accept / Reject** controls; use the `edit` timeline pastel. + On accept, refresh the file tree and Git panel state. + - Paths: `apps/web/src/components/*` (diff viewer), routes. + +**Deliverable / demo:** ask for a change -> see a diff -> Accept writes it (Reject +discards) -> change appears in tree and git. + +**New deps:** `diff` (unified-diff gen/parse) + a diff-viewer component. + +--- + +## Phase 4 - Dev panels: Git, Terminal, Browser (F8, F9, F10) + +**Goal:** complete the right-sidebar tool suite. + +Tasks: +1. **Git panel (F8).** `git.status` / `git.diff` via `simple-git` in core + adapter; + panel shows branch, changed files, per-file diff, refresh; non-git folder shows a + clear empty state. + - Paths: `packages/core/src/git/*`, `packages/api`, adapter, right-sidebar Git tab. +2. **Terminal (F9).** `PtyAdapter` seam in core; `node-pty` on server; desktop uses + Bun PTY if available, **falling back to `node-pty` under Bun**. Transport: WS + (server) / RPC stream (desktop). UI via `@xterm/xterm` rooted in workspace; run, + stream, stop. Also backs the `run_command` tool (with confirmation). + - Paths: `packages/core/src/terminal/*`, server WS route, desktop RPC channel, + right-sidebar Terminal tab. +3. **Browser panel (F10).** URL bar + reload; embedded webview (desktop) / iframe + (web) rendering a preview URL with a sensible empty state. + - Paths: right-sidebar Browser tab. + +**Deliverable / demo:** review a change in Git, run the dev server in Terminal, preview +it in the Browser tab. + +**New deps:** `node-pty`, `@xterm/xterm` (+ fit addon). + +--- + +## Phase 5 - Polish & hardening (cross-cutting §6) + +**Goal:** meet the functional acceptance checklist and DESIGN.md fidelity. + +Tasks: +1. **Stop & errors everywhere:** consistent stop for chat + terminal; inline + provider/network/tool error surfaces with retry; partial-output persistence. +2. **Tool-call log surfacing:** optional per-conversation activity/audit view. +3. **Recents & workspace switching** edge cases (missing folder, moved path). +4. **Command policy (F7/§6):** finalize per-command confirmation for `run_command`. +5. **DESIGN.md visual pass:** spacing (80px rhythm where relevant), hairline-only + depth, scarce orange, timeline pastels scoped to the agent timeline, responsive + collapse of panes. +6. **Full acceptance sweep:** walk the checklist in `v0.1.0-functions.md` §7 on both + desktop and web. + +**Deliverable / demo:** the full primary user story on both surfaces, on-brand. + +--- + +## Design-token mapping (product usage of DESIGN.md) + +| Product surface | DESIGN.md token(s) | +| --- | --- | +| App canvas / panes | `canvas` `#f7f7f4`, `canvas-soft` `#fafaf7` (editor/preview panes) | +| Cards, dialogs | `surface-card` `#ffffff`, hairline `#e6e5e0` borders (no shadows) | +| Primary CTA (Send, Apply, Download) | `primary` `#f54e00` / `primary-active` `#d04200` (scarce) | +| Body / headings | `body` `#5a5852`, `ink` `#26251e`, display weight 400 | +| Code (preview, diff, chat code, terminal) | JetBrains Mono, `code` 13px | +| Agent tool activity timeline | `timeline-thinking/grep/read/edit/done` pastels | +| Success / error (test connection, validation) | `semantic-success` `#1f8a65`, `semantic-error` `#cf2d56` | +| Badges / tags (branch, model) | `badge-pill` on `surface-strong` `#e6e5e0` | + +> The AI-timeline pastels map directly onto agent stages: **thinking** (peach) while +> reasoning, **grep** (mint) during `search_files`, **read** (blue) during +> `read_file`/`list_files`, **edit** (lavender) for `propose_edit`/`apply_patch`, +> **done** (gold) on completion. Keep these scoped to the agent timeline only. + +## Cross-phase decisions to lock before/at Phase 2 + +- Drizzle vs hand-written DAL over LibSQL (recommend Drizzle). +- Mastra + Hono integration: plain route driving `core` (preferred) vs mounting + Mastra's server. +- DeepSeek wiring: Mastra model router (`"deepseek/"`) vs `@ai-sdk/deepseek`; + confirm valid model ids once installed. +- Confirm Bun PTY parity (Phase 4) or commit to the `node-pty`-under-Bun fallback. + +## Verification (every phase) + +1. `pnpm run check` (Biome, auto-fix). +2. `pnpm run check-types` (TS across workspaces). +3. Manual demo of that phase's deliverable on **both** desktop and web. + +(No test runner is configured; do not assume one.) diff --git a/docs/prd/v0.1.0-tech.md b/docs/prd/v0.1.0-tech.md new file mode 100644 index 0000000..02fe699 --- /dev/null +++ b/docs/prd/v0.1.0-tech.md @@ -0,0 +1,399 @@ +# Chestnut Code - Technical Design (v0.1.0) + +| | | +| --- | --- | +| **Product** | Chestnut Code | +| **Version** | v0.1.0 | +| **Status** | Draft | +| **Doc type** | Technical design (how it is built) | +| **Companion** | `docs/prd/v0.1.0-functions.md` (what it does) | +| **Last updated** | 2026-07-24 | + +> Framework note: this design uses **Mastra** as the agent framework (agent loop, +> tools, memory, storage) instead of calling the Vercel AI SDK directly. Mastra is +> built on the AI SDK, so the existing `useChat` streaming UI is preserved. Mastra +> is not yet installed; exact APIs and the DeepSeek model id must be verified against +> the installed version at implementation time (see the `mastra` skill). + +## 1. Scope & principles + +- **Local-first:** all data lives on the user's machine; the only network egress is + to the chosen model provider (DeepSeek). +- **One UI, two transports:** a single React UI runs on desktop (Electrobun RPC) and + in the browser (Hono + tRPC/SSE/WS). Behavior is identical; only transport differs. +- **Shared core:** all runtime logic (workspace/FS, git, terminal, the Mastra agent, + persistence, key store) lives in one package used by both surfaces. +- **Review-gated writes:** the agent proposes; the user approves; only then does core + touch disk. + +## 2. Current repo baseline (what exists today) + +- Monorepo: Turborepo + pnpm, Biome, Better-Auth scaffolding. +- `apps/server` (Hono, port 3020): mounts Better-Auth at `/api/auth/*`, tRPC at + `/trpc/*`, and a **hardcoded** `POST /ai` endpoint using `google("gemini-2.5-flash")` + via the AI SDK. This endpoint is replaced in v0.1.0. +- `apps/web` (React 19 + TanStack Router + Vite): `src/routes/ai.tsx` already uses + `@ai-sdk/react` `useChat` with `DefaultChatTransport` pointing at the server `/ai`. +- `apps/desktop` (Electrobun): loads `web/dist` (or the web dev server under HMR) in a + BrowserWindow; Bun entrypoint at `src/bun/index.ts`. +- `packages/api` (tRPC): `router`, `publicProcedure`, `protectedProcedure`, context + from Better-Auth session; app router currently only `healthCheck` + `privateData`. +- `packages/ui`: shadcn/ui primitives (bubble, message, message-scroller, input-group, + button, dropdown-menu, tooltip, etc.). `Streamdown` used for markdown rendering. +- `packages/core`: **empty placeholder** (no source yet) - this is where core lands. +- Catalog already pins `ai`, `hono`, `@trpc/*`, `zod`, `better-auth`, `lucide-react`. + +## 3. Target architecture + +### 3.1 Component overview + +```mermaid +flowchart TB + subgraph UI["Shared React UI (apps/web)"] + Adapter["Client Adapter (transport selector)"] + end + + subgraph Desktop["Desktop (Electrobun Bun process)"] + RPC["Electrobun RPC"] + CoreD["@chestnut-code/core"] + end + + subgraph Server["Server (Hono, Node)"] + HTTP["tRPC + SSE (chat) + WS (terminal)"] + CoreS["@chestnut-code/core"] + end + + subgraph Core["@chestnut-code/core"] + Mastra["Mastra agent + tools + memory"] + WS["Workspace / FS / search"] + GIT["Git service"] + PTY["Terminal / PTY service"] + DB["Data layer (LibSQL)"] + KEYS["Encrypted key store"] + end + + subgraph Local["Local machine"] + FILES["Workspace files"] + GITBIN["git"] + DBFILE["SQLite ~/.chestnut-code/chestnut.db"] + ENC["Encrypted keys ~/.chestnut-code/config.enc"] + end + + DeepSeek["DeepSeek API"] + + Adapter -->|desktop| RPC --> CoreD + Adapter -->|web| HTTP --> CoreS + CoreD --- Core + CoreS --- Core + Mastra --> DeepSeek + WS --> FILES + GIT --> GITBIN + DB --> DBFILE + KEYS --> ENC +``` + +### 3.2 Why a shared `core` package + +A browser cannot touch the local filesystem, spawn a PTY, or shell out to git. To +keep both desktop and web functional with one implementation, all runtime logic is +extracted into `@chestnut-code/core`: + +- **Desktop** imports `core` directly and runs it **in-process** in the Bun runtime + (lowest latency, no network hop), exposing it to the webview via Electrobun RPC. +- **Server** imports the same `core` and exposes it over tRPC + SSE (chat streaming) + + WebSocket (terminal) for the browser client. + +### 3.3 Client adapter (transport abstraction) + +The UI never calls a transport directly. It calls a typed **client adapter** +interface; a runtime check selects the implementation: + +```ts +// apps/web/src/lib/adapter/index.ts (shape) +export interface ChestnutClient { + workspace: { open(path: string): Promise; recents(): Promise; tree(id: string, dir?: string): Promise; /* ... */ }; + files: { read(id: string, path: string): Promise; search(id: string, q: string): Promise }; + conversations: { list(wsId: string): Promise; create(...): Promise; /* ... */ }; + chat: { stream(input: ChatInput, signal: AbortSignal): AsyncIterable }; + edits: { apply(id: string, patch: Patch): Promise; reject(proposalId: string): Promise }; + git: { status(id: string): Promise; diff(id: string, path: string): Promise }; + terminal: { open(id: string): TerminalSession /* duplex stream */ }; + provider: { get(): Promise; setKey(k: string): Promise; test(): Promise }; +} + +// selection +export const client: ChestnutClient = + isElectrobun() ? createElectrobunClient() : createServerClient(SERVER_URL); +``` + +- `isElectrobun()` detects the desktop shell (Electrobun global / injected flag). +- `createServerClient` uses the tRPC client for request/response, `fetch`+SSE for + chat streaming (compatible with `useChat`'s `DefaultChatTransport`), and a + WebSocket for the terminal. +- `createElectrobunClient` bridges the same methods over Electrobun RPC and RPC + streams. + +### 3.4 Agent request flow (Mastra) + +```mermaid +sequenceDiagram + participant U as User + participant UI as UI (useChat) + participant A as Adapter + participant C as core + participant M as Mastra Agent + participant P as DeepSeek + participant FS as Workspace + + U->>UI: send message + UI->>A: chat.stream(conversationId, message) + A->>C: stream(...) + C->>M: agent.stream(messages, { resourceId, threadId }) + loop tool calls + M->>P: model call with tool schema + P-->>M: tool call (read_file / search_files / ...) + M->>C: execute tool (workspace-scoped) + C->>FS: perform op + FS-->>C: result + C-->>M: tool result (logged) + end + M-->>C: text deltas (AI SDK stream) + C-->>A: UIMessage chunks + A-->>UI: stream -> render markdown + tool activity +``` + +## 4. `@chestnut-code/core` design + +Runtime-agnostic (must run in both Bun and Node). Avoid runtime-specific APIs in +shared code (no `bun:sqlite`, no `node:sqlite`, no native `better-sqlite3`). + +### 4.1 Module map + +``` +packages/core/src/ + index.ts # public factory: createCore(config) -> Core + config/ # paths (~/.chestnut-code), runtime detection + db/ + client.ts # LibSQL client (@libsql/client) - single db file + schema.ts # tables (Drizzle or hand-written DAL) + migrations/ # schema migrations + keystore/ + index.ts # AES-256-GCM encrypt/decrypt over config.enc + workspace/ + index.ts # open/register/switch, recents + fs.ts # scoped list/read/stat, path guard + search.ts # name + content search (ripgrep if present, JS fallback) + git/ + index.ts # simple-git wrapper: status, diff, branch + terminal/ + index.ts # PTY session mgmt (adapter injected per runtime) + agent/ + mastra.ts # Mastra instance + Agent definition + tools.ts # workspace-scoped tools (list/read/search/propose/apply/run/git) + provider.ts # resolve active model from provider config + decrypted key + edits.ts # diff generation + atomic patch application + services/ # thin orchestration used by both transports +``` + +### 4.2 Mastra integration + +- **Agent:** a single `codingAgent` (Mastra `Agent`) with instructions describing the + workspace-scoped coding assistant behavior and the review-gated edit protocol. +- **Model:** resolved at request time from the active provider config. DeepSeek is + supplied either through Mastra's model router (`"deepseek/"`) or the + `@ai-sdk/deepseek` provider factory with the decrypted key. The exact model id and + wiring are verified against the installed Mastra version. +- **Tools:** custom Mastra tools defined with `zod` input schemas (see 4.3). Each tool + receives the active workspace root via closure/context and enforces the path guard. +- **Memory & storage:** Mastra `Memory` backed by `@mastra/libsql` (`LibSQLStore`) + writing to the same `chestnut.db` file, giving thread/message persistence and recall + keyed by `resourceId` (workspace) + `threadId` (conversation). +- **Streaming:** `agent.stream(...)` returns an AI SDK-compatible stream; core adapts + it into `UIMessage` chunks so the existing `useChat` UI works unchanged. +- **Cancellation:** an `AbortSignal` is threaded from the transport into + `agent.stream`. + +### 4.3 Agent tools (workspace-scoped) + +| Tool | Input (zod) | Behavior | Side effects | +| --- | --- | --- | --- | +| `list_files` | `{ dir?: string }` | List entries under a workspace-relative dir | Read-only | +| `read_file` | `{ path: string }` | Read a file's contents | Read-only | +| `search_files` | `{ query: string, regex?: boolean }` | Content search across workspace | Read-only | +| `propose_edit` | `{ edits: {path, newContent|patch}[] }` | Produce a unified diff proposal | None until approved | +| `apply_patch` | `{ proposalId }` | Apply an approved proposal atomically | Writes files (gated) | +| `run_command` | `{ command: string }` | Run a shell command in workspace root | Executes (gated) | +| `git_status` | `{}` | Branch + changed files | Read-only | + +- **Path guard:** every path is resolved against the workspace root; a resolved path + outside the root throws before any I/O. +- **Approval gating:** `propose_edit` never writes; it stores a proposal and emits a + diff event. `apply_patch`/`run_command` only proceed after an explicit user accept + relayed from the UI (approval state tracked per conversation turn). +- **Logging:** every tool invocation writes a `tool_call_logs` row (name, args, + result summary, status). Secrets are never logged. + +### 4.4 Persistence (LibSQL) + +Single SQLite file at `~/.chestnut-code/chestnut.db` via `@libsql/client` (works in +both Bun and Node). Mastra owns its memory tables; Chestnut owns app tables. A small +DAL (Drizzle recommended, or hand-written) sits on top. + +App-owned tables: + +| Table | Key columns | Purpose | +| --- | --- | --- | +| `workspaces` | `id, name, root_path, created_at` | Registered workspaces | +| `recent_workspaces` | `workspace_id, last_opened_at` | Recents ordering | +| `conversations` | `id, workspace_id, title, mastra_thread_id, created_at, updated_at` | Conversations (mapped to Mastra threads) | +| `tool_call_logs` | `id, conversation_id, tool_name, args_json, result_json, status, created_at` | Audit log | +| `provider_configs` | `id, provider, model, is_active, created_at` | Non-secret model selection | +| `edit_proposals` | `id, conversation_id, diff, status(pending/applied/rejected), created_at` | Review-gated edits | + +Mastra-owned tables: created and managed by `@mastra/libsql` (threads, messages, +working memory). Conversations map 1:1 to Mastra threads via `mastra_thread_id`, so +message history and tool parts persist and replay through Mastra. + +> **API keys are never stored in SQLite** - only `provider + model + is_active`. + +### 4.5 Encrypted key store + +- File: `~/.chestnut-code/config.enc`, AES-256-GCM (Node `crypto`, available in Bun). +- Contents: `{ deepseek: { apiKey }, ... }` plus active selection mirror. +- A local master key derived from a machine-bound secret (e.g. a random key file with + restrictive permissions in `~/.chestnut-code/`) for v0.1.0; OS keychain integration + is a future enhancement. +- Keys are decrypted in memory only at request time; never logged, never sent to the + client, never crosses the RPC/HTTP boundary to the UI. + +### 4.6 Terminal service + +- Cross-runtime seam: `core` defines a `PtyAdapter` interface; each host injects an + implementation. + - **Server (Node):** `node-pty`. + - **Desktop (Bun):** Bun's PTY / child-process spawn (verify Bun PTY support; fall + back to `node-pty` under Bun if needed). +- UI renders via `xterm.js`. Transport: WebSocket (server) or Electrobun RPC stream + (desktop). Sessions are spawned in the workspace root and are stoppable. + +## 5. Transport layers + +### 5.1 Server (`apps/server`, Hono) + +- Keep Better-Auth (`/api/auth/*`) and tRPC (`/trpc/*`). +- **Replace** the hardcoded `POST /ai` (Gemini) with a chat endpoint that drives the + Mastra agent via `core` and returns a `UIMessage` stream (`createUIMessageStream` + compatible with `useChat`). +- Add tRPC procedures for non-streaming ops (workspace, files, conversations, git, + provider config, edit apply/reject). +- Add a **WebSocket** endpoint for terminal I/O. +- Two integration options for Mastra on Hono (decide at implementation time): + 1. Drive `core`'s Mastra agent from a plain Hono route (thin, explicit) - preferred + to reuse the existing `/ai`-style handler. + 2. Mount Mastra's own Hono server/handlers - more built-in features, more surface. + +### 5.2 Desktop (`apps/desktop`, Electrobun) + +- Bun entrypoint constructs `core` in-process and registers Electrobun RPC handlers + mirroring the adapter interface, including streaming channels for chat and terminal. +- The webview loads the same `web` build; the adapter detects Electrobun and routes to + RPC instead of HTTP. + +### 5.3 tRPC surface (sketch) + +``` +appRouter + workspace: { open, listRecents, remove, tree, refresh } + files: { read, searchByName } + conversations: { list, create, rename, delete, get } + git: { status, diff } + provider: { get, setKey, selectModel, test } + edits: { apply, reject } + // chat streaming + terminal are NOT tRPC: SSE + WS respectively +``` + +## 6. Frontend plan (`apps/web`) + +- **Layout shell:** replace the current single-column layout with the three-pane + Cursor-style shell (left sidebar, center, right tabbed sidebar, status bar). New + routes under `src/routes/` (e.g. `/workspace`), reusing TanStack Router. +- **State:** TanStack Query for server/adapter data; local UI state for panel sizes, + active tabs, and pending edit approvals. +- **Chat:** reuse `src/routes/ai.tsx` patterns (`useChat`, `Streamdown`, + message-scroller, input-group). Extend message rendering to show tool-call parts + and diff parts with accept/reject buttons. +- **New UI pieces (via shadcn into `packages/ui` where shared):** resizable panels, + file-tree, tabs, diff viewer, `xterm.js` terminal wrapper, provider-settings form + (TanStack Form), git panel, browser panel (webview/iframe). +- **Theme:** implement `docs/DESIGN.md` as the source of truth - warm cream canvas + (light, not dark-first), single scarce Cursor Orange CTA, CursorGothic/Inter at + weight 400 for display, JetBrains Mono on code, hairline-only depth. Encode tokens + (colors, spacing, radius, typography) in `packages/ui` global styles; map the + AI-timeline pastels to agent tool-activity states. `next-themes` stays wired in + `__root.tsx`. + +## 7. Dependencies to add + +Reference the catalog where a version already exists; add new catalog entries for +shared deps. + +| Concern | Package(s) | Notes | +| --- | --- | --- | +| Agent framework | `@mastra/core`, `@mastra/libsql`, `@mastra/memory` | Verify exact names/versions on install | +| Provider (DeepSeek) | Mastra model router or `@ai-sdk/deepseek` | BYOK key at request time | +| DB driver | `@libsql/client` | Cross-runtime (Bun + Node) | +| DB DAL (optional) | `drizzle-orm` | Or a hand-written DAL | +| Git | `simple-git` | Or `git` CLI shell-out | +| Terminal | `node-pty` (+ Bun PTY seam), `@xterm/xterm` | UI + host PTY | +| Diff | `diff` / `diff-match-patch` | Unified-diff gen + render | + +Removed/replaced: the hardcoded `@ai-sdk/google` Gemini path in `apps/server`. + +## 8. Security & safety (implementation notes) + +- **Path guard** centralized in `core/workspace/fs.ts`; all tools and transports go + through it. +- **Approval protocol:** edits and commands require an explicit UI accept; core tracks + proposal/command state and refuses to write/execute without it. +- **Secret isolation:** keys only in `config.enc`; a lint/review rule to ensure keys + never enter SQLite, logs, or client payloads. +- **CORS:** server already restricts to `env.CORS_ORIGIN`; keep it tight for local use. + +## 9. Build, run & verify + +- Dev: `pnpm run dev` (all), or `dev:web` / `dev:server` / `dev:desktop`. +- Desktop HMR: `pnpm run dev:desktop` (loads web dev server) + `dev:server`. +- Verify before completing work: `pnpm run check` (Biome) and `pnpm run check-types`. +- No test runner is configured; do not assume one. + +## 10. Delivery phases (maps to functional features) + +Each phase is independently demoable. + +1. **Foundation:** create `@chestnut-code/core` (config, LibSQL client + schema, + keystore, workspace FS + path guard, client-adapter seam). Web: three-pane shell + + workspace open + file tree. Features: F1, F2 (read/preview), F11. +2. **Agent chat + read:** Mastra agent + DeepSeek BYOK config; tools `list_files`, + `read_file`, `search_files`; streaming chat replacing `/ai`; conversation CRUD + mapped to Mastra threads. Features: F3, F4, F5, F6. +3. **Edit + diff:** `propose_edit` / `apply_patch`, diff rendering, accept/reject, + atomic writes, `edit_proposals` + logs. Feature: F7. +4. **Dev panels:** Git panel, Terminal (PTY + xterm.js + WS/RPC), Browser panel. + Features: F8, F9, F10. +5. **Polish:** stop generation, error/retry states, recents, tool-call log surfacing, + Cursor-style visual pass. Cross-cutting section 6. + +## 11. Risks & open technical questions + +- **Bun PTY parity:** the terminal targets **both** surfaces in v0.1.0. Confirm Bun + can host a PTY for the desktop terminal; if not, run `node-pty` under Bun as the + fallback (server surface always uses `node-pty`). +- **Mastra API drift:** Mastra evolves fast; confirm `Agent`, `Memory`, `LibSQLStore`, + streaming, and model-router signatures against the installed version before coding. +- **Mastra + Hono integration choice:** plain route vs mounting Mastra's server (5.1). +- **DeepSeek model ids:** confirm valid model strings and reasoner support via the + provider registry once Mastra is installed. +- **Drizzle vs hand-written DAL** over LibSQL, and how to coexist cleanly with + Mastra-owned tables in the same file. +- **Cross-runtime `core` packaging:** ensure the built package works in both Node + (server) and Bun (desktop) without runtime-specific imports leaking in. diff --git a/docs/prd/v0.1.0.md b/docs/prd/v0.1.0.md deleted file mode 100644 index 416b29a..0000000 --- a/docs/prd/v0.1.0.md +++ /dev/null @@ -1,486 +0,0 @@ -# Chestnut Code - Product Requirements Document (v0.1.0) - -| | | -| --- | --- | -| **Product** | Chestnut Code | -| **Version** | v0.1.0 | -| **Status** | Draft | -| **Targets** | Web, Desktop, Server | -| **Last updated** | 2026-06-30 | - -## 1. Overview - -Chestnut Code is a local-first coding agent desktop application, inspired by tools -like Codex. It lets a developer open a local folder as a workspace, chat with an -AI agent, and have that agent read and write code in the workspace. The agent is -backed by a user-supplied model provider (BYOK: DeepSeek or OpenRouter). A right -sidebar exposes developer tools (Git, file preview, terminal, browser) so the user -can review the agent's work without leaving the app. - -### 1.1 Vision - -Give developers a focused, local-first surface where an AI agent can safely operate -on their codebase: reading files, proposing reviewable edits, running commands, and -inspecting git state, all scoped to a single workspace. - -### 1.2 Goals (v0.1.0) - -- Open a local folder as a workspace and browse its file tree. -- Create and manage conversations with an AI coding agent. -- Stream agent responses with markdown/code rendering. -- Let the agent read files and propose code edits that the user accepts or rejects. -- Provide Git, file preview, terminal, and browser panels in a right sidebar. -- Support BYOK provider configuration for DeepSeek and OpenRouter. -- Persist everything locally (SQLite + encrypted key store). - -### 1.3 Non-goals (v0.1.0) - -- Multi-user / cloud sync / team collaboration. -- Mobile / React Native target (the `native` app is out of scope for v0.1.0). -- Remote/hosted deployment of the server. -- Autonomous multi-step execution without user-visible review of edits. -- Provider support beyond DeepSeek and OpenRouter. -- Authentication beyond what the scaffolded Better-Auth provides (local single-user). - -## 2. Target platforms and scope - -v0.1.0 ships three coordinated targets that share one frontend and one core logic -layer. - -| Target | Role | Hosting of system access | -| --- | --- | --- | -| **Desktop** (`apps/desktop`, Electrobun) | Primary, full-featured target | Hosts core **in-process** in the Bun process | -| **Server** (`apps/server`, Hono) | Backend for the browser web client | Hosts the same core over tRPC + streaming | -| **Web** (`apps/web`, React + TanStack Router) | Shared UI; runs in browser or inside the desktop shell | Thin client; talks to desktop RPC or local server | - -The `native` (Expo) and `fumadocs` apps are present in the monorepo but are not part -of v0.1.0 scope. - -## 3. Architecture - -### 3.1 Reconciling "desktop-centric" with a working web target - -System access (filesystem, git, terminal, agent execution) is **desktop-centric**: -the Electrobun Bun process owns it and runs it in-process for the highest-fidelity, -lowest-latency path. A browser web app cannot touch the local filesystem, spawn a -terminal, or call git directly, so to keep the web target functional all of that -logic is extracted into a single shared package that the local server can also host. - -- New shared package **`@chestnut-code/core`** contains all runtime-agnostic logic: - workspace/FS operations, file search, git, terminal/PTY, the agent tool-loop - (Vercel AI SDK), the SQLite data layer, and the encrypted key store. -- **Desktop** imports `core` directly and exposes it to the webview via Electrobun - RPC (no network hop). -- **Server** imports the same `core` and exposes it over tRPC + streaming (SSE for - agent/chat, WebSocket for terminal) for the browser web client. -- The frontend uses a thin **client adapter** that selects the transport at runtime: - Electrobun RPC when running inside the desktop shell, tRPC/HTTP/WS when running in - a browser against the local server. - -### 3.2 Component diagram - -```mermaid -flowchart TB - subgraph UI["Shared React UI (apps/web)"] - Adapter["Client Adapter"] - end - - subgraph Desktop["Desktop (Electrobun Bun)"] - RPC["Electrobun RPC"] - CoreD["@chestnut-code/core"] - end - - subgraph Server["Server (Hono)"] - TRPC["tRPC + SSE/WS"] - CoreS["@chestnut-code/core"] - end - - subgraph Local["Local machine"] - FS["Workspace files"] - Git["git"] - PTY["Terminal / PTY"] - DB["SQLite ~/.chestnut-code/chestnut.db"] - Keys["Encrypted keys ~/.chestnut-code/config.enc"] - end - - Provider["LLM provider (DeepSeek / OpenRouter)"] - - Adapter -->|desktop| RPC --> CoreD - Adapter -->|web| TRPC --> CoreS - CoreD --> FS & Git & PTY & DB & Keys - CoreS --> FS & Git & PTY & DB & Keys - CoreD --> Provider - CoreS --> Provider -``` - -### 3.3 Agent request flow - -```mermaid -sequenceDiagram - participant U as User - participant UI as UI - participant Core as core agent loop - participant LLM as Provider - participant FS as Workspace - - U->>UI: Send message - UI->>Core: stream(conversationId, message) - Core->>LLM: streamText(messages, tools) - loop tool calls - LLM-->>Core: tool call (read_file / search / ...) - Core->>FS: execute (workspace-scoped) - FS-->>Core: result - Core->>LLM: tool result - end - LLM-->>Core: text delta stream - Core-->>UI: stream deltas + tool events - UI-->>U: render markdown + tool activity -``` - -## 4. Tech stack mapping - -Built on the existing Better-T-Stack monorepo (turborepo + pnpm). v0.1.0 reuses what -is scaffolded and adds a shared core package and a few dependencies. - -| Concern | Choice | Notes | -| --- | --- | --- | -| Monorepo / build | Turborepo + pnpm | Already configured | -| Frontend | React 19 + TanStack Router + Vite | `apps/web`, already scaffolded | -| UI kit | shadcn/ui via `packages/ui` | Reuse existing primitives | -| Desktop shell | Electrobun | `apps/desktop`, loads `web/dist` + Bun process | -| Server | Hono + tRPC (Node) | `apps/server`, `packages/api` | -| Agent SDK | Vercel AI SDK (`ai`) | Already a dependency; swap hardcoded Gemini for BYOK providers | -| Providers | `@ai-sdk/deepseek`, `@openrouter/ai-sdk-provider` | New deps | -| Persistence | SQLite via `@libsql/client` | Cross-runtime (Node + Bun); single local DB file | -| Terminal | `node-pty` (server) / Bun PTY (desktop) + `xterm.js` (UI) | New deps | -| Git | `simple-git` or `git` CLI shell-out | New dep | -| Encrypted keys | Node `crypto` AES-256-GCM over a local file | No extra dep | -| Lint / format | Biome | Already configured | - -### 4.1 New / changed packages - -``` -packages/ - core/ # NEW: @chestnut-code/core - workspace, fs, git, terminal, agent, db, keystore -apps/ - desktop/ # hosts core in Bun process, exposes via Electrobun RPC - server/ # hosts core over tRPC + SSE/WS - web/ # shared UI + client adapter (desktop RPC | server tRPC) -``` - -> **Cross-runtime SQLite note:** because `core` runs in both Bun (desktop) and Node -> (server), the DB driver must work in both. `@libsql/client` is the recommended -> choice; a small hand-written data-access layer (or Drizzle) sits on top. Avoid -> runtime-specific drivers (`bun:sqlite`, `node:sqlite`, native `better-sqlite3`) -> in shared code. - -## 5. Data model and on-disk layout - -### 5.1 On-disk layout - -``` -~/.chestnut-code/ - chestnut.db # SQLite database (all structured data) - config.enc # AES-256-GCM encrypted provider keys + active selection - logs/ # optional rotating logs (no secrets) -``` - -### 5.2 SQLite schema (logical) - -| Table | Key columns | Purpose | -| --- | --- | --- | -| `workspaces` | `id`, `name`, `root_path`, `created_at` | Registered workspaces | -| `recent_workspaces` | `workspace_id`, `last_opened_at` | Recent-workspace ordering | -| `conversations` | `id`, `workspace_id`, `title`, `created_at`, `updated_at` | Conversations per workspace | -| `messages` | `id`, `conversation_id`, `role`, `created_at` | Chat messages | -| `message_parts` | `id`, `message_id`, `type`, `content`, `order` | Text / tool-call / tool-result parts (AI SDK UIMessage parts) | -| `tool_call_logs` | `id`, `message_id`, `tool_name`, `args_json`, `result_json`, `status`, `created_at` | Audit log of agent tool calls | -| `provider_configs` | `id`, `provider`, `model`, `is_active`, `created_at` | Non-secret provider/model selection (keys live in `config.enc`) | - -Notes: -- Provider **API keys are never stored in SQLite**; only non-secret selection - (provider + model + active flag) lives in `provider_configs`. Secrets live in the - encrypted `config.enc`. -- Messages persist as AI SDK message parts so tool calls and results render and - replay faithfully. - -## 6. Provider and agent design - -### 6.1 Provider registry (BYOK) - -v0.1.0 supports two BYOK providers through the Vercel AI SDK: - -| Provider | AI SDK adapter | Auth | -| --- | --- | --- | -| DeepSeek | `@ai-sdk/deepseek` | API key | -| OpenRouter | `@openrouter/ai-sdk-provider` | API key | - -- The user adds a key per provider, selects an active provider + model, and the - agent loop resolves the active provider at request time. -- A **Test connection** action does a minimal request (e.g. list models or a tiny - completion) and reports success/failure without persisting any generated content. -- The current server hardcodes `google("gemini-2.5-flash")`; v0.1.0 replaces this - with a provider resolved from the active `provider_configs` row + decrypted key. - -### 6.2 Agent loop - -- Implemented in `core` using the AI SDK tool-calling loop (`streamText` with - `tools`, or `ToolLoopAgent`). -- Streams text deltas and tool events to the UI; supports cancellation (stop - generation) via an abort signal threaded through the transport. -- Every tool invocation is recorded in `tool_call_logs`. - -### 6.3 Agent tools - -All tools are **workspace-scoped**: paths are resolved against the workspace root and -path traversal outside the root is rejected. - -| Tool | Description | Side effects | -| --- | --- | --- | -| `list_files` | List files/dirs under a workspace-relative path | Read-only | -| `read_file` | Read a file's contents | Read-only | -| `search_files` | Keyword/regex search across the workspace (ripgrep-style) | Read-only | -| `propose_edit` | Propose a unified-diff change to one or more files | None until approved | -| `apply_patch` | Apply an approved diff to disk | Writes files (gated by approval) | -| `run_command` | Run a shell command in the workspace root | Executes process (gated by policy) | -| `git_status` | Return branch + changed files | Read-only | - -## 7. UI layout - -``` -+----------------------------------------------------------------------+ -| Title bar | -+-------------+--------------------------------------+-----------------+ -| Left sidebar| Center: Chat | Right sidebar | -| | | (tabbed) | -| Workspace | - message stream (markdown/code) | [Git] | -| - name | - tool activity / diffs | [File preview] | -| - switch | - input + send/stop | [Terminal] | -| Conversa- | | [Browser] | -| tions list | | | -+-------------+--------------------------------------+-----------------+ -``` - -- **Left sidebar:** current workspace (name + switcher + recents), conversation list - with create/rename/delete, file explorer tree. -- **Center:** active conversation chat with streaming responses, inline tool - activity, and proposed-edit diffs with accept/reject. -- **Right sidebar (tabs):** Git panel, file preview, terminal, browser. - -## 8. Feature specifications - -Each feature lists requirements and acceptance criteria (AC). All file/command -operations are workspace-scoped. - -### 8.1 Workspace - -Requirements: -- Open a local folder as a workspace (native folder picker on desktop; on web, the - local server resolves a path the user provides/selects). -- Show current workspace name in the left sidebar. -- Remember recent workspaces and allow switching between them. -- Refresh the workspace file tree on demand. -- Remove a workspace from the list (does not delete files on disk). - -AC: -- Opening a folder registers a `workspaces` row and sets it active. -- Recent workspaces persist across app restarts, ordered by `last_opened_at`. -- Switching workspace reloads file tree and the conversation list for that workspace. -- Removing a workspace deletes its row + recents entry but never touches disk files. - -### 8.2 File Explorer - -Requirements: -- Render the workspace file tree (lazy-expand directories). -- Open a file and preview its content in the right sidebar preview tab. -- Search files by name within the workspace. -- Show the selected file's workspace-relative path. - -AC: -- Tree reflects on-disk state after refresh. -- Selecting a file opens it in the preview tab and shows its path. -- Name search returns matches scoped to the workspace root. - -### 8.3 Conversation - -Requirements: -- Create a new conversation (scoped to active workspace). -- Show the conversation list; switch between conversations. -- Rename and delete conversations. -- Persist conversation history. - -AC: -- New conversation appears immediately and persists in SQLite. -- Switching conversation loads its full message history including tool parts. -- Rename/delete update SQLite and the UI; delete removes messages + parts + logs. - -### 8.4 Chat - -Requirements: -- Send a message to the agent. -- Stream the agent response. -- Render markdown and code blocks. -- Show loading and error states. -- Stop generation in progress. - -AC: -- Responses stream incrementally (reusing the existing AI SDK `useChat` pattern). -- Markdown/code render via the existing `Streamdown` component. -- A visible loading state shows while submitting/streaming; errors surface a clear - message and allow retry. -- Stop cancels the in-flight request; partial output is preserved and persisted. - -### 8.5 Agent Provider Config - -Requirements: -- Add a DeepSeek API key. -- Add an OpenRouter API key. -- Select active provider and model. -- Test the API connection. -- Save provider config locally. - -AC: -- Keys are written to the encrypted `config.enc`, never to SQLite or logs. -- Selecting provider/model updates `provider_configs.is_active`. -- Test connection reports success/failure without persisting generated content. -- Active provider/model is used by the agent loop on the next request. - -### 8.6 Agent File Reading - -Requirements: -- Agent can list workspace files. -- Agent can read a selected file. -- Agent can read multiple relevant files in one turn. -- Agent can search files by keyword. - -AC: -- `list_files`, `read_file`, `search_files` are available as tools and logged. -- All reads are confined to the workspace root; traversal outside is rejected. -- Tool activity is visible inline in the chat. - -### 8.7 Agent Code Editing - -Requirements: -- Agent can propose file changes. -- Show a code diff before applying. -- Accept proposed changes. -- Reject proposed changes. -- Apply accepted changes to local files. - -AC: -- Proposed edits render as a unified diff in the chat (and/or preview tab). -- No file is written until the user accepts; reject discards the proposal. -- Accept applies the patch atomically to disk and reflects in the file tree and git - panel. -- Each apply/reject decision is recorded in `tool_call_logs`. - -### 8.8 Git Panel - -Requirements: -- Show the current branch. -- Show changed files. -- Show a file diff. -- Refresh git status. - -AC: -- Branch and changed-file list match `git status` for the workspace. -- Selecting a changed file shows its diff. -- Refresh re-reads git state; non-git folders show a clear empty/disabled state. - -### 8.9 Terminal - -Requirements: -- Open a terminal in the workspace root. -- Run a command. -- Show command output (streamed). -- Stop a running command. - -AC: -- A PTY session starts in the workspace root and renders via `xterm.js`. -- Output streams live; input is sent to the PTY. -- Stop terminates the running process/session cleanly. - -### 8.10 Local Storage - -Requirements: -- Save workspaces, conversations, messages, provider configs, and agent tool-call - logs. - -AC: -- All structured data persists in `~/.chestnut-code/chestnut.db` and survives - restarts. -- Secrets persist only in encrypted `config.enc`. -- Desktop (Bun) and web (server/Node) read/write the same database file via the - shared cross-runtime driver. - -## 9. Security and safety - -- **Workspace sandboxing:** all FS and command operations resolve against the active - workspace root; resolved paths outside the root are rejected. -- **Edit approval:** the agent never writes to disk without an explicit user accept; - diffs are shown before applying. -- **Command policy:** `run_command` runs in the workspace root; v0.1.0 surfaces the - command to the user and requires confirmation before execution (no silent shell - access). Long-running commands can be stopped. -- **Key storage:** provider API keys are stored only in `config.enc`, encrypted with - AES-256-GCM. Keys are decrypted in memory at request time and never logged or sent - to the client. -- **Logging:** `tool_call_logs` capture tool name, args, results, and status, but - never secrets. - -## 10. Streaming, stop, and error behavior - -- Agent and chat responses stream over SSE (server/web) or RPC stream (desktop). -- Terminal I/O streams over WebSocket (server/web) or RPC channel (desktop). -- Stop generation aborts the in-flight model request; already-streamed content is - persisted as a partial assistant message. -- Errors (provider auth failure, network, tool error) surface inline with a - retry affordance and are not silently swallowed. - -## 11. Milestones - -Phased delivery; each phase is independently demoable. - -1. **Foundation:** `@chestnut-code/core` skeleton, SQLite via `@libsql/client`, - encrypted key store, client adapter (desktop RPC + server tRPC), workspace open + - file tree. -2. **Chat + agent read:** BYOK provider config, agent loop with `list_files`, - `read_file`, `search_files`; streaming chat with markdown/code; conversation CRUD - + persistence. -3. **Edit + diff:** `propose_edit` / `apply_patch` with diff review and accept/reject. -4. **Dev panels:** Git panel, file preview, terminal (PTY + xterm.js), browser tab. -5. **Polish:** stop generation, error states, recent workspaces, tool-call logs. - -### 11.1 v0.1.0 acceptance checklist - -- [ ] Open and switch local workspaces; recents persist. -- [ ] Browse file tree, preview files, search by name. -- [ ] Create/rename/delete/switch conversations with persisted history. -- [ ] Configure DeepSeek and OpenRouter keys (encrypted) + select model + test. -- [ ] Stream agent chat with markdown/code; stop generation works. -- [ ] Agent reads and searches files within the workspace. -- [ ] Agent proposes edits shown as diffs; accept applies, reject discards. -- [ ] Git panel shows branch, changed files, and diffs. -- [ ] Terminal runs commands in workspace root with streamed output + stop. -- [ ] Browser panel renders a preview URL. -- [ ] All data persists in SQLite; keys persist only in `config.enc`. -- [ ] Web (via local server) and desktop (in-process) both fully functional. - -## 12. Out of scope / future considerations - -- Multi-agent or background autonomous runs. -- Additional providers (OpenAI, Anthropic, local models) beyond DeepSeek/OpenRouter. -- Cloud sync, team sharing, and hosted server deployment. -- Mobile (`native`) target. -- Rich browser tooling (devtools, multiple tabs) beyond a basic preview. -- MCP tool integration (the stack includes MCP scaffolding; deferred). - -## 13. Open questions - -- Browser panel: embedded webview vs simple iframe preview, and what default URL - (e.g., a detected local dev server) should it load. -- `run_command` policy granularity: per-command confirmation vs an allowlist for - v0.1.0. -- Whether to adopt Drizzle over a hand-written data-access layer on top of libsql. - - From 713999bf4b7fd0e3d633aecbf1aae5e728d7ada2 Mon Sep 17 00:00:00 2001 From: Bobby Lin Date: Fri, 24 Jul 2026 13:15:01 +0800 Subject: [PATCH 03/32] feat: create a basic layout --- apps/desktop/package.json | 1 + apps/desktop/src/bun/index.ts | 77 ++- apps/server/package.json | 1 + apps/server/src/index.ts | 6 +- apps/web/package.json | 5 + apps/web/src/components/header.tsx | 33 -- apps/web/src/components/sign-in-form.tsx | 146 ------ apps/web/src/components/sign-up-form.tsx | 171 ------- apps/web/src/components/user-menu.tsx | 62 --- .../src/components/workspace/file-preview.tsx | 120 +++++ .../src/components/workspace/file-tree.tsx | 151 ++++++ .../components/workspace/workspace-shell.tsx | 471 ++++++++++++++++++ apps/web/src/lib/adapter/electrobun.ts | 80 +++ apps/web/src/lib/adapter/index.ts | 8 + apps/web/src/lib/adapter/server.ts | 22 + apps/web/src/lib/adapter/types.ts | 36 ++ apps/web/src/routes/__root.tsx | 14 +- apps/web/src/routes/_auth/dashboard.tsx | 22 - apps/web/src/routes/_auth/route.tsx | 20 - apps/web/src/routes/ai.tsx | 234 --------- apps/web/src/routes/index.tsx | 27 +- apps/web/src/routes/login.tsx | 19 - biome.json | 2 + packages/api/package.json | 1 + packages/api/src/routers/index.ts | 75 ++- packages/core/package.json | 23 + packages/core/src/config/index.ts | 29 ++ packages/core/src/db/client.ts | 15 + packages/core/src/db/migrations.ts | 59 +++ packages/core/src/index.ts | 52 ++ packages/core/src/keystore/index.ts | 99 ++++ packages/core/src/workspace/fs.ts | 168 +++++++ packages/core/src/workspace/index.ts | 145 ++++++ packages/core/src/workspace/search.ts | 150 ++++++ packages/core/tsconfig.json | 11 + pnpm-lock.yaml | 310 +++++++++++- 36 files changed, 2104 insertions(+), 761 deletions(-) delete mode 100644 apps/web/src/components/header.tsx delete mode 100644 apps/web/src/components/sign-in-form.tsx delete mode 100644 apps/web/src/components/sign-up-form.tsx delete mode 100644 apps/web/src/components/user-menu.tsx create mode 100644 apps/web/src/components/workspace/file-preview.tsx create mode 100644 apps/web/src/components/workspace/file-tree.tsx create mode 100644 apps/web/src/components/workspace/workspace-shell.tsx create mode 100644 apps/web/src/lib/adapter/electrobun.ts create mode 100644 apps/web/src/lib/adapter/index.ts create mode 100644 apps/web/src/lib/adapter/server.ts create mode 100644 apps/web/src/lib/adapter/types.ts delete mode 100644 apps/web/src/routes/_auth/dashboard.tsx delete mode 100644 apps/web/src/routes/_auth/route.tsx delete mode 100644 apps/web/src/routes/ai.tsx delete mode 100644 apps/web/src/routes/login.tsx create mode 100644 packages/core/package.json create mode 100644 packages/core/src/config/index.ts create mode 100644 packages/core/src/db/client.ts create mode 100644 packages/core/src/db/migrations.ts create mode 100644 packages/core/src/index.ts create mode 100644 packages/core/src/keystore/index.ts create mode 100644 packages/core/src/workspace/fs.ts create mode 100644 packages/core/src/workspace/index.ts create mode 100644 packages/core/src/workspace/search.ts create mode 100644 packages/core/tsconfig.json diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 72d67fa..86ec0d7 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -12,6 +12,7 @@ "check-types": "tsc --noEmit" }, "dependencies": { + "@chestnut-code/core": "workspace:*", "electrobun": "^1.18.1" }, "devDependencies": { diff --git a/apps/desktop/src/bun/index.ts b/apps/desktop/src/bun/index.ts index 9ac9525..480515d 100644 --- a/apps/desktop/src/bun/index.ts +++ b/apps/desktop/src/bun/index.ts @@ -1,8 +1,82 @@ -import { BrowserWindow, Updater } from "electrobun/bun"; +import { + createCore, + type FileContent, + type FsNode, + type Workspace, +} from "@chestnut-code/core"; +import { + BrowserView, + BrowserWindow, + type RPCSchema, + Updater, + Utils, +} from "electrobun/bun"; const DEV_SERVER_PORT = 3021; const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`; +type DesktopRpc = { + bun: RPCSchema<{ + requests: { + filesRead: { + params: { path: string; workspaceId: string }; + response: FileContent; + }; + filesSearchByName: { + params: { query: string; workspaceId: string }; + response: FsNode[]; + }; + workspaceList: { + params: { directory?: string; workspaceId: string }; + response: FsNode[]; + }; + workspaceListRecents: { + params: Record; + response: Workspace[]; + }; + workspaceOpen: { params: { rootPath: string }; response: Workspace }; + workspacePickFolder: { + params: Record; + response: string | null; + }; + workspaceRemove: { params: { workspaceId: string }; response: undefined }; + }; + }>; + webview: RPCSchema<{ requests: Record }>; +}; + +const core = createCore(); +await core.ready; + +const rpc = BrowserView.defineRPC({ + maxRequestTime: 30_000, + handlers: { + messages: {}, + requests: { + filesRead: ({ path, workspaceId }) => + core.workspace.read(workspaceId, path), + filesSearchByName: ({ query, workspaceId }) => + core.workspace.searchByName(workspaceId, query), + workspaceList: ({ directory, workspaceId }) => + core.workspace.list(workspaceId, directory), + workspaceListRecents: () => core.workspace.listRecents(), + workspaceOpen: ({ rootPath }) => core.workspace.open(rootPath), + workspacePickFolder: async () => { + const paths = await Utils.openFileDialog({ + allowsMultipleSelection: false, + canChooseDirectory: true, + canChooseFiles: false, + }); + return paths[0] || null; + }, + workspaceRemove: async ({ workspaceId }) => { + await core.workspace.remove(workspaceId); + return undefined; + }, + }, + }, +}); + async function getMainViewUrl(): Promise { const channel = await Updater.localInfo.channel(); if (channel === "dev") { @@ -21,6 +95,7 @@ async function getMainViewUrl(): Promise { const url = await getMainViewUrl(); new BrowserWindow({ + rpc, title: "chestnut-code", url, frame: { diff --git a/apps/server/package.json b/apps/server/package.json index 5d9cbc0..2f21b12 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -14,6 +14,7 @@ "@ai-sdk/google": "^4.0.1", "@chestnut-code/api": "workspace:*", "@chestnut-code/auth": "workspace:*", + "@chestnut-code/core": "workspace:*", "@chestnut-code/env": "workspace:*", "@hono/node-server": "^1.14.4", "@hono/trpc-server": "^0.4.0", diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 99245e5..5ae0ee5 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -1,8 +1,9 @@ import { devToolsMiddleware } from "@ai-sdk/devtools"; import { google } from "@ai-sdk/google"; import { createContext } from "@chestnut-code/api/context"; -import { appRouter } from "@chestnut-code/api/routers/index"; +import { createAppRouter } from "@chestnut-code/api/routers/index"; import { auth } from "@chestnut-code/auth"; +import { createCore } from "@chestnut-code/core"; import { env } from "@chestnut-code/env/server"; import { trpcServer } from "@hono/trpc-server"; import { @@ -16,6 +17,9 @@ import { Hono } from "hono"; import { cors } from "hono/cors"; import { logger } from "hono/logger"; +const core = createCore(); +await core.ready; +const appRouter = createAppRouter(core); const app = new Hono(); app.use(logger()); diff --git a/apps/web/package.json b/apps/web/package.json index d080f22..118452f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -16,6 +16,10 @@ "@chestnut-code/env": "workspace:*", "@chestnut-code/ui": "workspace:*", "@hookform/resolvers": "^5.2.2", + "@shikijs/core": "^4.3.1", + "@shikijs/engine-javascript": "^4.3.1", + "@shikijs/langs": "^4.3.1", + "@shikijs/themes": "^4.3.1", "@tailwindcss/vite": "^4.3.1", "@tanstack/react-form": "catalog:", "@tanstack/react-query": "catalog:", @@ -26,6 +30,7 @@ "ai": "catalog:", "better-auth": "catalog:", "dotenv": "catalog:", + "electrobun": "^1.18.1", "lucide-react": "catalog:", "next-themes": "catalog:", "react": "^19.2.7", diff --git a/apps/web/src/components/header.tsx b/apps/web/src/components/header.tsx deleted file mode 100644 index b98f07e..0000000 --- a/apps/web/src/components/header.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { Link } from "@tanstack/react-router"; - -import { ModeToggle } from "./mode-toggle"; -import UserMenu from "./user-menu"; - -export default function Header() { - const links = [ - { to: "/", label: "Home" }, - { to: "/dashboard", label: "Dashboard" }, - { to: "/ai", label: "AI Chat" }, - ] as const; - - return ( -
-
- -
- - -
-
-
-
- ); -} diff --git a/apps/web/src/components/sign-in-form.tsx b/apps/web/src/components/sign-in-form.tsx deleted file mode 100644 index 6458c72..0000000 --- a/apps/web/src/components/sign-in-form.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import { Button } from "@chestnut-code/ui/components/button"; -import { Input } from "@chestnut-code/ui/components/input"; -import { Label } from "@chestnut-code/ui/components/label"; -import { useForm } from "@tanstack/react-form"; -import { useNavigate } from "@tanstack/react-router"; -import { toast } from "sonner"; -import z from "zod"; - -import { authClient } from "@/lib/auth-client"; - -import Loader from "./loader"; - -export default function SignInForm({ - onSwitchToSignUp, -}: { - onSwitchToSignUp: () => void; -}) { - const navigate = useNavigate({ - from: "/", - }); - const { isPending } = authClient.useSession(); - - const form = useForm({ - defaultValues: { - email: "", - password: "", - }, - onSubmit: async ({ value }) => { - await authClient.signIn.email( - { - email: value.email, - password: value.password, - }, - { - onSuccess: () => { - navigate({ - to: "/dashboard", - }); - toast.success("Sign in successful"); - }, - onError: (error) => { - toast.error(error.error.message || error.error.statusText); - }, - }, - ); - }, - validators: { - onSubmit: z.object({ - email: z.email("Invalid email address"), - password: z.string().min(8, "Password must be at least 8 characters"), - }), - }, - }); - - if (isPending) { - return ; - } - - return ( -
-

Welcome Back

- -
{ - e.preventDefault(); - e.stopPropagation(); - form.handleSubmit(); - }} - className="space-y-4" - > -
- - {(field) => ( -
- - field.handleChange(e.target.value)} - /> - {field.state.meta.errors.map((error) => ( -

- {error?.message} -

- ))} -
- )} -
-
- -
- - {(field) => ( -
- - field.handleChange(e.target.value)} - /> - {field.state.meta.errors.map((error) => ( -

- {error?.message} -

- ))} -
- )} -
-
- - ({ - canSubmit: state.canSubmit, - isSubmitting: state.isSubmitting, - })} - > - {({ canSubmit, isSubmitting }) => ( - - )} - -
- -
- -
-
- ); -} diff --git a/apps/web/src/components/sign-up-form.tsx b/apps/web/src/components/sign-up-form.tsx deleted file mode 100644 index a9c8b33..0000000 --- a/apps/web/src/components/sign-up-form.tsx +++ /dev/null @@ -1,171 +0,0 @@ -import { Button } from "@chestnut-code/ui/components/button"; -import { Input } from "@chestnut-code/ui/components/input"; -import { Label } from "@chestnut-code/ui/components/label"; -import { useForm } from "@tanstack/react-form"; -import { useNavigate } from "@tanstack/react-router"; -import { toast } from "sonner"; -import z from "zod"; - -import { authClient } from "@/lib/auth-client"; - -import Loader from "./loader"; - -export default function SignUpForm({ - onSwitchToSignIn, -}: { - onSwitchToSignIn: () => void; -}) { - const navigate = useNavigate({ - from: "/", - }); - const { isPending } = authClient.useSession(); - - const form = useForm({ - defaultValues: { - email: "", - password: "", - name: "", - }, - onSubmit: async ({ value }) => { - await authClient.signUp.email( - { - email: value.email, - password: value.password, - name: value.name, - }, - { - onSuccess: () => { - navigate({ - to: "/dashboard", - }); - toast.success("Sign up successful"); - }, - onError: (error) => { - toast.error(error.error.message || error.error.statusText); - }, - }, - ); - }, - validators: { - onSubmit: z.object({ - name: z.string().min(2, "Name must be at least 2 characters"), - email: z.email("Invalid email address"), - password: z.string().min(8, "Password must be at least 8 characters"), - }), - }, - }); - - if (isPending) { - return ; - } - - return ( -
-

Create Account

- -
{ - e.preventDefault(); - e.stopPropagation(); - form.handleSubmit(); - }} - className="space-y-4" - > -
- - {(field) => ( -
- - field.handleChange(e.target.value)} - /> - {field.state.meta.errors.map((error) => ( -

- {error?.message} -

- ))} -
- )} -
-
- -
- - {(field) => ( -
- - field.handleChange(e.target.value)} - /> - {field.state.meta.errors.map((error) => ( -

- {error?.message} -

- ))} -
- )} -
-
- -
- - {(field) => ( -
- - field.handleChange(e.target.value)} - /> - {field.state.meta.errors.map((error) => ( -

- {error?.message} -

- ))} -
- )} -
-
- - ({ - canSubmit: state.canSubmit, - isSubmitting: state.isSubmitting, - })} - > - {({ canSubmit, isSubmitting }) => ( - - )} - -
- -
- -
-
- ); -} diff --git a/apps/web/src/components/user-menu.tsx b/apps/web/src/components/user-menu.tsx deleted file mode 100644 index 4b26021..0000000 --- a/apps/web/src/components/user-menu.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { Button } from "@chestnut-code/ui/components/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@chestnut-code/ui/components/dropdown-menu"; -import { Skeleton } from "@chestnut-code/ui/components/skeleton"; -import { Link, useNavigate } from "@tanstack/react-router"; - -import { authClient } from "@/lib/auth-client"; - -export default function UserMenu() { - const navigate = useNavigate(); - const { data: session, isPending } = authClient.useSession(); - - if (isPending) { - return ; - } - - if (!session) { - return ( - - - - ); - } - - return ( - - }> - {session.user.name} - - - - My Account - - {session.user.email} - { - authClient.signOut({ - fetchOptions: { - onSuccess: () => { - navigate({ - to: "/", - }); - }, - }, - }); - }} - > - Sign Out - - - - - ); -} diff --git a/apps/web/src/components/workspace/file-preview.tsx b/apps/web/src/components/workspace/file-preview.tsx new file mode 100644 index 0000000..ac04927 --- /dev/null +++ b/apps/web/src/components/workspace/file-preview.tsx @@ -0,0 +1,120 @@ +import { + createBundledHighlighter, + createSingletonShorthands, +} from "@shikijs/core"; +import { createJavaScriptRegexEngine } from "@shikijs/engine-javascript"; +import { FileCode2Icon, FileWarningIcon } from "lucide-react"; +import { useEffect, useState } from "react"; +import type { FileContent } from "@/lib/adapter"; + +const bundledLanguages = { + css: () => import("@shikijs/langs/css"), + html: () => import("@shikijs/langs/html"), + javascript: () => import("@shikijs/langs/javascript"), + json: () => import("@shikijs/langs/json"), + jsx: () => import("@shikijs/langs/jsx"), + markdown: () => import("@shikijs/langs/markdown"), + tsx: () => import("@shikijs/langs/tsx"), + typescript: () => import("@shikijs/langs/typescript"), + yaml: () => import("@shikijs/langs/yaml"), +} as const; + +const createHighlighter = createBundledHighlighter({ + engine: () => createJavaScriptRegexEngine(), + langs: bundledLanguages, + themes: { + "github-light": () => import("@shikijs/themes/github-light"), + }, +}); + +const { codeToHtml } = createSingletonShorthands(createHighlighter); + +type FilePreviewProps = { + file: FileContent | null; + isLoading: boolean; +}; + +export function FilePreview({ file, isLoading }: FilePreviewProps) { + const [highlightedHtml, setHighlightedHtml] = useState(""); + + useEffect(() => { + let isCurrent = true; + setHighlightedHtml(""); + if (!file?.canPreview || !file.content) return; + + void codeToHtml(file.content, { + lang: (isBundledLanguage(file.language) + ? file.language + : "text") as keyof typeof bundledLanguages, + theme: "github-light", + }) + .then((html) => { + if (isCurrent) setHighlightedHtml(html); + }) + .catch(() => { + if (isCurrent) setHighlightedHtml(""); + }); + + return () => { + isCurrent = false; + }; + }, [file]); + + if (isLoading) { + return ; + } + + if (!file) { + return ( + + ); + } + + if (!file.canPreview) { + const description = + file.reason === "too-large" + ? "This file is larger than 1 MB and cannot be previewed." + : "This appears to be a binary file and cannot be previewed."; + return ; + } + + return ( +
+ {highlightedHtml ? ( +
+ ) : ( +
+					{file.content}
+				
+ )} +
+ ); +} + +function isBundledLanguage( + language: string, +): language is keyof typeof bundledLanguages { + return language in bundledLanguages; +} + +function PreviewState({ + description, + warning = false, +}: { + description: string; + warning?: boolean; +}) { + const Icon = warning ? FileWarningIcon : FileCode2Icon; + + return ( +
+ +

{description}

+
+ ); +} diff --git a/apps/web/src/components/workspace/file-tree.tsx b/apps/web/src/components/workspace/file-tree.tsx new file mode 100644 index 0000000..1fc0056 --- /dev/null +++ b/apps/web/src/components/workspace/file-tree.tsx @@ -0,0 +1,151 @@ +import { Button } from "@chestnut-code/ui/components/button"; +import { + ChevronDownIcon, + ChevronRightIcon, + FileIcon, + FolderIcon, +} from "lucide-react"; +import { useState } from "react"; +import type { FsNode } from "@/lib/adapter"; + +type FileTreeProps = { + entries: FsNode[]; + loadDirectory: (directory: string) => Promise; + onSelectFile: (path: string) => void; + searching: boolean; +}; + +export function FileTree({ + entries, + loadDirectory, + onSelectFile, + searching, +}: FileTreeProps) { + const [children, setChildren] = useState>({}); + const [expanded, setExpanded] = useState>(new Set()); + const [loadingDirectories, setLoadingDirectories] = useState>( + new Set(), + ); + const rootEntries = entries; + + const toggleDirectory = async (path: string) => { + if (expanded.has(path)) { + setExpanded((current) => { + const next = new Set(current); + next.delete(path); + return next; + }); + return; + } + + setExpanded((current) => new Set(current).add(path)); + if (children[path]) return; + setLoadingDirectories((current) => new Set(current).add(path)); + try { + const nextChildren = await loadDirectory(path); + setChildren((current) => ({ ...current, [path]: nextChildren })); + } finally { + setLoadingDirectories((current) => { + const next = new Set(current); + next.delete(path); + return next; + }); + } + }; + + return ( +
+ {searching ? ( +

Searching…

+ ) : rootEntries.length === 0 ? ( +

No matching files.

+ ) : ( + + )} +
+ ); +} + +type TreeEntriesProps = { + directoryEntries: Record; + entries: FsNode[]; + expanded: Set; + loadingDirectories: Set; + onSelectFile: (path: string) => void; + onToggleDirectory: (path: string) => void; +}; + +function TreeEntries({ + directoryEntries, + entries, + expanded, + loadingDirectories, + onSelectFile, + onToggleDirectory, +}: TreeEntriesProps) { + return ( +
    + {entries.map((entry) => { + const isDirectory = entry.type === "directory"; + const isExpanded = expanded.has(entry.path); + const nestedEntries = directoryEntries[entry.path]; + + return ( +
  • + + {isDirectory && isExpanded && nestedEntries ? ( +
    + +
    + ) : null} +
  • + ); + })} +
+ ); +} diff --git a/apps/web/src/components/workspace/workspace-shell.tsx b/apps/web/src/components/workspace/workspace-shell.tsx new file mode 100644 index 0000000..2fccde3 --- /dev/null +++ b/apps/web/src/components/workspace/workspace-shell.tsx @@ -0,0 +1,471 @@ +import { Button } from "@chestnut-code/ui/components/button"; +import { Input } from "@chestnut-code/ui/components/input"; +import { + BotIcon, + CircleDotDashedIcon, + Code2Icon, + FolderOpenIcon, + FolderPlusIcon, + GitBranchIcon, + Globe2Icon, + PanelRightIcon, + PlusIcon, + RefreshCwIcon, + SearchIcon, + Settings2Icon, + TerminalSquareIcon, + Trash2Icon, +} from "lucide-react"; +import { + type FormEvent, + startTransition, + useDeferredValue, + useEffect, + useMemo, + useState, +} from "react"; +import { + client, + type FileContent, + type FsNode, + type Workspace, +} from "@/lib/adapter"; + +import { FilePreview } from "./file-preview"; +import { FileTree } from "./file-tree"; + +const panelTabs = [ + { icon: GitBranchIcon, id: "git", label: "Git" }, + { icon: Code2Icon, id: "preview", label: "File Preview" }, + { icon: TerminalSquareIcon, id: "terminal", label: "Terminal" }, + { icon: Globe2Icon, id: "browser", label: "Browser" }, +] as const; + +type PanelTab = (typeof panelTabs)[number]["id"]; + +export function WorkspaceShell() { + const [workspace, setWorkspace] = useState(null); + const [recents, setRecents] = useState([]); + const [rootEntries, setRootEntries] = useState([]); + const [file, setFile] = useState(null); + const [filePath, setFilePath] = useState(""); + const [search, setSearch] = useState(""); + const [searchResults, setSearchResults] = useState(null); + const [activeTab, setActiveTab] = useState("preview"); + const [isOpening, setIsOpening] = useState(false); + const [isLoadingFile, setIsLoadingFile] = useState(false); + const [isSearching, setIsSearching] = useState(false); + const [error, setError] = useState(null); + const deferredSearch = useDeferredValue(search); + + useEffect(() => { + void client.workspace + .listRecents() + .then(setRecents) + .catch((recentsError: unknown) => setError(toMessage(recentsError))); + }, []); + + useEffect(() => { + if (!workspace) { + setSearchResults(null); + return; + } + const query = deferredSearch.trim(); + if (!query) { + setSearchResults(null); + return; + } + + let isCurrent = true; + setIsSearching(true); + void client.files + .searchByName(workspace.id, query) + .then((results) => { + if (isCurrent) setSearchResults(results); + }) + .catch((searchError: unknown) => { + if (isCurrent) setError(toMessage(searchError)); + }) + .finally(() => { + if (isCurrent) setIsSearching(false); + }); + + return () => { + isCurrent = false; + }; + }, [deferredSearch, workspace]); + + const treeEntries = useMemo( + () => searchResults ?? rootEntries, + [rootEntries, searchResults], + ); + + const selectWorkspace = async (rootPath: string) => { + setIsOpening(true); + setError(null); + try { + const selectedWorkspace = await client.workspace.open(rootPath); + const [entries, nextRecents] = await Promise.all([ + client.workspace.list(selectedWorkspace.id), + client.workspace.listRecents(), + ]); + startTransition(() => { + setWorkspace(selectedWorkspace); + setRootEntries(entries); + setRecents(nextRecents); + setFile(null); + setFilePath(""); + setSearch(""); + }); + } catch (openError) { + setError(toMessage(openError)); + } finally { + setIsOpening(false); + } + }; + + const pickWorkspace = async () => { + if (!client.workspace.pickFolder) return; + try { + const selectedPath = await client.workspace.pickFolder(); + if (selectedPath) await selectWorkspace(selectedPath); + } catch (pickerError) { + setError(toMessage(pickerError)); + } + }; + + const refreshTree = async () => { + if (!workspace) return; + try { + setRootEntries(await client.workspace.list(workspace.id)); + setSearchResults(null); + setSearch(""); + } catch (refreshError) { + setError(toMessage(refreshError)); + } + }; + + const selectFile = async (path: string) => { + if (!workspace) return; + setActiveTab("preview"); + setFilePath(path); + setIsLoadingFile(true); + try { + setFile(await client.files.read(workspace.id, path)); + } catch (readError) { + setError(toMessage(readError)); + } finally { + setIsLoadingFile(false); + } + }; + + const removeWorkspace = async (workspaceId: string) => { + try { + await client.workspace.remove(workspaceId); + const nextRecents = await client.workspace.listRecents(); + setRecents(nextRecents); + if (workspace?.id === workspaceId) { + setWorkspace(null); + setRootEntries([]); + setFile(null); + setFilePath(""); + } + } catch (removeError) { + setError(toMessage(removeError)); + } + }; + + return ( +
+
+
+ + Chestnut Code + / + + {workspace?.name ?? "No workspace"} + +
+
+ + Local mode + + +
+
+ +
+ + +
+
+
+

New conversation

+

+ Agent chat is coming next. +

+
+ +
+
+
+ +

+ Your local coding workspace +

+

+ {workspace + ? `Browse ${workspace.name} on the left and preview files on the right.` + : "Open a project folder to get started."} +

+
+
+
+ + +
+ +
+ {workspace ? workspace.rootPath : "No workspace selected"} + {error ? `Error: ${error}` : "Ready"} +
+
+ ); +} + +type WorkspaceSectionProps = { + isOpening: boolean; + onOpen: (rootPath: string) => Promise; + onPick?: () => Promise; + onRemove: (workspaceId: string) => Promise; + recents: Workspace[]; + workspace: Workspace | null; +}; + +function WorkspaceSection({ + isOpening, + onOpen, + onPick, + onRemove, + recents, + workspace, +}: WorkspaceSectionProps) { + const [rootPath, setRootPath] = useState(""); + const [showPathInput, setShowPathInput] = useState(!onPick); + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + const path = rootPath.trim(); + if (!path) return; + await onOpen(path); + setRootPath(""); + }; + + return ( +
+
+ + +
+ {workspace ? ( +
+ +
+

{workspace.name}

+

+ {workspace.rootPath} +

+
+
+ ) : null} + {showPathInput ? ( +
+ setRootPath(event.target.value)} + placeholder="/path/to/project" + className="h-8 min-w-0 text-xs" + disabled={isOpening} + /> + +
+ ) : null} + {recents.length > 0 ? ( +
+

+ Recents +

+
    + {recents.map((recent) => ( +
  • + + +
  • + ))} +
+
+ ) : null} +
+ ); +} + +function PanelPlaceholder({ tab }: { tab: Exclude }) { + const content = { + browser: "Browser preview is planned for Phase 4.", + git: "Git status and diffs are planned for Phase 4.", + terminal: "The workspace terminal is planned for Phase 4.", + }[tab]; + + return ( +
+ {content} +
+ ); +} + +function SectionTitle({ label }: { label: string }) { + return ( + + {label} + + ); +} + +function toMessage(error: unknown): string { + return error instanceof Error ? error.message : "Something went wrong."; +} diff --git a/apps/web/src/lib/adapter/electrobun.ts b/apps/web/src/lib/adapter/electrobun.ts new file mode 100644 index 0000000..2452623 --- /dev/null +++ b/apps/web/src/lib/adapter/electrobun.ts @@ -0,0 +1,80 @@ +import type { RPCSchema } from "electrobun/view"; + +import type { ChestnutClient, FileContent, FsNode, Workspace } from "./types"; + +type DesktopRpc = { + bun: RPCSchema<{ + requests: { + filesRead: { + params: { path: string; workspaceId: string }; + response: FileContent; + }; + filesSearchByName: { + params: { query: string; workspaceId: string }; + response: FsNode[]; + }; + workspaceList: { + params: { directory?: string; workspaceId: string }; + response: FsNode[]; + }; + workspaceListRecents: { + params: Record; + response: Workspace[]; + }; + workspaceOpen: { params: { rootPath: string }; response: Workspace }; + workspacePickFolder: { + params: Record; + response: string | null; + }; + workspaceRemove: { params: { workspaceId: string }; response: undefined }; + }; + }>; + webview: RPCSchema<{ requests: Record }>; +}; + +let rpcPromise: ReturnType | undefined; + +export function isElectrobun(): boolean { + return ( + typeof window !== "undefined" && + "__electrobunWebviewId" in window && + "__electrobunRpcSocketPort" in window + ); +} + +export function createElectrobunClient(): ChestnutClient { + return { + files: { + read: async (workspaceId, path) => + (await getRpc()).request.filesRead({ path, workspaceId }), + searchByName: async (workspaceId, query) => + (await getRpc()).request.filesSearchByName({ query, workspaceId }), + }, + workspace: { + list: async (workspaceId, directory) => + (await getRpc()).request.workspaceList({ directory, workspaceId }), + listRecents: async () => + (await getRpc()).request.workspaceListRecents({}), + open: async (rootPath) => + (await getRpc()).request.workspaceOpen({ rootPath }), + pickFolder: async () => (await getRpc()).request.workspacePickFolder({}), + remove: async (workspaceId) => + (await getRpc()).request.workspaceRemove({ workspaceId }), + }, + }; +} + +async function getRpc() { + rpcPromise ??= createDesktopRpc(); + return rpcPromise; +} + +async function createDesktopRpc() { + const { Electroview } = await import("electrobun/view"); + const rpc = Electroview.defineRPC({ + maxRequestTime: 30_000, + handlers: { messages: {}, requests: {} }, + }); + new Electroview({ rpc }); + return rpc; +} diff --git a/apps/web/src/lib/adapter/index.ts b/apps/web/src/lib/adapter/index.ts new file mode 100644 index 0000000..75fa586 --- /dev/null +++ b/apps/web/src/lib/adapter/index.ts @@ -0,0 +1,8 @@ +import { createElectrobunClient, isElectrobun } from "./electrobun"; +import { createServerClient } from "./server"; + +export type { ChestnutClient, FileContent, FsNode, Workspace } from "./types"; + +export const client = isElectrobun() + ? createElectrobunClient() + : createServerClient(); diff --git a/apps/web/src/lib/adapter/server.ts b/apps/web/src/lib/adapter/server.ts new file mode 100644 index 0000000..d4ceaf0 --- /dev/null +++ b/apps/web/src/lib/adapter/server.ts @@ -0,0 +1,22 @@ +import { trpcClient } from "@/utils/trpc"; + +import type { ChestnutClient } from "./types"; + +export function createServerClient(): ChestnutClient { + return { + files: { + read: (workspaceId, path) => + trpcClient.files.read.query({ path, workspaceId }), + searchByName: (workspaceId, query) => + trpcClient.files.searchByName.query({ query, workspaceId }), + }, + workspace: { + list: (workspaceId, directory) => + trpcClient.workspace.tree.query({ directory, workspaceId }), + listRecents: () => trpcClient.workspace.listRecents.query(), + open: (rootPath) => trpcClient.workspace.open.mutate({ rootPath }), + remove: (workspaceId) => + trpcClient.workspace.remove.mutate({ workspaceId }), + }, + }; +} diff --git a/apps/web/src/lib/adapter/types.ts b/apps/web/src/lib/adapter/types.ts new file mode 100644 index 0000000..2cd842c --- /dev/null +++ b/apps/web/src/lib/adapter/types.ts @@ -0,0 +1,36 @@ +export type Workspace = { + createdAt: string; + id: string; + lastOpenedAt?: string; + name: string; + rootPath: string; +}; + +export type FsNode = { + name: string; + path: string; + type: "directory" | "file"; +}; + +export type FileContent = { + canPreview: boolean; + content?: string; + language: string; + path: string; + reason?: "binary" | "too-large"; + size: number; +}; + +export interface ChestnutClient { + workspace: { + list: (workspaceId: string, directory?: string) => Promise; + listRecents: () => Promise; + open: (rootPath: string) => Promise; + pickFolder?: () => Promise; + remove: (workspaceId: string) => Promise; + }; + files: { + read: (workspaceId: string, path: string) => Promise; + searchByName: (workspaceId: string, query: string) => Promise; + }; +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 5fc6e4b..464f985 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1,14 +1,11 @@ import { Toaster } from "@chestnut-code/ui/components/sonner"; import type { QueryClient } from "@tanstack/react-query"; -import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { createRootRouteWithContext, HeadContent, Outlet, } from "@tanstack/react-router"; -import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; -import Header from "@/components/header"; import { ThemeProvider } from "@/components/theme-provider"; import type { trpc } from "@/utils/trpc"; @@ -46,18 +43,13 @@ function RootComponent() { -
-
- -
- + +
- - ); } diff --git a/apps/web/src/routes/_auth/dashboard.tsx b/apps/web/src/routes/_auth/dashboard.tsx deleted file mode 100644 index 59237fe..0000000 --- a/apps/web/src/routes/_auth/dashboard.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { createFileRoute } from "@tanstack/react-router"; - -import { trpc } from "@/utils/trpc"; - -export const Route = createFileRoute("/_auth/dashboard")({ - component: RouteComponent, -}); - -function RouteComponent() { - const { session } = Route.useRouteContext(); - - const privateData = useQuery(trpc.privateData.queryOptions()); - - return ( -
-

Dashboard

-

Welcome {session.data?.user.name}

-

API: {privateData.data?.message}

-
- ); -} diff --git a/apps/web/src/routes/_auth/route.tsx b/apps/web/src/routes/_auth/route.tsx deleted file mode 100644 index a372d43..0000000 --- a/apps/web/src/routes/_auth/route.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { createFileRoute, Outlet, redirect } from "@tanstack/react-router"; - -import { authClient } from "@/lib/auth-client"; - -export const Route = createFileRoute("/_auth")({ - component: AuthLayout, - beforeLoad: async () => { - const session = await authClient.getSession(); - if (!session.data) { - throw redirect({ - to: "/login", - }); - } - return { session }; - }, -}); - -function AuthLayout() { - return ; -} diff --git a/apps/web/src/routes/ai.tsx b/apps/web/src/routes/ai.tsx deleted file mode 100644 index b075079..0000000 --- a/apps/web/src/routes/ai.tsx +++ /dev/null @@ -1,234 +0,0 @@ -import { useChat } from "@ai-sdk/react"; -import { env } from "@chestnut-code/env/web"; -import { Bubble, BubbleContent } from "@chestnut-code/ui/components/bubble"; -import { Button } from "@chestnut-code/ui/components/button"; -import { - Empty, - EmptyDescription, - EmptyHeader, - EmptyMedia, - EmptyTitle, -} from "@chestnut-code/ui/components/empty"; -import { - InputGroup, - InputGroupAddon, - InputGroupButton, - InputGroupTextarea, -} from "@chestnut-code/ui/components/input-group"; -import { - Message, - MessageContent as MessageBody, - MessageHeader, -} from "@chestnut-code/ui/components/message"; -import { - MessageScroller, - MessageScrollerButton, - MessageScrollerContent, - MessageScrollerItem, - MessageScrollerProvider, - MessageScrollerViewport, -} from "@chestnut-code/ui/components/message-scroller"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@chestnut-code/ui/components/tooltip"; -import { createFileRoute } from "@tanstack/react-router"; -import { DefaultChatTransport } from "ai"; -import { - ArrowUpIcon, - Loader2, - MessageCircleDashedIcon, - RotateCwIcon, -} from "lucide-react"; -import { type FormEvent, type KeyboardEvent, useState } from "react"; -import { Streamdown } from "streamdown"; - -export const Route = createFileRoute("/ai")({ - component: RouteComponent, -}); - -function RouteComponent() { - const [input, setInput] = useState(""); - const { messages, sendMessage, status, setMessages } = useChat({ - transport: new DefaultChatTransport({ - api: `${env.VITE_SERVER_URL}/ai`, - }), - }); - const isSending = status === "submitted" || status === "streaming"; - - const handleSubmit = (e: FormEvent) => { - e.preventDefault(); - const text = input.trim(); - if (!text || isSending) return; - sendMessage({ text }); - setInput(""); - }; - - const handlePromptKeyDown = (e: KeyboardEvent) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - e.currentTarget.form?.requestSubmit(); - } - }; - - const resetConversation = () => { - setInput(""); - setMessages([]); - }; - - return ( - -
-
-
-
-

New Chat

-

- How can I help you today? -

-
-
- - - } - > - - - Reset - -
-
-
-
- {messages.length === 0 && !isSending ? ( - - - - - - Morning, chestnut-code! - - What are we working on today? - - - - ) : ( - - - - {messages.map((message) => { - const isUser = message.role === "user"; - - return ( - - - - - {isUser ? "You" : "AI Assistant"} - - - - {message.parts?.map((part, index) => { - if (part.type === "text") { - return ( - - {part.text} - - ); - } - return null; - })} - - - - - - ); - })} - {status === "submitted" && ( - - - - - - - Thinking... - - - - - - )} - - - - - - )} -
-
-
-
- - setInput(e.target.value)} - onKeyDown={handlePromptKeyDown} - placeholder="Type your message..." - className="max-h-32 min-h-14" - rows={1} - autoComplete="off" - autoFocus - disabled={isSending} - /> - - - {isSending ? ( - - ) : ( - - )} - Send - - - -
-
-
-
-
- ); -} diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index 4b3a0e2..80b51e4 100644 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -1,34 +1,11 @@ -import { useQuery } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; -import { trpc } from "@/utils/trpc"; +import { WorkspaceShell } from "@/components/workspace/workspace-shell"; export const Route = createFileRoute("/")({ component: HomeComponent, }); function HomeComponent() { - const healthCheck = useQuery(trpc.healthCheck.queryOptions()); - - return ( -
-
-
-

API Status

-
-
- - {healthCheck.isLoading - ? "Checking..." - : healthCheck.data - ? "Connected" - : "Disconnected"} - -
-
-
-
- ); + return ; } diff --git a/apps/web/src/routes/login.tsx b/apps/web/src/routes/login.tsx deleted file mode 100644 index ff6a9b5..0000000 --- a/apps/web/src/routes/login.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { useState } from "react"; - -import SignInForm from "@/components/sign-in-form"; -import SignUpForm from "@/components/sign-up-form"; - -export const Route = createFileRoute("/login")({ - component: RouteComponent, -}); - -function RouteComponent() { - const [showSignIn, setShowSignIn] = useState(false); - - return showSignIn ? ( - setShowSignIn(false)} /> - ) : ( - setShowSignIn(true)} /> - ); -} diff --git a/biome.json b/biome.json index d753966..49ff493 100644 --- a/biome.json +++ b/biome.json @@ -11,6 +11,7 @@ "**", "!**/.next", "!**/dist", + "!apps/desktop/build", "!**/.turbo", "!**/.nx", "!**/dev-dist", @@ -27,6 +28,7 @@ "!**/wrangler.jsonc", "!**/.source", "!**/convex/_generated", + "!skills-lock.json", "!.agents", "!.cursor" ] diff --git a/packages/api/package.json b/packages/api/package.json index fb9f127..889b890 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -12,6 +12,7 @@ "scripts": {}, "dependencies": { "@chestnut-code/auth": "workspace:*", + "@chestnut-code/core": "workspace:*", "@chestnut-code/env": "workspace:*", "@trpc/client": "catalog:", "@trpc/server": "catalog:", diff --git a/packages/api/src/routers/index.ts b/packages/api/src/routers/index.ts index 757365b..d36003c 100644 --- a/packages/api/src/routers/index.ts +++ b/packages/api/src/routers/index.ts @@ -1,14 +1,65 @@ +import type { Core } from "@chestnut-code/core"; +import { z } from "zod"; + import { protectedProcedure, publicProcedure, router } from "../index"; -export const appRouter = router({ - healthCheck: publicProcedure.query(() => { - return "OK"; - }), - privateData: protectedProcedure.query(({ ctx }) => { - return { - message: "This is private", - user: ctx.session.user, - }; - }), -}); -export type AppRouter = typeof appRouter; +export function createAppRouter(core: Core) { + return router({ + healthCheck: publicProcedure.query(() => { + return "OK"; + }), + privateData: protectedProcedure.query(({ ctx }) => { + return { + message: "This is private", + user: ctx.session.user, + }; + }), + workspace: router({ + open: publicProcedure + .input(z.object({ rootPath: z.string().min(1) })) + .mutation(({ input }) => core.workspace.open(input.rootPath)), + listRecents: publicProcedure.query(() => core.workspace.listRecents()), + remove: publicProcedure + .input(z.object({ workspaceId: z.string().uuid() })) + .mutation(({ input }) => core.workspace.remove(input.workspaceId)), + tree: publicProcedure + .input( + z.object({ + directory: z.string().optional(), + workspaceId: z.string().uuid(), + }), + ) + .query(({ input }) => + core.workspace.list(input.workspaceId, input.directory), + ), + }), + files: router({ + read: publicProcedure + .input( + z.object({ path: z.string().min(1), workspaceId: z.string().uuid() }), + ) + .query(({ input }) => + core.workspace.read(input.workspaceId, input.path), + ), + searchByName: publicProcedure + .input(z.object({ query: z.string(), workspaceId: z.string().uuid() })) + .query(({ input }) => + core.workspace.searchByName(input.workspaceId, input.query), + ), + searchContent: publicProcedure + .input(z.object({ query: z.string(), workspaceId: z.string().uuid() })) + .query(({ input }) => + core.workspace.searchContent(input.workspaceId, input.query), + ), + stat: publicProcedure + .input( + z.object({ path: z.string().min(1), workspaceId: z.string().uuid() }), + ) + .query(({ input }) => + core.workspace.stat(input.workspaceId, input.path), + ), + }), + }); +} + +export type AppRouter = ReturnType; diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000..c67a881 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,23 @@ +{ + "name": "@chestnut-code/core", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": { + "default": "./src/index.ts" + } + }, + "scripts": { + "check-types": "tsc --noEmit" + }, + "devDependencies": { + "@chestnut-code/config": "workspace:*", + "@types/node": "^22.13.14", + "typescript": "catalog:" + }, + "dependencies": { + "@libsql/client": "^0.17.4", + "simple-git": "^3.36.0" + } +} diff --git a/packages/core/src/config/index.ts b/packages/core/src/config/index.ts new file mode 100644 index 0000000..5e8dda5 --- /dev/null +++ b/packages/core/src/config/index.ts @@ -0,0 +1,29 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; + +export type CorePaths = { + dataDir: string; + databasePath: string; + keyPath: string; + keyFilePath: string; +}; + +export type CoreConfig = { + dataDir?: string; + databaseUrl?: string; +}; + +export function resolveCorePaths(config: CoreConfig = {}): CorePaths { + const dataDir = config.dataDir ?? join(homedir(), ".chestnut-code"); + + return { + dataDir, + databasePath: config.databaseUrl ?? join(dataDir, "chestnut.db"), + keyPath: join(dataDir, "config.enc"), + keyFilePath: join(dataDir, "master.key"), + }; +} + +export function getRuntime(): "bun" | "node" { + return "Bun" in globalThis ? "bun" : "node"; +} diff --git a/packages/core/src/db/client.ts b/packages/core/src/db/client.ts new file mode 100644 index 0000000..b736ae2 --- /dev/null +++ b/packages/core/src/db/client.ts @@ -0,0 +1,15 @@ +import { mkdir } from "node:fs/promises"; +import { dirname } from "node:path"; +import { type Client, createClient } from "@libsql/client"; + +import type { CorePaths } from "../config/index.js"; + +export async function createDatabase(paths: CorePaths): Promise { + await mkdir(dirname(paths.databasePath), { recursive: true, mode: 0o700 }); + + return createClient({ + url: paths.databasePath.startsWith("file:") + ? paths.databasePath + : `file:${paths.databasePath}`, + }); +} diff --git a/packages/core/src/db/migrations.ts b/packages/core/src/db/migrations.ts new file mode 100644 index 0000000..fe9aa49 --- /dev/null +++ b/packages/core/src/db/migrations.ts @@ -0,0 +1,59 @@ +import type { Client } from "@libsql/client"; + +export async function migrate(client: Client): Promise { + await client.executeMultiple(` + PRAGMA foreign_keys = ON; + + CREATE TABLE IF NOT EXISTS workspaces ( + id TEXT PRIMARY KEY NOT NULL, + name TEXT NOT NULL, + root_path TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS recent_workspaces ( + workspace_id TEXT PRIMARY KEY NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + last_opened_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS app_settings ( + key TEXT PRIMARY KEY NOT NULL, + value TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS conversations ( + id TEXT PRIMARY KEY NOT NULL, + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + title TEXT NOT NULL, + mastra_thread_id TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS tool_call_logs ( + id TEXT PRIMARY KEY NOT NULL, + conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, + tool_name TEXT NOT NULL, + args_json TEXT NOT NULL, + result_json TEXT NOT NULL, + status TEXT NOT NULL, + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS provider_configs ( + id TEXT PRIMARY KEY NOT NULL, + provider TEXT NOT NULL, + model TEXT NOT NULL, + is_active INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS edit_proposals ( + id TEXT PRIMARY KEY NOT NULL, + conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, + diff TEXT NOT NULL, + status TEXT NOT NULL, + created_at TEXT NOT NULL + ); + `); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 0000000..0232a77 --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,52 @@ +import type { Client } from "@libsql/client"; + +import { + type CoreConfig, + getRuntime, + resolveCorePaths, +} from "./config/index.js"; +import { createDatabase } from "./db/client.js"; +import { migrate } from "./db/migrations.js"; +import { KeyStore } from "./keystore/index.js"; +import { WorkspaceService } from "./workspace/index.js"; + +export type { CoreConfig } from "./config/index.js"; +export type { + ContentSearchMatch, + FileContent, + FsNode, +} from "./workspace/fs.js"; +export type { Workspace } from "./workspace/index.js"; + +export type Core = { + database: Client; + keystore: KeyStore; + ready: Promise; + runtime: "bun" | "node"; + workspace: WorkspaceService; +}; + +export function createCore(config: CoreConfig = {}): Core { + const paths = resolveCorePaths(config); + const databasePromise = createDatabase(paths); + const ready = databasePromise.then(migrate); + const database = new Proxy({} as Client, { + get(_target, property) { + return async (...args: unknown[]) => { + const client = await databasePromise; + await ready; + const method = Reflect.get(client, property); + if (typeof method !== "function") return method; + return Reflect.apply(method, client, args); + }; + }, + }); + + return { + database, + keystore: new KeyStore(paths), + ready, + runtime: getRuntime(), + workspace: new WorkspaceService(database), + }; +} diff --git a/packages/core/src/keystore/index.ts b/packages/core/src/keystore/index.ts new file mode 100644 index 0000000..249dab6 --- /dev/null +++ b/packages/core/src/keystore/index.ts @@ -0,0 +1,99 @@ +import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; +import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; + +import type { CorePaths } from "../config/index.js"; + +type EncryptedPayload = { + ciphertext: string; + iv: string; + tag: string; + version: 1; +}; + +export class KeyStore { + readonly #paths: CorePaths; + + constructor(paths: CorePaths) { + this.#paths = paths; + } + + async get(): Promise { + try { + const [key, payloadText] = await Promise.all([ + this.#getMasterKey(), + readFile(this.#paths.keyPath, "utf8"), + ]); + const payload = JSON.parse(payloadText) as EncryptedPayload; + const decipher = createDecipheriv( + "aes-256-gcm", + key, + Buffer.from(payload.iv, "base64"), + ); + decipher.setAuthTag(Buffer.from(payload.tag, "base64")); + const plaintext = Buffer.concat([ + decipher.update(Buffer.from(payload.ciphertext, "base64")), + decipher.final(), + ]); + + return JSON.parse(plaintext.toString("utf8")) as T; + } catch (error) { + if (isMissingFileError(error)) return null; + throw new Error("Could not decrypt the local Chestnut configuration.", { + cause: error, + }); + } + } + + async set(value: unknown): Promise { + const key = await this.#getMasterKey(); + const iv = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", key, iv); + const ciphertext = Buffer.concat([ + cipher.update(JSON.stringify(value), "utf8"), + cipher.final(), + ]); + const payload: EncryptedPayload = { + ciphertext: ciphertext.toString("base64"), + iv: iv.toString("base64"), + tag: cipher.getAuthTag().toString("base64"), + version: 1, + }; + + await writeFile(this.#paths.keyPath, JSON.stringify(payload), { + encoding: "utf8", + mode: 0o600, + }); + await chmod(this.#paths.keyPath, 0o600); + } + + async #getMasterKey(): Promise { + await mkdir(dirname(this.#paths.keyFilePath), { + recursive: true, + mode: 0o700, + }); + + try { + const key = await readFile(this.#paths.keyFilePath); + if (key.length !== 32) { + throw new Error("The local Chestnut master key is invalid."); + } + return key; + } catch (error) { + if (!isMissingFileError(error)) throw error; + const key = randomBytes(32); + await writeFile(this.#paths.keyFilePath, key, { mode: 0o600 }); + await chmod(this.#paths.keyFilePath, 0o600); + return key; + } + } +} + +function isMissingFileError(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + error.code === "ENOENT" + ); +} diff --git a/packages/core/src/workspace/fs.ts b/packages/core/src/workspace/fs.ts new file mode 100644 index 0000000..12c4ce8 --- /dev/null +++ b/packages/core/src/workspace/fs.ts @@ -0,0 +1,168 @@ +import { readdir, readFile, realpath, stat } from "node:fs/promises"; +import { basename, extname, relative, resolve, sep } from "node:path"; + +export const MAX_PREVIEW_BYTES = 1_000_000; + +export type FsNode = { + name: string; + path: string; + type: "directory" | "file"; +}; + +export type FileContent = { + canPreview: boolean; + content?: string; + language: string; + path: string; + reason?: "binary" | "too-large"; + size: number; +}; + +export type ContentSearchMatch = { + line: number; + path: string; + preview: string; +}; + +export async function listDirectory( + workspaceRoot: string, + directory = ".", +): Promise { + const { absolutePath, relativePath } = await resolveWorkspacePath( + workspaceRoot, + directory, + ); + const entries = await readdir(absolutePath, { withFileTypes: true }); + + return entries + .filter((entry) => entry.isDirectory() || entry.isFile()) + .map((entry) => ({ + name: entry.name, + path: relativePath === "." ? entry.name : `${relativePath}/${entry.name}`, + type: entry.isDirectory() ? ("directory" as const) : ("file" as const), + })) + .toSorted((left, right) => { + if (left.type !== right.type) return left.type === "directory" ? -1 : 1; + return left.name.localeCompare(right.name, undefined, { numeric: true }); + }); +} + +export async function readWorkspaceFile( + workspaceRoot: string, + filePath: string, +): Promise { + const { absolutePath, relativePath } = await resolveWorkspacePath( + workspaceRoot, + filePath, + ); + const fileStat = await stat(absolutePath); + if (!fileStat.isFile()) { + throw new Error(`"${relativePath}" is not a file.`); + } + + const language = getLanguage(relativePath); + if (fileStat.size > MAX_PREVIEW_BYTES) { + return { + canPreview: false, + language, + path: relativePath, + reason: "too-large", + size: fileStat.size, + }; + } + + const contents = await readFile(absolutePath); + if (contents.includes(0)) { + return { + canPreview: false, + language, + path: relativePath, + reason: "binary", + size: fileStat.size, + }; + } + + return { + canPreview: true, + content: new TextDecoder("utf-8", { fatal: false }).decode(contents), + language, + path: relativePath, + size: fileStat.size, + }; +} + +export async function statWorkspacePath( + workspaceRoot: string, + path: string, +): Promise<{ path: string; type: "directory" | "file"; size: number }> { + const { absolutePath, relativePath } = await resolveWorkspacePath( + workspaceRoot, + path, + ); + const entry = await stat(absolutePath); + + return { + path: relativePath, + size: entry.size, + type: entry.isDirectory() ? "directory" : "file", + }; +} + +export async function resolveWorkspacePath( + workspaceRoot: string, + workspacePath = ".", +): Promise<{ absolutePath: string; relativePath: string }> { + if (!workspacePath || workspacePath === ".") { + return { absolutePath: workspaceRoot, relativePath: "." }; + } + if (workspacePath.startsWith("/") || workspacePath.startsWith("\\")) { + throw new Error("Workspace paths must be relative."); + } + + const lexicalPath = resolve(workspaceRoot, workspacePath); + assertPathWithinRoot(workspaceRoot, lexicalPath); + + const [rootRealPath, targetRealPath] = await Promise.all([ + realpath(workspaceRoot), + realpath(lexicalPath), + ]); + assertPathWithinRoot(rootRealPath, targetRealPath); + + return { + absolutePath: targetRealPath, + relativePath: relative(rootRealPath, targetRealPath) || ".", + }; +} + +export function assertPathWithinRoot(root: string, target: string): void { + const pathRelative = relative(root, target); + if ( + pathRelative === ".." || + pathRelative.startsWith(`..${sep}`) || + pathRelative.startsWith("../") || + pathRelative.startsWith("..\\") || + (pathRelative === "" && root !== target) + ) { + throw new Error("Path resolves outside the active workspace."); + } +} + +export function getLanguage(filePath: string): string { + const extension = extname(basename(filePath)).slice(1).toLowerCase(); + const languages: Record = { + cjs: "javascript", + css: "css", + html: "html", + js: "javascript", + json: "json", + jsx: "jsx", + md: "markdown", + mjs: "javascript", + ts: "typescript", + tsx: "tsx", + yaml: "yaml", + yml: "yaml", + }; + + return languages[extension] ?? "text"; +} diff --git a/packages/core/src/workspace/index.ts b/packages/core/src/workspace/index.ts new file mode 100644 index 0000000..e877e27 --- /dev/null +++ b/packages/core/src/workspace/index.ts @@ -0,0 +1,145 @@ +import { randomUUID } from "node:crypto"; +import { realpath, stat } from "node:fs/promises"; +import { basename } from "node:path"; +import type { Client, InValue } from "@libsql/client"; + +import type { ContentSearchMatch, FileContent, FsNode } from "./fs.js"; +import { listDirectory, readWorkspaceFile, statWorkspacePath } from "./fs.js"; +import { searchFileContents, searchFilesByName } from "./search.js"; + +export type Workspace = { + createdAt: string; + id: string; + name: string; + rootPath: string; + lastOpenedAt?: string; +}; + +export class WorkspaceService { + readonly #database: Client; + + constructor(database: Client) { + this.#database = database; + } + + async open(rootPath: string): Promise { + const canonicalRoot = await realpath(rootPath); + const rootStat = await stat(canonicalRoot); + if (!rootStat.isDirectory()) { + throw new Error("A workspace must be a directory."); + } + + const now = new Date().toISOString(); + const existing = await this.#getByRootPath(canonicalRoot); + const workspace = existing ?? { + createdAt: now, + id: randomUUID(), + name: basename(canonicalRoot), + rootPath: canonicalRoot, + }; + + if (!existing) { + await this.#database.execute({ + sql: "INSERT INTO workspaces (id, name, root_path, created_at) VALUES (?, ?, ?, ?)", + args: [ + workspace.id, + workspace.name, + workspace.rootPath, + workspace.createdAt, + ], + }); + } + + await this.#database.batch( + [ + { + sql: "INSERT INTO recent_workspaces (workspace_id, last_opened_at) VALUES (?, ?) ON CONFLICT(workspace_id) DO UPDATE SET last_opened_at = excluded.last_opened_at", + args: [workspace.id, now], + }, + { + sql: "INSERT INTO app_settings (key, value) VALUES ('active_workspace_id', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value", + args: [workspace.id], + }, + ], + "write", + ); + + return { ...workspace, lastOpenedAt: now }; + } + + async listRecents(): Promise { + const result = await this.#database.execute( + "SELECT w.id, w.name, w.root_path, w.created_at, r.last_opened_at FROM recent_workspaces r INNER JOIN workspaces w ON w.id = r.workspace_id ORDER BY r.last_opened_at DESC", + ); + + return result.rows.map((row) => workspaceFromRow(row)); + } + + async remove(workspaceId: string): Promise { + await this.#database.batch( + [ + { + sql: "DELETE FROM app_settings WHERE key = 'active_workspace_id' AND value = ?", + args: [workspaceId], + }, + { sql: "DELETE FROM workspaces WHERE id = ?", args: [workspaceId] }, + ], + "write", + ); + } + + async list(workspaceId: string, directory?: string): Promise { + return listDirectory((await this.get(workspaceId)).rootPath, directory); + } + + async read(workspaceId: string, path: string): Promise { + return readWorkspaceFile((await this.get(workspaceId)).rootPath, path); + } + + async stat( + workspaceId: string, + path: string, + ): Promise<{ path: string; type: "directory" | "file"; size: number }> { + return statWorkspacePath((await this.get(workspaceId)).rootPath, path); + } + + async searchByName(workspaceId: string, query: string): Promise { + return searchFilesByName((await this.get(workspaceId)).rootPath, query); + } + + async searchContent( + workspaceId: string, + query: string, + ): Promise { + return searchFileContents((await this.get(workspaceId)).rootPath, query); + } + + async get(workspaceId: string): Promise { + const result = await this.#database.execute({ + sql: "SELECT id, name, root_path, created_at FROM workspaces WHERE id = ?", + args: [workspaceId], + }); + const row = result.rows[0]; + if (!row) throw new Error("Workspace not found."); + return workspaceFromRow(row); + } + + async #getByRootPath(rootPath: string): Promise { + const result = await this.#database.execute({ + sql: "SELECT id, name, root_path, created_at FROM workspaces WHERE root_path = ?", + args: [rootPath], + }); + const row = result.rows[0]; + return row ? workspaceFromRow(row) : null; + } +} + +function workspaceFromRow(row: Record): Workspace { + return { + createdAt: String(row.created_at), + id: String(row.id), + lastOpenedAt: row.last_opened_at ? String(row.last_opened_at) : undefined, + name: String(row.name), + rootPath: String(row.root_path), + }; +} diff --git a/packages/core/src/workspace/search.ts b/packages/core/src/workspace/search.ts new file mode 100644 index 0000000..35ee0d5 --- /dev/null +++ b/packages/core/src/workspace/search.ts @@ -0,0 +1,150 @@ +import { execFile } from "node:child_process"; +import { readFile } from "node:fs/promises"; +import { promisify } from "node:util"; + +import type { ContentSearchMatch, FsNode } from "./fs.js"; +import { listDirectory, resolveWorkspacePath } from "./fs.js"; + +const MAX_SEARCH_RESULTS = 100; +const execFileAsync = promisify(execFile); + +export async function searchFilesByName( + workspaceRoot: string, + query: string, +): Promise { + const normalizedQuery = query.trim().toLocaleLowerCase(); + if (!normalizedQuery) return []; + + const results: FsNode[] = []; + await visitDirectory(workspaceRoot, ".", async (entry) => { + if (entry.name.toLocaleLowerCase().includes(normalizedQuery)) { + results.push(entry); + } + return results.length < MAX_SEARCH_RESULTS; + }); + + return results; +} + +export async function searchFileContents( + workspaceRoot: string, + query: string, +): Promise { + const normalizedQuery = query.trim().toLocaleLowerCase(); + if (!normalizedQuery) return []; + const ripgrepResults = await searchWithRipgrep(workspaceRoot, query.trim()); + if (ripgrepResults) return ripgrepResults; + + const results: ContentSearchMatch[] = []; + await visitDirectory(workspaceRoot, ".", async (entry) => { + if (entry.type !== "file") return true; + const { absolutePath } = await resolveWorkspacePath( + workspaceRoot, + entry.path, + ); + const contents = await readFile(absolutePath); + if (contents.includes(0) || contents.length > 1_000_000) return true; + + const lines = new TextDecoder().decode(contents).split("\n"); + for (const [index, line] of lines.entries()) { + if (line.toLocaleLowerCase().includes(normalizedQuery)) { + results.push({ + path: entry.path, + line: index + 1, + preview: line.trim(), + }); + if (results.length >= MAX_SEARCH_RESULTS) return false; + } + } + return true; + }); + + return results; +} + +async function searchWithRipgrep( + workspaceRoot: string, + query: string, +): Promise { + try { + const { stdout } = await execFileAsync( + "rg", + [ + "--json", + "--fixed-strings", + "--ignore-case", + "--hidden", + "--glob", + "!.git", + "--max-count", + String(MAX_SEARCH_RESULTS), + query, + ".", + ], + { cwd: workspaceRoot, maxBuffer: 10_000_000 }, + ); + + return stdout + .split("\n") + .flatMap((line) => parseRipgrepMatch(line)) + .slice(0, MAX_SEARCH_RESULTS); + } catch (error) { + if (isRipgrepNoMatches(error)) return []; + return null; + } +} + +function parseRipgrepMatch(line: string): ContentSearchMatch[] { + if (!line) return []; + try { + const result = JSON.parse(line) as { + data?: { + line_number?: number; + lines?: { text?: string }; + path?: { text?: string }; + }; + type?: string; + }; + if ( + result.type !== "match" || + !result.data?.path?.text || + !result.data.line_number + ) { + return []; + } + return [ + { + line: result.data.line_number, + path: result.data.path.text.replace(/^\.\//, ""), + preview: result.data.lines?.text?.trim() ?? "", + }, + ]; + } catch { + return []; + } +} + +function isRipgrepNoMatches(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + error.code === 1 + ); +} + +async function visitDirectory( + workspaceRoot: string, + directory: string, + visit: (entry: FsNode) => Promise, +): Promise { + const entries = await listDirectory(workspaceRoot, directory); + for (const entry of entries) { + if (!(await visit(entry))) return false; + if (entry.type === "directory") { + if (!(await visitDirectory(workspaceRoot, entry.path, visit))) + return false; + } + } + return true; +} diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 0000000..c659559 --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@chestnut-code/config/tsconfig.base.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "composite": true + }, + "include": ["src/**/*.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4c7a40f..ecfccb1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -96,6 +96,9 @@ importers: apps/desktop: dependencies: + '@chestnut-code/core': + specifier: workspace:* + version: link:../../packages/core electrobun: specifier: ^1.18.1 version: 1.18.1 @@ -369,6 +372,9 @@ importers: '@chestnut-code/auth': specifier: workspace:* version: link:../../packages/auth + '@chestnut-code/core': + specifier: workspace:* + version: link:../../packages/core '@chestnut-code/env': specifier: workspace:* version: link:../../packages/env @@ -430,6 +436,18 @@ importers: '@hookform/resolvers': specifier: ^5.2.2 version: 5.4.0(react-hook-form@7.80.0(react@19.2.7)) + '@shikijs/core': + specifier: ^4.3.1 + version: 4.3.1 + '@shikijs/engine-javascript': + specifier: ^4.3.1 + version: 4.3.1 + '@shikijs/langs': + specifier: ^4.3.1 + version: 4.3.1 + '@shikijs/themes': + specifier: ^4.3.1 + version: 4.3.1 '@tailwindcss/vite': specifier: ^4.3.1 version: 4.3.1(vite@8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) @@ -460,6 +478,9 @@ importers: dotenv: specifier: 'catalog:' version: 17.4.2 + electrobun: + specifier: ^1.18.1 + version: 1.18.1 lucide-react: specifier: 'catalog:' version: 1.22.0(react@19.2.7) @@ -524,6 +545,9 @@ importers: '@chestnut-code/auth': specifier: workspace:* version: link:../auth + '@chestnut-code/core': + specifier: workspace:* + version: link:../core '@chestnut-code/env': specifier: workspace:* version: link:../env @@ -577,6 +601,25 @@ importers: packages/config: {} + packages/core: + dependencies: + '@libsql/client': + specifier: ^0.17.4 + version: 0.17.4 + simple-git: + specifier: ^3.36.0 + version: 3.36.0 + devDependencies: + '@chestnut-code/config': + specifier: workspace:* + version: link:../config + '@types/node': + specifier: ^22.13.14 + version: 22.20.0 + typescript: + specifier: 'catalog:' + version: 6.0.3 + packages/env: dependencies: '@t3-oss/env-core': @@ -1988,6 +2031,69 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@kwsites/file-exists@1.1.1': + resolution: {integrity: sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==} + + '@kwsites/promise-deferred@1.1.1': + resolution: {integrity: sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==} + + '@libsql/client@0.17.4': + resolution: {integrity: sha512-lYayFWasDV78A+TjlEhr6ubb3odBV6OHjb+wdp8VQcyWWAEIjuwbCHaraEUS4m4yWoo0BvZo96It4VdzZRmRWw==} + + '@libsql/core@0.17.4': + resolution: {integrity: sha512-LqF9gIvnJ38nmAH1y/ChizHqDO/MO1wLgA96XrraulEEbqXxLjleSH92YWTolbuJKgPUmGu4aJk9W3UnAcxLOQ==} + + '@libsql/darwin-arm64@0.5.29': + resolution: {integrity: sha512-K+2RIB1OGFPYQbfay48GakLhqf3ArcbHqPFu7EZiaUcRgFcdw8RoltsMyvbj5ix2fY0HV3Q3Ioa/ByvQdaSM0A==} + cpu: [arm64] + os: [darwin] + + '@libsql/darwin-x64@0.5.29': + resolution: {integrity: sha512-OtT+KFHsKFy1R5FVadr8FJ2Bb1mghtXTyJkxv0trocq7NuHntSki1eUbxpO5ezJesDvBlqFjnWaYYY516QNLhQ==} + cpu: [x64] + os: [darwin] + + '@libsql/hrana-client@0.10.0': + resolution: {integrity: sha512-OoA4EMqRAC7kn7V2P6EQqRcpZf2W+AjsNIyCizBg339Tq/aMC7sRnzs3SklderhmQWAqEzvv8A2vhxVmWpkVvw==} + + '@libsql/isomorphic-ws@0.1.5': + resolution: {integrity: sha512-DtLWIH29onUYR00i0GlQ3UdcTRC6EP4u9w/h9LxpUZJWRMARk6dQwZ6Jkd+QdwVpuAOrdxt18v0K2uIYR3fwFg==} + + '@libsql/linux-arm-gnueabihf@0.5.29': + resolution: {integrity: sha512-CD4n4zj7SJTHso4nf5cuMoWoMSS7asn5hHygsDuhRl8jjjCTT3yE+xdUvI4J7zsyb53VO5ISh4cwwOtf6k2UhQ==} + cpu: [arm] + os: [linux] + + '@libsql/linux-arm-musleabihf@0.5.29': + resolution: {integrity: sha512-2Z9qBVpEJV7OeflzIR3+l5yAd4uTOLxklScYTwpZnkm2vDSGlC1PRlueLaufc4EFITkLKXK2MWBpexuNJfMVcg==} + cpu: [arm] + os: [linux] + + '@libsql/linux-arm64-gnu@0.5.29': + resolution: {integrity: sha512-gURBqaiXIGGwFNEaUj8Ldk7Hps4STtG+31aEidCk5evMMdtsdfL3HPCpvys+ZF/tkOs2MWlRWoSq7SOuCE9k3w==} + cpu: [arm64] + os: [linux] + + '@libsql/linux-arm64-musl@0.5.29': + resolution: {integrity: sha512-fwgYZ0H8mUkyVqXZHF3mT/92iIh1N94Owi/f66cPVNsk9BdGKq5gVpoKO+7UxaNzuEH1roJp2QEwsCZMvBLpqg==} + cpu: [arm64] + os: [linux] + + '@libsql/linux-x64-gnu@0.5.29': + resolution: {integrity: sha512-y14V0vY0nmMC6G0pHeJcEarcnGU2H6cm21ZceRkacWHvQAEhAG0latQkCtoS2njFOXiYIg+JYPfAoWKbi82rkg==} + cpu: [x64] + os: [linux] + + '@libsql/linux-x64-musl@0.5.29': + resolution: {integrity: sha512-gquqwA/39tH4pFl+J9n3SOMSymjX+6kZ3kWgY3b94nXFTwac9bnFNMffIomgvlFaC4ArVqMnOZD3nuJ3H3VO1w==} + cpu: [x64] + os: [linux] + + '@libsql/win32-x64-msvc@0.5.29': + resolution: {integrity: sha512-4/0CvEdhi6+KjMxMaVbFM2n2Z44escBRoEYpR+gZg64DdetzGnYm8mcNLcoySaDJZNaBd6wz5DNdgRmcI4hXcg==} + cpu: [x64] + os: [win32] + '@malept/cross-spawn-promise@1.1.1': resolution: {integrity: sha512-RTBGWL5FWQcg9orDOCcp4LvItNzUPcyEU9bwaeJX0rJ1IQxzucC48Y0/sQLp/g6t99IQgAlGIaesJS+gTn7tVQ==} engines: {node: '>= 10'} @@ -2014,6 +2120,9 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 + '@neon-rs/load@0.0.4': + resolution: {integrity: sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==} + '@next/env@16.2.9': resolution: {integrity: sha512-ki5VxxXfzD/9TDe13wyeTKIjQTAwBVpnr8KhRDUr8ltMUq1/NBpWNT5tiPoxiGl+PHM4X2ahSOiPk6iAimIzPg==} @@ -2772,10 +2881,18 @@ packages: resolution: {integrity: sha512-EooU3i9F6IAE8kEu+AnGf9DFZWkQBZ+hJn3tLVbsH+61mtQiva5biai66fAA6nvFPXkLgvrh7BrR7YcJU83xQQ==} engines: {node: '>=20'} + '@shikijs/core@4.3.1': + resolution: {integrity: sha512-ANMDxuaPsNMdDC1m4vfvhlDmJweMwkE5XitTwrq2rWHx5jM+dlm4MmHt2PP6t0uejfR77SuhrhJ0zEijIF/uhA==} + engines: {node: '>=20'} + '@shikijs/engine-javascript@4.3.0': resolution: {integrity: sha512-hTv/KiFf2tpiqlACPiztGGurEARWIutB8YUhcrA1pUC7VzzwKO+g5crUocrLztrZ5ro5Z4hbXg7bYclETn3gSQ==} engines: {node: '>=20'} + '@shikijs/engine-javascript@4.3.1': + resolution: {integrity: sha512-JBItcnPuYq7jVJdZo/vMj94r+szT7XEjHFX+mvFDGSEIbVAXAGyHAHzhbWzpGOwYidCZrErJLLgn2PVeiokHnQ==} + engines: {node: '>=20'} + '@shikijs/engine-oniguruma@4.3.0': resolution: {integrity: sha512-1vMdN3gHfnKfLYwecUI2ITJI4RhHt96xEaJumVn7Heb0IlJ8WQMIH0Voak+2j22BpSNKdnOfB/pCTPnPm2gq7A==} engines: {node: '>=20'} @@ -2784,21 +2901,43 @@ packages: resolution: {integrity: sha512-rnlqFbBRSys9bT4gl/5rw9RnS0W/I84ZldXPkO7cvlEMoV85TyF/aU01N7/NbSR776RNLjrJKjfFUXJR6wN1Cg==} engines: {node: '>=20'} + '@shikijs/langs@4.3.1': + resolution: {integrity: sha512-m0l9nsDqgBHvbZbk7A0/kXz/impK3uB/c6rAn6Gpg/uPtdZRQ+alsN/17MU5thb68XTj/4DxkZAotrM0GGSpDQ==} + engines: {node: '>=20'} + '@shikijs/primitive@4.3.0': resolution: {integrity: sha512-CPkz64PTa5diRW1ggzMZH9VM/du4RNChYgVtgqrFcgruvIybmCvySv8GkiHSczUHXYuuR8TdKEwFx+UnZMpgdg==} engines: {node: '>=20'} + '@shikijs/primitive@4.3.1': + resolution: {integrity: sha512-CXQRQOYy1leqQ8ceTeJdmXv/bsUY++6QyLpXJ94LZAAYj5X2SKRdc5ipguv4NPyGVKItB2PPwUpRNe0Sjh5S1A==} + engines: {node: '>=20'} + '@shikijs/themes@4.3.0': resolution: {integrity: sha512-Avgt05YiT+Y3prjIc9lmQxhJzHBcCfR6cjiFW4OyaMBbt2A6trX5rfjUzx+Vj/mE9qpArYjatnqo9XPjQNW/AQ==} engines: {node: '>=20'} + '@shikijs/themes@4.3.1': + resolution: {integrity: sha512-dgpoJ4WqNi2yTmizQHBJ5zcX6j2lE6icN/0yt4l1kkf16jrY/pwPLoTb1ETsWMz0OBLf9ZNvwmxft+cH+N9qSA==} + engines: {node: '>=20'} + '@shikijs/types@4.3.0': resolution: {integrity: sha512-oc8b9U2SYvofKZk8e/737nIX0qwf6eV2vHFATeObAu7r+mUVpLs8Re0BmVkIjAWAYgkmG/CzLNo7rzuBzRu/wQ==} engines: {node: '>=20'} + '@shikijs/types@4.3.1': + resolution: {integrity: sha512-CHFxE0jztBIZRHH6gxXE7DXUCFXjReEGxZ/j0rfSLGKZuwp2xBYycEP14875DSa9KLL/6700oxIq6oO6ef9K2g==} + engines: {node: '>=20'} + '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@simple-git/args-pathspec@1.0.3': + resolution: {integrity: sha512-ngJMaHlsWDTfjyq9F3VIQ8b7NXbBLq5j9i5bJ6XLYtD6qlDXT7fdKY2KscWWUF8t18xx052Y/PUO1K1TRc9yKA==} + + '@simple-git/argv-parser@1.1.1': + resolution: {integrity: sha512-Q9lBcfQ+VQCpQqGJFHe5yooOS5hGdLFFbJ5R+R5aDsnkPCahtn1hSkMcORX65J2Z5lxSkD0lQorMsncuBQxYUw==} + '@sinclair/typebox@0.27.10': resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==} @@ -3408,6 +3547,9 @@ packages: '@types/webxr@0.5.24': resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@types/yargs-parser@21.0.3': resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} @@ -4300,6 +4442,10 @@ packages: resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + detect-libc@2.0.2: + resolution: {integrity: sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==} + engines: {node: '>=8'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -5328,6 +5474,9 @@ packages: jose@6.2.3: resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + js-base64@3.9.1: + resolution: {integrity: sha512-U73qptcvf/HIOauFOmqT3a0mDUp0MYlfd15oqoe9kqZt5XhiXVb+HG09sLvI9PQ9tZIBFS4nlErai8zbWazP0g==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -5457,6 +5606,11 @@ packages: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} engines: {node: '>=6'} + libsql@0.5.29: + resolution: {integrity: sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg==} + cpu: [x64, arm64, wasm32, arm] + os: [darwin, linux, win32] + lighthouse-logger@1.4.2: resolution: {integrity: sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==} @@ -6350,6 +6504,9 @@ packages: resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} engines: {node: '>=0.4.0'} + promise-limit@2.7.0: + resolution: {integrity: sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==} + promise@7.3.1: resolution: {integrity: sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==} @@ -6882,6 +7039,9 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + simple-git@3.36.0: + resolution: {integrity: sha512-cGQjLjK8bxJw4QuYT7gxHw3/IouVESbhahSsHrX97MzCL1gu2u7oy38W6L2ZIGECEfIBG4BabsWDPjBxJENv9Q==} + simple-plist@1.3.1: resolution: {integrity: sha512-iMSw5i0XseMnrhtIzRb7XpQEXepa9xhWxGUojHBL43SIpQuDQkh3Wpy67ZbDzZVr6EKxvwVChnVpdl8hEVLDiw==} @@ -8862,7 +9022,7 @@ snapshots: postcss: 8.5.16 resolve-from: 5.0.0 optionalDependencies: - expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.7(react@19.2.7))(react-native-web@0.21.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)(typescript@6.0.3) transitivePeerDependencies: - bufferutil - supports-color @@ -9267,6 +9427,72 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@kwsites/file-exists@1.1.1': + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@kwsites/promise-deferred@1.1.1': {} + + '@libsql/client@0.17.4': + dependencies: + '@libsql/core': 0.17.4 + '@libsql/hrana-client': 0.10.0 + js-base64: 3.9.1 + libsql: 0.5.29 + promise-limit: 2.7.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@libsql/core@0.17.4': + dependencies: + js-base64: 3.9.1 + + '@libsql/darwin-arm64@0.5.29': + optional: true + + '@libsql/darwin-x64@0.5.29': + optional: true + + '@libsql/hrana-client@0.10.0': + dependencies: + '@libsql/isomorphic-ws': 0.1.5 + js-base64: 3.9.1 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@libsql/isomorphic-ws@0.1.5': + dependencies: + '@types/ws': 8.18.1 + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@libsql/linux-arm-gnueabihf@0.5.29': + optional: true + + '@libsql/linux-arm-musleabihf@0.5.29': + optional: true + + '@libsql/linux-arm64-gnu@0.5.29': + optional: true + + '@libsql/linux-arm64-musl@0.5.29': + optional: true + + '@libsql/linux-x64-gnu@0.5.29': + optional: true + + '@libsql/linux-x64-musl@0.5.29': + optional: true + + '@libsql/win32-x64-msvc@0.5.29': + optional: true + '@malept/cross-spawn-promise@1.1.1': dependencies: cross-spawn: 7.0.6 @@ -9341,6 +9567,8 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true + '@neon-rs/load@0.0.4': {} + '@next/env@16.2.9': {} '@next/swc-darwin-arm64@16.2.9': @@ -10220,12 +10448,26 @@ snapshots: '@types/hast': 3.0.4 hast-util-to-html: 9.0.5 + '@shikijs/core@4.3.1': + dependencies: + '@shikijs/primitive': 4.3.1 + '@shikijs/types': 4.3.1 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.5 + '@shikijs/engine-javascript@4.3.0': dependencies: '@shikijs/types': 4.3.0 '@shikijs/vscode-textmate': 10.0.2 oniguruma-to-es: 4.3.6 + '@shikijs/engine-javascript@4.3.1': + dependencies: + '@shikijs/types': 4.3.1 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 4.3.6 + '@shikijs/engine-oniguruma@4.3.0': dependencies: '@shikijs/types': 4.3.0 @@ -10235,23 +10477,48 @@ snapshots: dependencies: '@shikijs/types': 4.3.0 + '@shikijs/langs@4.3.1': + dependencies: + '@shikijs/types': 4.3.1 + '@shikijs/primitive@4.3.0': dependencies: '@shikijs/types': 4.3.0 '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 + '@shikijs/primitive@4.3.1': + dependencies: + '@shikijs/types': 4.3.1 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + '@shikijs/themes@4.3.0': dependencies: '@shikijs/types': 4.3.0 + '@shikijs/themes@4.3.1': + dependencies: + '@shikijs/types': 4.3.1 + '@shikijs/types@4.3.0': dependencies: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 + '@shikijs/types@4.3.1': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + '@shikijs/vscode-textmate@10.0.2': {} + '@simple-git/args-pathspec@1.0.3': {} + + '@simple-git/argv-parser@1.1.1': + dependencies: + '@simple-git/args-pathspec': 1.0.3 + '@sinclair/typebox@0.27.10': {} '@sindresorhus/merge-streams@4.0.0': {} @@ -10858,6 +11125,10 @@ snapshots: '@types/webxr@0.5.24': {} + '@types/ws@8.18.1': + dependencies: + '@types/node': 26.0.1 + '@types/yargs-parser@21.0.3': {} '@types/yargs@17.0.35': @@ -11096,7 +11367,7 @@ snapshots: react-refresh: 0.14.2 optionalDependencies: '@babel/runtime': 7.29.7 - expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.7(react@19.2.7))(react-native-web@0.21.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)(typescript@6.0.3) transitivePeerDependencies: - '@babel/core' - supports-color @@ -11312,7 +11583,7 @@ snapshots: chrome-launcher@0.15.2: dependencies: - '@types/node': 25.9.4 + '@types/node': 26.0.1 escape-string-regexp: 4.0.0 is-wsl: 2.2.0 lighthouse-logger: 1.4.2 @@ -11321,7 +11592,7 @@ snapshots: chromium-edge-launcher@0.3.0: dependencies: - '@types/node': 25.9.4 + '@types/node': 26.0.1 escape-string-regexp: 4.0.0 is-wsl: 2.2.0 lighthouse-logger: 1.4.2 @@ -11788,6 +12059,8 @@ snapshots: destroy@1.2.0: {} + detect-libc@2.0.2: {} + detect-libc@2.1.2: {} detect-node-es@1.1.0: {} @@ -13119,6 +13392,8 @@ snapshots: jose@6.2.3: {} + js-base64@3.9.1: {} + js-tokens@4.0.0: {} js-yaml@4.3.0: @@ -13214,6 +13489,21 @@ snapshots: leven@3.1.0: {} + libsql@0.5.29: + dependencies: + '@neon-rs/load': 0.0.4 + detect-libc: 2.0.2 + optionalDependencies: + '@libsql/darwin-arm64': 0.5.29 + '@libsql/darwin-x64': 0.5.29 + '@libsql/linux-arm-gnueabihf': 0.5.29 + '@libsql/linux-arm-musleabihf': 0.5.29 + '@libsql/linux-arm64-gnu': 0.5.29 + '@libsql/linux-arm64-musl': 0.5.29 + '@libsql/linux-x64-gnu': 0.5.29 + '@libsql/linux-x64-musl': 0.5.29 + '@libsql/win32-x64-msvc': 0.5.29 + lighthouse-logger@1.4.2: dependencies: debug: 2.6.9 @@ -14463,6 +14753,8 @@ snapshots: progress@2.0.3: {} + promise-limit@2.7.0: {} + promise@7.3.1: dependencies: asap: 2.0.6 @@ -15395,6 +15687,16 @@ snapshots: signal-exit@4.1.0: {} + simple-git@3.36.0: + dependencies: + '@kwsites/file-exists': 1.1.1 + '@kwsites/promise-deferred': 1.1.1 + '@simple-git/args-pathspec': 1.0.3 + '@simple-git/argv-parser': 1.1.1 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + simple-plist@1.3.1: dependencies: bplist-creator: 0.1.0 From b0cd13b67bcaf7d95385e3eb52274fb99c7561f3 Mon Sep 17 00:00:00 2001 From: Bobby Lin Date: Fri, 24 Jul 2026 15:18:45 +0800 Subject: [PATCH 04/32] feat: add new layout --- apps/desktop/src/bun/index.ts | 2 + apps/web/components.json | 3 +- .../components/workspace/workspace-shell.tsx | 551 ++++-------------- apps/web/src/routes/__root.tsx | 5 +- apps/web/src/routes/index.tsx | 4 +- design-qa.md | 38 ++ packages/ui/components.json | 5 +- packages/ui/package.json | 4 +- packages/ui/src/components/attachment.tsx | 16 +- packages/ui/src/components/bubble.tsx | 4 +- packages/ui/src/components/button.tsx | 16 +- packages/ui/src/components/card.tsx | 13 +- packages/ui/src/components/checkbox.tsx | 5 +- packages/ui/src/components/command.tsx | 193 ++++++ packages/ui/src/components/dialog.tsx | 156 +++++ packages/ui/src/components/dropdown-menu.tsx | 18 +- packages/ui/src/components/empty.tsx | 6 +- packages/ui/src/components/input-group.tsx | 38 +- packages/ui/src/components/input.tsx | 2 +- packages/ui/src/components/label.tsx | 6 +- packages/ui/src/components/marker.tsx | 4 +- .../ui/src/components/message-scroller.tsx | 13 +- packages/ui/src/components/message.tsx | 10 +- packages/ui/src/components/skeleton.tsx | 2 +- packages/ui/src/components/textarea.tsx | 2 +- packages/ui/src/components/tooltip.tsx | 7 +- packages/ui/src/styles/globals.css | 28 +- pnpm-lock.yaml | 41 +- 28 files changed, 626 insertions(+), 566 deletions(-) create mode 100644 design-qa.md create mode 100644 packages/ui/src/components/command.tsx create mode 100644 packages/ui/src/components/dialog.tsx diff --git a/apps/desktop/src/bun/index.ts b/apps/desktop/src/bun/index.ts index 480515d..209a9b6 100644 --- a/apps/desktop/src/bun/index.ts +++ b/apps/desktop/src/bun/index.ts @@ -97,6 +97,8 @@ const url = await getMainViewUrl(); new BrowserWindow({ rpc, title: "chestnut-code", + titleBarStyle: "hiddenInset", + trafficLightOffset: { x: 0, y: 6 }, url, frame: { width: 1280, diff --git a/apps/web/components.json b/apps/web/components.json index 782b6d3..e37520f 100644 --- a/apps/web/components.json +++ b/apps/web/components.json @@ -1,6 +1,6 @@ { "$schema": "https://ui.shadcn.com/schema.json", - "style": "base-lyra", + "style": "base-rhea", "rsc": false, "tsx": true, "tailwind": { @@ -11,6 +11,7 @@ "prefix": "" }, "iconLibrary": "lucide", + "rtl": false, "aliases": { "components": "@/components", "utils": "@chestnut-code/ui/lib/utils", diff --git a/apps/web/src/components/workspace/workspace-shell.tsx b/apps/web/src/components/workspace/workspace-shell.tsx index 2fccde3..348b067 100644 --- a/apps/web/src/components/workspace/workspace-shell.tsx +++ b/apps/web/src/components/workspace/workspace-shell.tsx @@ -1,471 +1,130 @@ import { Button } from "@chestnut-code/ui/components/button"; -import { Input } from "@chestnut-code/ui/components/input"; import { - BotIcon, - CircleDotDashedIcon, - Code2Icon, - FolderOpenIcon, - FolderPlusIcon, - GitBranchIcon, - Globe2Icon, + Command, + CommandDialog, + CommandEmpty, + CommandInput, + CommandList, +} from "@chestnut-code/ui/components/command"; +import { cn } from "@chestnut-code/ui/lib/utils"; + +import { + ArrowLeftIcon, + ArrowRightIcon, + PanelLeftIcon, PanelRightIcon, - PlusIcon, - RefreshCwIcon, SearchIcon, - Settings2Icon, - TerminalSquareIcon, - Trash2Icon, } from "lucide-react"; -import { - type FormEvent, - startTransition, - useDeferredValue, - useEffect, - useMemo, - useState, -} from "react"; -import { - client, - type FileContent, - type FsNode, - type Workspace, -} from "@/lib/adapter"; - -import { FilePreview } from "./file-preview"; -import { FileTree } from "./file-tree"; +import { type ReactNode, useEffect, useState } from "react"; -const panelTabs = [ - { icon: GitBranchIcon, id: "git", label: "Git" }, - { icon: Code2Icon, id: "preview", label: "File Preview" }, - { icon: TerminalSquareIcon, id: "terminal", label: "Terminal" }, - { icon: Globe2Icon, id: "browser", label: "Browser" }, -] as const; - -type PanelTab = (typeof panelTabs)[number]["id"]; +type WorkspaceShellProps = { + children: ReactNode; +}; -export function WorkspaceShell() { - const [workspace, setWorkspace] = useState(null); - const [recents, setRecents] = useState([]); - const [rootEntries, setRootEntries] = useState([]); - const [file, setFile] = useState(null); - const [filePath, setFilePath] = useState(""); - const [search, setSearch] = useState(""); - const [searchResults, setSearchResults] = useState(null); - const [activeTab, setActiveTab] = useState("preview"); - const [isOpening, setIsOpening] = useState(false); - const [isLoadingFile, setIsLoadingFile] = useState(false); - const [isSearching, setIsSearching] = useState(false); - const [error, setError] = useState(null); - const deferredSearch = useDeferredValue(search); +export function WorkspaceShell({ children }: WorkspaceShellProps) { + const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); + const [isSearchOpen, setIsSearchOpen] = useState(false); useEffect(() => { - void client.workspace - .listRecents() - .then(setRecents) - .catch((recentsError: unknown) => setError(toMessage(recentsError))); - }, []); - - useEffect(() => { - if (!workspace) { - setSearchResults(null); - return; - } - const query = deferredSearch.trim(); - if (!query) { - setSearchResults(null); - return; - } - - let isCurrent = true; - setIsSearching(true); - void client.files - .searchByName(workspace.id, query) - .then((results) => { - if (isCurrent) setSearchResults(results); - }) - .catch((searchError: unknown) => { - if (isCurrent) setError(toMessage(searchError)); - }) - .finally(() => { - if (isCurrent) setIsSearching(false); - }); - - return () => { - isCurrent = false; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.metaKey && event.key.toLowerCase() === "k") { + event.preventDefault(); + setIsSearchOpen((open) => !open); + } }; - }, [deferredSearch, workspace]); - - const treeEntries = useMemo( - () => searchResults ?? rootEntries, - [rootEntries, searchResults], - ); - - const selectWorkspace = async (rootPath: string) => { - setIsOpening(true); - setError(null); - try { - const selectedWorkspace = await client.workspace.open(rootPath); - const [entries, nextRecents] = await Promise.all([ - client.workspace.list(selectedWorkspace.id), - client.workspace.listRecents(), - ]); - startTransition(() => { - setWorkspace(selectedWorkspace); - setRootEntries(entries); - setRecents(nextRecents); - setFile(null); - setFilePath(""); - setSearch(""); - }); - } catch (openError) { - setError(toMessage(openError)); - } finally { - setIsOpening(false); - } - }; - - const pickWorkspace = async () => { - if (!client.workspace.pickFolder) return; - try { - const selectedPath = await client.workspace.pickFolder(); - if (selectedPath) await selectWorkspace(selectedPath); - } catch (pickerError) { - setError(toMessage(pickerError)); - } - }; - const refreshTree = async () => { - if (!workspace) return; - try { - setRootEntries(await client.workspace.list(workspace.id)); - setSearchResults(null); - setSearch(""); - } catch (refreshError) { - setError(toMessage(refreshError)); - } - }; - - const selectFile = async (path: string) => { - if (!workspace) return; - setActiveTab("preview"); - setFilePath(path); - setIsLoadingFile(true); - try { - setFile(await client.files.read(workspace.id, path)); - } catch (readError) { - setError(toMessage(readError)); - } finally { - setIsLoadingFile(false); - } - }; - - const removeWorkspace = async (workspaceId: string) => { - try { - await client.workspace.remove(workspaceId); - const nextRecents = await client.workspace.listRecents(); - setRecents(nextRecents); - if (workspace?.id === workspaceId) { - setWorkspace(null); - setRootEntries([]); - setFile(null); - setFilePath(""); - } - } catch (removeError) { - setError(toMessage(removeError)); - } - }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, []); return ( -
-
-
- - Chestnut Code - / - - {workspace?.name ?? "No workspace"} - -
-
- - Local mode - - +
+ + + +
-
- - -
-
-
-

New conversation

-

- Agent chat is coming next. -

-
- -
-
-
- -

- Your local coding workspace -

-

- {workspace - ? `Browse ${workspace.name} on the left and preview files on the right.` - : "Open a project folder to get started."} -

-
-
-
- -
- -
- {workspace ? workspace.rootPath : "No workspace selected"} - {error ? `Error: ${error}` : "Ready"} -
-
- ); -} - -type WorkspaceSectionProps = { - isOpening: boolean; - onOpen: (rootPath: string) => Promise; - onPick?: () => Promise; - onRemove: (workspaceId: string) => Promise; - recents: Workspace[]; - workspace: Workspace | null; -}; - -function WorkspaceSection({ - isOpening, - onOpen, - onPick, - onRemove, - recents, - workspace, -}: WorkspaceSectionProps) { - const [rootPath, setRootPath] = useState(""); - const [showPathInput, setShowPathInput] = useState(!onPick); - - const handleSubmit = async (event: FormEvent) => { - event.preventDefault(); - const path = rootPath.trim(); - if (!path) return; - await onOpen(path); - setRootPath(""); - }; - - return ( -
-
- - -
- {workspace ? ( -
- -
-

{workspace.name}

-

- {workspace.rootPath} -

-
-
- ) : null} - {showPathInput ? ( -
- setRootPath(event.target.value)} - placeholder="/path/to/project" - className="h-8 min-w-0 text-xs" - disabled={isOpening} - /> -
- ) : null} - {recents.length > 0 ? ( -
-

- Recents -

-
    - {recents.map((recent) => ( -
  • - - -
  • - ))} -
-
- ) : null} -
- ); -} - -function PanelPlaceholder({ tab }: { tab: Exclude }) { - const content = { - browser: "Browser preview is planned for Phase 4.", - git: "Git status and diffs are planned for Phase 4.", - terminal: "The workspace terminal is planned for Phase 4.", - }[tab]; - - return ( -
- {content} + +
+ {children} +
+
+ + + + + No results found. + + +
); } - -function SectionTitle({ label }: { label: string }) { - return ( - - {label} - - ); -} - -function toMessage(error: unknown): string { - return error instanceof Error ? error.message : "Something went wrong."; -} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 464f985..7a170a7 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -7,6 +7,7 @@ import { } from "@tanstack/react-router"; import { ThemeProvider } from "@/components/theme-provider"; +import { WorkspaceShell } from "@/components/workspace/workspace-shell"; import type { trpc } from "@/utils/trpc"; import "../index.css"; @@ -47,7 +48,9 @@ function RootComponent() { disableTransitionOnChange storageKey="vite-ui-theme" > - + + + diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index 80b51e4..c6ad1a4 100644 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -1,11 +1,9 @@ import { createFileRoute } from "@tanstack/react-router"; -import { WorkspaceShell } from "@/components/workspace/workspace-shell"; - export const Route = createFileRoute("/")({ component: HomeComponent, }); function HomeComponent() { - return ; + return
Hello World
; } diff --git a/design-qa.md b/design-qa.md new file mode 100644 index 0000000..8e9c34f --- /dev/null +++ b/design-qa.md @@ -0,0 +1,38 @@ +# Desktop Title-Bar Control Alignment QA + +## Comparison target + +- Source visual truth: `/var/folders/ky/g8ysflnj7dn09j73fg6kjggw0000gn/T/codex-clipboard-5fa429ad-0f7f-46e1-9d2b-e7c5f1a931da.png` +- Implementation capture: `/tmp/chestnut-titlebar-aligned.png` +- Full-view comparison evidence: `/tmp/chestnut-titlebar-alignment-comparison.png` +- Viewport: implementation `1280 × 720` CSS px at 1× browser capture; source `1788 × 372` px. +- Density normalization: source title-bar/sidebar region was cropped to `586 × 354` px and downsampled to `330 × 200` px; the matching `330 × 200` px region was used from the implementation capture. +- State: default light workspace with the sidebar expanded. + +## Comparison history + +- [P1] The trigger and navigation actions were initially grouped together, unlike the reference where Back/Forward sit at the far end of the title-bar control group. + - Fix: made the title-bar group use `justify-between`, keeping the trigger near the native inset and moving the navigation pair to the trailing edge. +- [P2] The trigger sat 30 px too close to the native traffic-light inset after density normalization. + - Fix: added a 30 px logical start offset for the trigger while retaining the navigation pair's matched trailing position. + +## Findings + +No actionable P0, P1, or P2 differences remain. + +- Native traffic lights share the same `hiddenInset` desktop row as the web controls. +- The trigger, Back, and Forward buttons share a centered flex baseline; their horizontal geometry now matches the highlighted source region. +- Fonts/typography, spacing/layout rhythm, neutral surface colors, icon treatment, and the unchanged app copy remain consistent with the reference. +- No custom imagery is present in the focused region; standard Lucide icons are used for application controls. + +## Interaction checks + +- Sidebar collapse/expand remains available from the aligned trigger. +- Back and Forward remain wired to browser history. +- The local browser capture reported no console errors. + +## Follow-up polish + +- P3: replace browser-history navigation with workspace-level history when that interaction exists. + +final result: passed diff --git a/packages/ui/components.json b/packages/ui/components.json index cf5577c..a48348a 100644 --- a/packages/ui/components.json +++ b/packages/ui/components.json @@ -1,6 +1,6 @@ { "$schema": "https://ui.shadcn.com/schema.json", - "style": "base-lyra", + "style": "base-rhea", "rsc": false, "tsx": true, "tailwind": { @@ -20,5 +20,6 @@ }, "menuColor": "default", "menuAccent": "subtle", - "registries": {} + "registries": {}, + "rtl": false } diff --git a/packages/ui/package.json b/packages/ui/package.json index bae8900..7a2f710 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -15,14 +15,16 @@ }, "dependencies": { "@base-ui/react": "^1.6.0", + "@fontsource-variable/inter": "^5.3.0", "@shadcn/react": "^0.1.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "lucide-react": "catalog:", "next-themes": "catalog:", "react": "^19.2.7", "react-dom": "^19.2.7", - "shadcn": "^4.12.0", + "shadcn": "^4.14.0", "sonner": "catalog:", "tailwind-merge": "catalog:", "tw-animate-css": "^1.4.0" diff --git a/packages/ui/src/components/attachment.tsx b/packages/ui/src/components/attachment.tsx index 9526116..fe1840a 100644 --- a/packages/ui/src/components/attachment.tsx +++ b/packages/ui/src/components/attachment.tsx @@ -6,14 +6,14 @@ import { cva, type VariantProps } from "class-variance-authority"; import type * as React from "react"; const attachmentVariants = cva( - "group/attachment relative flex w-fit min-w-0 max-w-full shrink-0 flex-wrap rounded-none border bg-card text-card-foreground transition-colors focus-within:ring-1 focus-within:ring-ring/50 has-[>a,>button]:hover:bg-muted/50 data-[state=error]:border-destructive/30 data-[state=idle]:border-dashed", + "group/attachment relative flex w-fit min-w-0 max-w-full shrink-0 flex-wrap rounded-2xl border bg-card text-card-foreground transition-colors focus-within:ring-1 focus-within:ring-ring/30 has-[>a,>button]:hover:bg-muted/50 data-[state=error]:border-destructive/30 data-[state=idle]:border-dashed", { variants: { size: { default: - "gap-2 text-xs has-data-[slot=attachment-media]:p-1.5 has-data-[slot=attachment-content]:px-2 has-data-[slot=attachment-content]:py-1.5", - sm: "gap-2.5 text-xs has-data-[slot=attachment-media]:p-1 has-data-[slot=attachment-content]:px-1.5 has-data-[slot=attachment-content]:py-1", - xs: "gap-1.5 rounded-none text-xs has-data-[slot=attachment-media]:p-1 has-data-[slot=attachment-content]:px-1.5 has-data-[slot=attachment-content]:py-1", + "gap-2 text-sm has-data-[slot=attachment-media]:p-2 has-data-[slot=attachment-content]:px-2.5 has-data-[slot=attachment-content]:py-2", + sm: "gap-2.5 text-xs has-data-[slot=attachment-media]:p-1.5 has-data-[slot=attachment-content]:px-2 has-data-[slot=attachment-content]:py-1.5", + xs: "gap-1.5 rounded-xl text-xs has-data-[slot=attachment-media]:p-1 has-data-[slot=attachment-content]:px-1.5 has-data-[slot=attachment-content]:py-1", }, orientation: { horizontal: "min-w-40 items-center", @@ -33,14 +33,12 @@ function Attachment({ VariantProps & { state?: "idle" | "uploading" | "processing" | "error" | "done"; }) { - const resolvedOrientation = orientation ?? "horizontal"; - return (
@@ -48,7 +46,7 @@ function Attachment({ } const attachmentMediaVariants = cva( - "relative flex aspect-square w-10 shrink-0 items-center justify-center overflow-hidden rounded-none bg-muted text-foreground group-data-[orientation=vertical]/attachment:w-full group-data-[size=sm]/attachment:w-8 group-data-[size=xs]/attachment:w-7 group-data-[size=xs]/attachment:rounded-none group-data-[state=error]/attachment:bg-destructive/10 group-data-[state=error]/attachment:text-destructive group-data-[orientation=vertical]/attachment:*:data-[slot=spinner]:size-6! [&_svg:not([class*='size-'])]:size-4 group-data-[orientation=vertical]/attachment:[&_svg:not([class*='size-'])]:size-6 group-data-[size=xs]/attachment:[&_svg:not([class*='size-'])]:size-3.5 [&_svg]:pointer-events-none", + "relative flex aspect-square w-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted text-foreground group-data-[orientation=vertical]/attachment:w-full group-data-[size=sm]/attachment:w-8 group-data-[size=xs]/attachment:w-7 group-data-[size=xs]/attachment:rounded-md group-data-[state=error]/attachment:bg-destructive/10 group-data-[state=error]/attachment:text-destructive group-data-[orientation=vertical]/attachment:*:data-[slot=spinner]:size-6! [&_svg:not([class*='size-'])]:size-4 group-data-[orientation=vertical]/attachment:[&_svg:not([class*='size-'])]:size-6 group-data-[size=xs]/attachment:[&_svg:not([class*='size-'])]:size-3.5 [&_svg]:pointer-events-none", { variants: { variant: { @@ -147,13 +145,11 @@ function AttachmentAction({ className, variant, size = "icon-xs", - type = "button", ...props }: React.ComponentProps) { return (