diff --git a/CLAUDE.md b/CLAUDE.md index b3a30ccbf..46053ca92 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,11 @@ - Monorepo with pnpm workspaces and turbo - `apps/twig` - Twig Electron desktop app (React + Vite) +- `apps/cli` - CLI tool (thin wrapper around @twig/core) +- `apps/mobile` - React Native mobile app (Expo) - `packages/agent` - TypeScript agent framework wrapping Claude Agent SDK +- `packages/core` - Shared business logic for jj/GitHub operations +- `packages/electron-trpc` - Custom tRPC package for Electron IPC ## Commands @@ -59,13 +63,13 @@ Import directly from source files instead. ## Architecture -See [ARCHITECTURE.md](./ARCHITECTURE.md) for detailed patterns (DI, services, tRPC, state management). +See [ARCHITECTURE.md](./apps/twig/ARCHITECTURE.md) for detailed patterns (DI, services, tRPC, state management). ### Electron App (apps/twig) - **Main process** (`src/main/`) - Stateless services, tRPC routers, system I/O - **Renderer process** (`src/renderer/`) - React app, all application state -- **IPC**: tRPC over Electron IPC (type-safe) +- **IPC**: tRPC over Electron IPC (type-safe via @posthog/electron-trpc) - **DI**: InversifyJS in both processes (`src/main/di/`, `src/renderer/di/`) - **State**: Zustand stores in renderer only - main is stateless - **Testing**: Vitest with React Testing Library @@ -88,22 +92,264 @@ See [ARCHITECTURE.md](./ARCHITECTURE.md) for detailed patterns (DI, services, tR - Shared business logic for jj/GitHub operations +## Agent Integration Guidelines + +- **No rawInput**: Don't use Claude Code SDK's `rawInput` - only use Zod validated meta fields. This keeps us agent agnostic and gives us a maintainable, extensible format for logs. +- **Use ACP SDK types**: Don't roll your own types for things available in the ACP SDK. Import types directly from `@anthropic-ai/claude-agent-sdk` TypeScript SDK. +- **Permissions via tool calls**: If something requires user input/approval, implement it through a tool call with a permission instead of custom methods + notifications. Avoid patterns like `_array/permission_request`. + ## Key Libraries -- React 18, Radix UI Themes, Tailwind CSS +- React 19, Radix UI Themes, Tailwind CSS - TanStack Query for data fetching - xterm.js for terminal emulation - CodeMirror for code editing - Tiptap for rich text - Zod for schema validation +- InversifyJS for dependency injection +- Sonner for toast notifications + +## Code Patterns + +### React Components + +Components are functional with hooks. Props typed with interfaces: + +```typescript +interface AgentMessageProps { + content: string; +} + +export function AgentMessage({ content }: AgentMessageProps) { + return ( + + + + ); +} +``` + +Complex components organize hooks by concern (data, UI state, side effects): + +```typescript +export function TaskDetail({ task: initialTask }: TaskDetailProps) { + const taskId = initialTask.id; + useTaskData({ taskId, initialTask }); // Data fetching + + const workspace = useWorkspaceStore((state) => state.workspaces[taskId]); // Store + const [filePickerOpen, setFilePickerOpen] = useState(false); // Local state + + useHotkeys("mod+p", () => setFilePickerOpen(true), {...}); // Effects + useFileWatcher(effectiveRepoPath ?? null, taskId); + // ... +} +``` + +### Zustand Stores + +Stores separate state and actions with persistence middleware: + +```typescript +interface SidebarStoreState { + open: boolean; + width: number; +} + +interface SidebarStoreActions { + setOpen: (open: boolean) => void; + toggle: () => void; +} + +type SidebarStore = SidebarStoreState & SidebarStoreActions; + +export const useSidebarStore = create()( + persist( + (set) => ({ + open: false, + width: 256, + setOpen: (open) => set({ open }), + toggle: () => set((state) => ({ open: !state.open })), + }), + { + name: "sidebar-storage", + partialize: (state) => ({ open: state.open, width: state.width }), + } + ) +); +``` + +### tRPC Routers (Main Process) + +Routers get services from DI container per-request: + +```typescript +const getService = () => container.get(MAIN_TOKENS.GitService); + +export const gitRouter = router({ + detectRepo: publicProcedure + .input(detectRepoInput) + .output(detectRepoOutput) + .query(({ input }) => getService().detectRepo(input.directoryPath)), + + onCloneProgress: publicProcedure.subscription(async function* (opts) { + const service = getService(); + for await (const data of service.toIterable(GitServiceEvent.CloneProgress, { signal: opts.signal })) { + yield data; + } + }), +}); +``` + +### Services (Main Process) + +Services are injectable, stateless, and can emit events: + +```typescript +@injectable() +export class GitService extends TypedEventEmitter { + public async detectRepo(directoryPath: string): Promise { + if (!directoryPath) return null; + const remoteUrl = await this.getRemoteUrl(directoryPath); + // ... + } +} +``` + +### Custom Hooks + +Hooks extract store subscriptions into cleaner interfaces: + +```typescript +export function useConnectivity() { + const isOnline = useConnectivityStore((s) => s.isOnline); + const check = useConnectivityStore((s) => s.check); + return { isOnline, check }; +} +``` + +### Logger Usage + +Use scoped logger instead of console: + +```typescript +const log = logger.scope("navigation-store"); + +export const useNavigationStore = create()( + persist((set, get) => { + log.info("Folder path is stale, redirecting...", { folderId: folder.id }); + // ... + }) +); +``` -## Environment Variables - -- Copy `.env.example` to `.env` +## Testing -TODO: Update me +### Commands + +- `pnpm test` - Run unit tests across all packages +- `pnpm --filter twig test` - Run twig unit tests only +- `pnpm test:e2e` - Run Playwright E2E tests + +### When to Write Unit Tests vs E2E Tests + +**Unit tests (Vitest)** - Fast, isolated, run frequently: +- Zustand store logic and state transitions +- Pure utility functions and helpers +- Service methods with mocked dependencies +- Complex business logic in isolation +- Data transformations and validators + +**E2E tests (Playwright)** - Slower, test real user flows: +- Critical user journeys (auth, task creation, workspace setup) +- IPC communication between main and renderer +- Features requiring real Electron APIs (file system, shell) +- Multi-step workflows spanning multiple components +- Regression tests for reported bugs + +**Rule of thumb**: If it can be tested without Electron running, use a unit test. If it requires the full app context or tests user-facing behavior, use E2E. + +### Test File Location + +Tests are colocated with source code using `.test.ts` or `.test.tsx` extension. E2E tests live in `tests/e2e/`. + +### Store Testing + +```typescript +describe("store", () => { + beforeEach(() => { + localStorage.clear(); + useStore.setState({ /* reset state */ }); + }); + + it("action changes state", () => { + useStore.getState().action(); + expect(useStore.getState().property).toBe(expectedValue); + }); + + it("persists to localStorage", () => { + useStore.getState().action(); + const persisted = localStorage.getItem("store-key"); + expect(JSON.parse(persisted).state).toEqual(expectedState); + }); +}); +``` + +### Mocking Patterns + +**Hoisted mocks for complex modules:** +```typescript +const mockPty = vi.hoisted(() => ({ spawn: vi.fn() })); +vi.mock("node-pty", () => mockPty); +``` + +**Simple module mocks:** +```typescript +vi.mock("@renderer/lib/analytics", () => ({ track: vi.fn() })); +``` + +**Global fetch stubbing:** +```typescript +const mockFetch = vi.fn(); +vi.stubGlobal("fetch", mockFetch); +mockFetch.mockResolvedValueOnce(ok()); +``` + +### Test Helpers + +Test utilities are in `src/test/`: +- `setup.ts` - Global test setup with localStorage mock +- `utils.tsx` - `renderWithProviders()` for component tests +- `fixtures.ts` - Mock data factories +- `panelTestHelpers.ts` - Domain-specific assertions + +## Directory Structure + +``` +apps/twig/src/ +├── main/ +│ ├── di/ # InversifyJS container + tokens +│ ├── services/ # Stateless services (git, shell, workspace, etc.) +│ ├── trpc/ +│ │ ├── router.ts # Root router combining all routers +│ │ └── routers/ # Individual routers per service +│ └── lib/logger.ts +├── renderer/ +│ ├── di/ # Renderer DI container +│ ├── features/ # Feature modules (sessions, tasks, terminal, etc.) +│ ├── stores/ # Zustand stores (21+ stores) +│ ├── hooks/ # Custom React hooks +│ ├── components/ # Shared components +│ ├── trpc/client.ts # tRPC client setup +│ └── lib/ +│ ├── analytics.ts # PostHog integration +│ └── logger.ts +├── shared/ # Shared between main & renderer +│ ├── types.ts # Shared type definitions +│ └── constants.ts +├── api/ # PostHog API client +└── test/ # Test utilities +``` -## Testing +## Environment Variables -- `pnpm test` - Run tests across all packages -- Twig app: Vitest with jsdom, helpers in `apps/twig/src/test/` +- Copy `.env.example` to `.env` diff --git a/README.md b/README.md index 0870b0a3e..db1f88295 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,12 @@ > [!IMPORTANT] > Twig is pre-alpha and not production-ready. Interested? Email jonathan@posthog.com +**[Download the latest desktop app release](https://github.com/PostHog/twig/releases/latest)** -# PostHog Twig Monorepo +# Twig This is the monorepo for PostHog's Twig apps and the agent framework that powers them. -## Projects - -- **[apps/twig](./apps/twig)** - Twig desktop application (Electron) -- **[apps/mobile](./apps/mobile)** - PostHog mobile app (React Native / Expo) -- **[packages/agent](./packages/agent)** - The TypeScript agent framework - ## Development ### Prerequisites @@ -28,8 +23,8 @@ npm install -g pnpm # Install dependencies for all packages pnpm install -# Build the agent package -pnpm --filter agent build +# Copy environment config +cp .env.example .env ``` ### Running in Development @@ -43,155 +38,39 @@ pnpm dev:agent # Run agent in watch mode pnpm dev:twig # Run twig app ``` -### Mobile App - -```bash -# Install mobile dependencies -pnpm mobile:install - -# Build and run on iOS simulator -pnpm mobile:run:ios - -# Start development server (without rebuilding again) -pnpm mobile:start +### Utility Scripts -# Submit to TestFlight -pnpm mobile:testflight -``` - -See [apps/mobile/README.md](./apps/mobile/README.md) for more details on developing the mobile app. - -### Other Commands - -```bash -# Build all packages -pnpm build +Scripts in `scripts/` for development and debugging: -# Run type checking across all packages -pnpm typecheck - -# Run linting across all packages -pnpm lint - -# Run tests across all packages -pnpm test -``` +| Script | Description | +|--------|-------------| +| `scripts/clean-twig-macos.sh` | Remove all Twig app data from macOS (caches, preferences, logs, saved state). Use `--app` flag to also delete Twig.app from /Applications. | +| `scripts/test-access-token.js` | Validate a PostHog OAuth access token by testing API endpoints. Usage: `node scripts/test-access-token.js [region]` | ## Project Structure ``` -twig-monorepo/ +twig/ ├── apps/ -│ ├── twig/ # Electron desktop app -│ └── mobile/ # React Native mobile app (Expo) +│ ├── twig/ # Electron desktop app (React, Vite) +│ ├── mobile/ # React Native mobile app (Expo) +│ └── cli/ # arr CLI for stacked PRs ├── packages/ -│ └── agent/ # Agent framework -├── pnpm-workspace.yaml # Workspace configuration -└── package.json # Root package.json +│ ├── agent/ # TypeScript agent framework +│ ├── core/ # Shared business logic +│ └── electron-trpc/ # tRPC for Electron IPC ``` -## Workspace Configuration (twig.json) - -Twig supports per-repository configuration through a `twig.json` file (or legacy `array.json`). This lets you define scripts that run automatically when workspaces are created or destroyed. - -### File Locations - -Twig searches for configuration in this order (first match wins): - -1. `.twig/{workspace-name}/twig.json` - Workspace-specific config (new) -2. `.twig/{workspace-name}/array.json` - Workspace-specific config (legacy) -3. `.array/{workspace-name}/array.json` - Workspace-specific config (legacy location) -4. `twig.json` - Repository root config (new) -5. `array.json` - Repository root config (legacy) - -### Schema - -```json -{ - "scripts": { - "init": "npm install", - "start": ["npm run server", "npm run client"], - "destroy": "docker-compose down" - } -} -``` +## Documentation -| Script | When it runs | Behavior | -|--------|--------------|----------| -| `init` | Workspace creation | Runs first, fails fast (stops on error) | -| `start` | After init completes | Continues even if scripts fail | -| `destroy` | Workspace deletion | Runs silently before cleanup | - -Each script can be a single command string or an array of commands. Commands run sequentially in dedicated terminal sessions. - -### Examples - -Install dependencies on workspace creation: -```json -{ - "scripts": { - "init": "pnpm install" - } -} -``` - -Start development servers: -```json -{ - "scripts": { - "init": ["pnpm install", "pnpm run build"], - "start": ["pnpm run dev", "pnpm run storybook"] - } -} -``` - -Clean up Docker containers: -```json -{ - "scripts": { - "destroy": "docker-compose down -v" - } -} -``` - -## Workspace Environment Variables - -Twig automatically sets environment variables in all workspace terminals and scripts. These are available in `init`, `start`, and `destroy` scripts, as well as any terminal sessions opened within a workspace. - -| Variable | Description | Example | -|----------|-------------|---------| -| `TWIG_WORKSPACE_NAME` | Worktree name, or folder name in root mode | `my-feature-branch` | -| `TWIG_WORKSPACE_PATH` | Absolute path to the workspace | `/Users/dev/.twig/worktrees/repo/my-feature` | -| `TWIG_ROOT_PATH` | Absolute path to the repository root | `/Users/dev/repos/my-project` | -| `TWIG_DEFAULT_BRANCH` | Default branch detected from git | `main` | -| `TWIG_WORKSPACE_BRANCH` | Initial branch when workspace was created | `twig/my-feature` | -| `TWIG_WORKSPACE_PORTS` | Comma-separated list of allocated ports | `50000,50001,...,50019` | -| `TWIG_WORKSPACE_PORTS_RANGE` | Number of ports allocated | `20` | -| `TWIG_WORKSPACE_PORTS_START` | First port in the range | `50000` | -| `TWIG_WORKSPACE_PORTS_END` | Last port in the range | `50019` | - -Note: `TWIG_WORKSPACE_BRANCH` reflects the branch at workspace creation time. If you or the agent checks out a different branch, this variable will still show the original branch name. - -### Port Allocation - -Each workspace is assigned a unique range of 20 ports starting from port 50000. The allocation is deterministic based on the task ID, so the same workspace always receives the same ports across restarts. - -### Usage Examples - -Use ports in your start scripts: -```json -{ - "scripts": { - "start": "npm run dev -- --port $TWIG_WORKSPACE_PORTS_START" - } -} -``` - -Reference the workspace path: -```bash -echo "Working in: $TWIG_WORKSPACE_NAME" -echo "Root repo: $TWIG_ROOT_PATH" -``` +| File | Description | +|------|-------------| +| [CLAUDE.md](./CLAUDE.md) | Code style, patterns, and testing guidelines | +| [UPDATES.md](./UPDATES.md) | Release versioning and git tagging | +| [apps/twig/README.md](./apps/twig/README.md) | Desktop app: building, signing, distribution, and workspace configuration | +| [apps/twig/ARCHITECTURE.md](./apps/twig/ARCHITECTURE.md) | Desktop app: dependency injection, tRPC, state management, and events | +| [apps/mobile/README.md](./apps/mobile/README.md) | Mobile app: Expo setup, EAS builds, and TestFlight deployment | +| [apps/cli/README.md](./apps/cli/README.md) | CLI: stacked PR management with Jujutsu | ## Troubleshooting diff --git a/ARCHITECTURE.md b/apps/twig/ARCHITECTURE.md similarity index 98% rename from ARCHITECTURE.md rename to apps/twig/ARCHITECTURE.md index 5d8957db8..7abdce021 100644 --- a/ARCHITECTURE.md +++ b/apps/twig/ARCHITECTURE.md @@ -1,6 +1,8 @@ -# Contributing to Twig +# Twig Architecture -## Architecture Overview +Implementation patterns for the Twig desktop app. For code style and commands, see [CLAUDE.md](./CLAUDE.md). + +## Overview Twig is an Electron app with a React renderer. The main process handles system operations (stateless), while the renderer owns all application state. diff --git a/apps/twig/README.md b/apps/twig/README.md index adbaed5ef..d92ce6a56 100644 --- a/apps/twig/README.md +++ b/apps/twig/README.md @@ -161,3 +161,103 @@ Set `SKIP_NOTARIZE=1` if you need to generate signed artifacts without submittin ```bash SKIP_NOTARIZE=1 pnpm run make ``` + +## Workspace Configuration (twig.json) + +Twig supports per-repository configuration through a `twig.json` file. This lets you define scripts that run automatically when workspaces are created or destroyed. + +### File Locations + +Twig searches for configuration in this order (first match wins): + +1. `.twig/{workspace-name}/twig.json` - Workspace-specific config +2. `twig.json` - Repository root config + +### Schema + +```json +{ + "scripts": { + "init": "npm install", + "start": ["npm run server", "npm run client"], + "destroy": "docker-compose down" + } +} +``` + +| Script | When it runs | Behavior | +|--------|--------------|----------| +| `init` | Workspace creation | Runs first, fails fast (stops on error) | +| `start` | After init completes | Continues even if scripts fail | +| `destroy` | Workspace deletion | Runs silently before cleanup | + +Each script can be a single command string or an array of commands. Commands run sequentially in dedicated terminal sessions. + +### Examples + +Install dependencies on workspace creation: +```json +{ + "scripts": { + "init": "pnpm install" + } +} +``` + +Start development servers: +```json +{ + "scripts": { + "init": ["pnpm install", "pnpm run build"], + "start": ["pnpm run dev", "pnpm run storybook"] + } +} +``` + +Clean up Docker containers: +```json +{ + "scripts": { + "destroy": "docker-compose down -v" + } +} +``` + +## Workspace Environment Variables + +Twig automatically sets environment variables in all workspace terminals and scripts. These are available in `init`, `start`, and `destroy` scripts, as well as any terminal sessions opened within a workspace. + +| Variable | Description | Example | +|----------|-------------|---------| +| `TWIG_WORKSPACE_NAME` | Worktree name, or folder name in root mode | `my-feature-branch` | +| `TWIG_WORKSPACE_PATH` | Absolute path to the workspace | `/Users/dev/.twig/worktrees/repo/my-feature` | +| `TWIG_ROOT_PATH` | Absolute path to the repository root | `/Users/dev/repos/my-project` | +| `TWIG_DEFAULT_BRANCH` | Default branch detected from git | `main` | +| `TWIG_WORKSPACE_BRANCH` | Initial branch when workspace was created | `twig/my-feature` | +| `TWIG_WORKSPACE_PORTS` | Comma-separated list of allocated ports | `50000,50001,...,50019` | +| `TWIG_WORKSPACE_PORTS_RANGE` | Number of ports allocated | `20` | +| `TWIG_WORKSPACE_PORTS_START` | First port in the range | `50000` | +| `TWIG_WORKSPACE_PORTS_END` | Last port in the range | `50019` | + +Note: `TWIG_WORKSPACE_BRANCH` reflects the branch at workspace creation time. If you or the agent checks out a different branch, this variable will still show the original branch name. + +### Port Allocation + +Each workspace is assigned a unique range of 20 ports starting from port 50000. The allocation is deterministic based on the task ID, so the same workspace always receives the same ports across restarts. + +### Usage Examples + +Use ports in your start scripts: +```json +{ + "scripts": { + "start": "npm run dev -- --port $TWIG_WORKSPACE_PORTS_START" + } +} +``` + +Reference the workspace path: +```bash +echo "Working in: $TWIG_WORKSPACE_NAME" +echo "Root repo: $TWIG_ROOT_PATH" +``` diff --git a/package.json b/package.json index 51c3ca2ac..c5f5a466e 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "packageManager": "pnpm@10.23.0+sha512.21c4e5698002ade97e4efe8b8b4a89a8de3c85a37919f957e7a0f30f38fbc5bbdd05980ffe29179b2fb6e6e691242e098d945d1601772cad0fef5fb6411e2a4b", "scripts": { "setup": "bash apps/twig/bin/setup", - "dev": "pnpm --filter @posthog/electron-trpc build && mprocs", + "dev": "pnpm --filter @posthog/electron-trpc build && pnpm --filter agent build && mprocs", "dev:agent": "pnpm --filter agent dev", "dev:twig": "pnpm --filter twig dev", "start": "pnpm --filter twig start",