diff --git a/.agents/skills/capture-learnings/SKILL.md b/.agents/skills/capture-learnings/SKILL.md index b4b85716..1649e2d1 100644 --- a/.agents/skills/capture-learnings/SKILL.md +++ b/.agents/skills/capture-learnings/SKILL.md @@ -1,15 +1,27 @@ --- name: capture-learnings description: >- - Capture and apply accumulated knowledge in learnings.md. Use when the user - gives feedback, shares preferences, corrects a mistake, or when you discover - something worth remembering for future conversations. + Capture and apply accumulated knowledge via the Resources system. Use when the + user gives feedback, shares preferences, corrects a mistake, or when you + discover something worth remembering for future conversations. user-invocable: false --- # Capture Learnings -This is background knowledge, not a slash command. **Read `learnings.md` before starting significant work.** Update it when you learn something worth remembering. +This is background knowledge, not a slash command. **Read the `learnings.md` resource before starting significant work.** Update it when you learn something worth remembering. + +## How to Read & Write Learnings + +Learnings are stored as **resources** in the SQL database, not as files on disk. + +- **Read:** `pnpm script resource-read --path learnings.md` +- **Write:** `pnpm script resource-write --path learnings.md --content "..."` +- **List all resources:** `pnpm script resource-list` + +Resources can be **personal** (per-user, default) or **shared** (team-wide): +- `pnpm script resource-write --path learnings.md --scope personal --content "..."` +- `pnpm script resource-write --path team-guidelines.md --scope shared --content "..."` ## When to Capture @@ -33,7 +45,7 @@ This is background knowledge, not a slash command. **Read `learnings.md` before ## Format -Add entries to `learnings.md` at the project root. Group by category: +Write learnings as markdown, grouped by category: ```markdown ## Preferences @@ -55,19 +67,37 @@ Add entries to `learnings.md` at the project root. Group by category: ## Key Rules -1. **Read first, write second** — always check `learnings.md` before starting work +1. **Read first, write second** — always read the `learnings.md` resource before starting work 2. **Capture immediately** — don't wait until the end of the conversation 3. **Keep it concise** — one line per learning, grouped by category 4. **Don't duplicate** — if a learning exists, refine it rather than adding another -5. **learnings.md is gitignored** — safe for personal info, preferences, contacts +5. **Resources are SQL-backed** — safe for personal info, preferences, contacts. They persist across sessions and are not in git. + +## Organizing Resources + +Learnings are just one type of resource. You can create additional resources for different purposes: +- `learnings.md` — user preferences, corrections, patterns (personal) +- `contacts.md` — important contacts and relationships (personal) +- `team-guidelines.md` — shared team conventions (shared) +- `notes/meeting-2026-03-26.md` — meeting notes (personal or shared) + +Use path prefixes like `notes/`, `docs/`, etc. to organize resources into virtual folders. ## Graduation When a learning is referenced repeatedly, it may belong in AGENTS.md or a skill: -- Updating `learnings.md` is a Tier 1 modification (data — auto-apply) +- Updating the `learnings.md` resource is a Tier 1 modification (data — auto-apply) - Updating a SKILL.md based on learnings is Tier 2 (source — verify after) +## Migration + +If a `learnings.md` file exists at the project root (from before the Resources system), run: +``` +pnpm script migrate-learnings +``` +This imports the file contents into the `learnings.md` resource. + ## Related Skills -- **self-modifying-code** — learnings.md updates are Tier 1; skill updates are Tier 2 +- **self-modifying-code** — resource updates are Tier 1; skill updates are Tier 2 - **create-skill** — when a learning graduates, create a skill from it diff --git a/.agents/skills/files-as-database/SKILL.md b/.agents/skills/files-as-database/SKILL.md index fba1fe5e..9e4f7a1b 100644 --- a/.agents/skills/files-as-database/SKILL.md +++ b/.agents/skills/files-as-database/SKILL.md @@ -1,102 +1,87 @@ --- -name: files-as-database +name: storing-data description: >- - How to choose between files and SQLite for storing application state. Use when - adding data models, deciding where to store data, or reading/writing + How to store application data in agent-native apps. All data lives in SQL. + Use when adding data models, deciding where to store data, or reading/writing application data. --- -# Files vs SQLite — Choosing the Right Data Layer +# Storing Data — SQL is the Source of Truth ## Rule -Agent-native apps use **two data layers**: files for content and configuration, SQLite for structured application data. Choose the right one based on what you're storing. +All application data lives in **SQL** (SQLite locally, cloud database in production). The agent and UI share the same database. There is no filesystem dependency for data. -## When to Use Files +## How It Works -Store data as files in `data/` (JSON, markdown, images) when: +Agent-native apps use SQLite via Drizzle ORM + `@libsql/client`. This works locally out of the box and upgrades seamlessly to cloud databases (Turso, Neon, Supabase, D1) by setting `DATABASE_URL`. **Local and production behave identically.** -- **Content** — markdown documents, drafts, articles, slide decks -- **Settings/Configuration** — app settings, user preferences, sync config -- **Application state** — ephemeral UI state in `application-state/` (compose windows, search state) -- **Media** — images, uploads, generated assets -- **Data the agent edits directly** — the agent can read/write files on the filesystem without going through an API +### Core SQL Stores (auto-created, available in all templates) -Files are the shared interface between the AI agent and the UI. The agent reads and writes files directly. The UI reads files via API routes. SSE streams file changes back to the UI in real-time. +| Store | Purpose | Access | +| ------------------- | ---------------------------------------------------- | ------------------------------------------ | +| `application_state` | Ephemeral UI state (compose windows, navigation) | `readAppState()` / `writeAppState()` | +| `settings` | Persistent KV config (preferences, app settings) | `getSetting()` / `setSetting()` | +| `oauth_tokens` | OAuth credentials | `@agent-native/core/oauth-tokens` | +| `sessions` | Auth sessions | `@agent-native/core/server` | -### How (Files) +### Domain Data (per-template) -- Store data as JSON or markdown files in `data/` (or a project-specific subdirectory). -- API routes in `server/routes/` read files with `fs.readFile` and return them. -- The agent modifies files directly — no API calls needed from the agent side. -- `createFileWatcher("./data")` watches for changes and streams them via SSE. -- `useFileWatcher()` on the client invalidates React Query caches when files change. +Define schema with Drizzle ORM in `server/db/schema.ts`. Get a database instance with `const db = getDb()` from `server/db/index.ts`. All queries are async. -### File Organization +| Template | Tables | +| ------------ | --------------------------------------------- | +| **Mail** | emails, labels (+ Gmail API when connected) | +| **Calendar** | events, bookings | +| **Forms** | forms, responses | +| **Content** | documents | +| **Slides** | decks (JSON stored in SQL) | +| **Videos** | compositions in registry + localStorage | -| Question | Single file | Directory of files | -| ------------------------------------ | ----------------- | ---------------------------- | -| Are items independently addressable? | No — use one file | Yes — one file per item | -| Will there be >50 items? | Probably fine | Definitely split | -| Do items need individual URLs? | No | Yes | -| Do items change independently? | No | Yes — avoids write conflicts | +### Agent Access -## When to Use SQLite +The agent uses scripts to read/write the database: -Store data in SQLite (`data/app.db`) via Drizzle ORM + `@libsql/client` when: +- `pnpm script db-schema` — Show all tables, columns, types +- `pnpm script db-query --sql "SELECT * FROM forms"` — Run SELECT queries +- `pnpm script db-exec --sql "INSERT INTO ..."` — Run INSERT/UPDATE/DELETE +- App-specific scripts for domain operations -- **Structured records** — forms, bookings, submissions, compositions with relationships -- **Data that needs querying** — filtering, sorting, aggregation, joins -- **High-volume data** — hundreds or thousands of records -- **Relational data** — foreign keys, references between entities -- **Data that benefits from transactions** — atomic multi-table writes +### Cloud Deployment -### How (SQLite) +Local SQLite works out of the box. To deploy to production with a cloud database: -- Define schema with Drizzle ORM in `server/db/schema.ts`. -- Get a database instance with `const db = getDb()` from `server/db/index.ts`. -- All queries are **async** (using `@libsql/client`, not `better-sqlite3`). -- The agent uses DB scripts (`pnpm script db-schema`, `db-query`, `db-exec`) or app-specific scripts to read/write data. -- Set `DATABASE_URL` env var for cloud database (Turso); defaults to local `file:data/app.db`. +1. Set `DATABASE_URL` (e.g. `libsql://your-db.turso.io`) +2. Set `DATABASE_AUTH_TOKEN` for auth +3. No code changes needed — `@libsql/client` handles both local and remote -### Cloud Upgrade Path +### Real-time Sync -Local SQLite works out of the box. To upgrade to a cloud database: +SSE streams database changes to the UI. When the agent writes to the database via scripts, the UI updates instantly via `useFileWatcher()` which invalidates React Query caches. -1. Set `DATABASE_URL` to a Turso URL (e.g. `libsql://your-db.turso.io`) -2. Set `DATABASE_AUTH_TOKEN` to your Turso auth token -3. No code changes needed — `@libsql/client` handles both local and remote +## Do + +- Use Drizzle ORM for structured domain data (forms, bookings, documents) +- Use the `settings` store for app configuration and user preferences +- Use `application-state` for ephemeral UI state that the agent and UI share +- Use `oauth-tokens` for OAuth credentials +- Use core DB scripts (`db-schema`, `db-query`, `db-exec`) for ad-hoc database operations ## Don't -- Don't store structured app data (forms, bookings, records) as individual JSON files when you need querying -- Don't store app state in localStorage, sessionStorage, or cookies +- Don't store structured app data as JSON files +- Don't store app state in localStorage, sessionStorage, or cookies (except for UI-only preferences like sidebar width) - Don't keep state only in memory (server variables, global stores) - Don't use Redis or any external state store for app data -- Don't interpolate user input directly into file paths (see Security below) - -## Examples by Template - -| Template | Files | SQLite | -| ---------- | ------------------------------------------------ | ---------------------------------- | -| **Forms** | `data/settings.json` | forms, responses | -| **Calendar** | `data/settings.json`, `data/availability.json` | bookings | -| **Slides** | `data/decks/*.json` | (not used — decks are JSON files) | -| **Content** | `content/projects/**/*.md`, `*.json` | (not used — content is files) | -| **Videos** | compositions in registry | (not used — state in localStorage) | +- Don't interpolate user input directly into SQL queries — use Drizzle ORM's query builder ## Security -- **Path sanitization** — Always sanitize IDs from request params before constructing file paths. Use `id.replace(/[^a-zA-Z0-9_-]/g, "")` or the core utility `isValidPath()`. Without this, `../../.env` as an ID reads your environment file. -- **Validate before writing** — Check data shape before writing files, especially for user-submitted data. A malformed write can break all subsequent reads. -- **SQL injection** — Use Drizzle ORM's query builder, never raw string interpolation for SQL queries. - -## Route Loaders vs API Routes - -React Router route `loader` functions can fetch data server-side during SSR. However, the default pattern is **SSR shell + client rendering**: the server renders a loading spinner and the client fetches data from `/api/*` routes via React Query. Only use server `loader` when a page genuinely needs server-rendered content for SEO or og tags (e.g., public booking pages). For all app pages behind auth, stick with the client-side React Query pattern. +- **SQL injection** — Use Drizzle ORM's query builder, never raw string interpolation for SQL queries +- **Validate before writing** — Check data shape before writing, especially for user-submitted data ## Related Skills -- **sse-file-watcher** — Set up real-time sync so the UI updates when data files change -- **scripts** — Create scripts that read/write data files or query the database -- **self-modifying-code** — The agent writes data files as Tier 1 (auto-apply) modifications +- **real-time-sync** — Set up SSE so the UI updates when the database changes +- **scripts** — Create scripts that query the database +- **self-modifying-code** — The agent can also modify the app's source code diff --git a/.agents/skills/ship/SKILL.md b/.agents/skills/ship/SKILL.md new file mode 100644 index 00000000..0286fa05 --- /dev/null +++ b/.agents/skills/ship/SKILL.md @@ -0,0 +1,38 @@ +--- +name: ship +description: Commit all local changes, run prep, push, check CI, and address PR feedback +user_invocable: true +--- + +# Ship + +Commit all locally changed files, run prep, push to remote, check CI status, and address PR review feedback. + +## Steps + +1. **Check local changes**: Run `git status` to see all modified/untracked files. + +2. **Run prep**: Run `pnpm run prep` to verify build, typecheck, tests, and formatting all pass. If anything fails, fix it before proceeding. + +3. **Stage and commit**: Stage all changed files (except `learnings.md` or other gitignored personal files). Write a concise commit message summarizing the changes. + +4. **Push**: Push to the current remote branch. + +5. **Check CI**: Run `gh pr checks` to see if CI is green. If there are failures, investigate with `gh run view --log-failed`, fix the issues, and push again. + +6. **Review PR feedback**: Check for new PR review comments via `gh api repos/{owner}/{repo}/pulls/{number}/comments`. For each comment: + - Be skeptical — not all suggestions are worth implementing + - Fix real bugs regardless of who wrote the code — you own the whole PR + - Reply to comments you disagree with, explaining why + - Only skip code that looks actively mid-work (half-written, clearly incomplete). If it looks done but has a bug, fix it. + +7. **Report**: Summarize what was committed, CI status, and any feedback addressed. + +## Important + +- **Multiple agents run concurrently.** There will often be locally changed files you didn't generate. This is normal. Include everything and move forward. Don't revert other agents' work — but DO fix bugs in it if PR feedback flags real issues. Only leave code alone if it's clearly mid-work (half-written, incomplete). If it looks done but broken, fix it. +- Never commit `learnings.md` or files in `.gitignore` +- If prep fails on code you didn't write, fix it (bad imports, type errors, missing prettier, etc.) +- If PR review comments flag real bugs in other agents' code, fix those too — you own the whole PR +- Run `npx prettier --write` on any files you modify for fixes +- Always run `pnpm run prep` before pushing — it catches what CI will catch diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock deleted file mode 100644 index 5a72d3d5..00000000 --- a/.claude/scheduled_tasks.lock +++ /dev/null @@ -1 +0,0 @@ -{"sessionId":"e338f45d-7d4e-41c2-8b36-76c41faed683","pid":74103,"acquiredAt":1774220517559} \ No newline at end of file diff --git a/.gemini/settings.json b/.gemini/settings.json new file mode 100644 index 00000000..15d7325e --- /dev/null +++ b/.gemini/settings.json @@ -0,0 +1,5 @@ +{ + "context": { + "fileName": ["AGENTS.md", "CONTEXT.md", "GEMINI.md"] + } +} diff --git a/.gitignore b/.gitignore index 4f079ffa..2f1bdb76 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,4 @@ ralph-loop.local.md # Wrangler local state .wrangler +.claude/scheduled_tasks.lock diff --git a/AGENTS.md b/AGENTS.md index f72d2515..cecc47a1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,9 +36,9 @@ When the agent needs to do something — query data, call APIs, process informat **Do:** Create focused scripts for discrete operations. Parse args with `parseArgs()`. Use scripts to list, search, create, and manage data — not just for background tasks. **Don't:** Put complex logic inline in agent chat. Keep scripts small and composable. Don't say "I don't have access" — check the scripts and database first. -### 4. SSE keeps the UI in sync +### 4. Polling keeps the UI in sync -Server-Sent Events stream database changes to the UI in real-time. When the agent writes to the database (application state, settings, or domain data), the SSE handler broadcasts the change. The client `useFileWatcher()` hook invalidates React Query caches on changes. SSE events have a `source` field: `"app-state"` or `"settings"`. +Database changes are synced to the UI via lightweight polling. When the agent writes to the database (application state, settings, or domain data), a version counter increments. The client `useFileWatcher()` hook polls `/api/poll` every 2 seconds and invalidates React Query caches when changes are detected. Events have a `source` field: `"app-state"`, `"settings"`, or `"resources"`. This works in all deployment environments including serverless and edge. ### 5. The agent can modify code @@ -46,7 +46,7 @@ The agent can edit the app's own source code — components, routes, styles, scr ### 6. Application state in SQL -Ephemeral UI state lives in the `application_state` SQL table, keyed by session ID and key. Both the agent and the UI can read and write application state. When the agent writes state (e.g., a compose draft), the UI reacts via SSE and updates accordingly. When the user interacts with the UI, changes are written back so the agent can read them. +Ephemeral UI state lives in the `application_state` SQL table, keyed by session ID and key. Both the agent and the UI can read and write application state. When the agent writes state (e.g., a compose draft), the UI reacts via polling and updates accordingly. When the user interacts with the UI, changes are written back so the agent can read them. **Do:** Use `writeAppState(key, value)` from scripts, `appStatePut(sessionId, key, value)` from server code. Use `readAppState(key)` to read state. **Don't:** Use application-state for persistent data — use the `settings` store instead. Don't store secrets here. @@ -54,8 +54,8 @@ Ephemeral UI state lives in the `application_state` SQL table, keyed by session **Script helpers** (from `@agent-native/core/application-state`): - `readAppState(key)` — read state for current session -- `writeAppState(key, value)` — write state (triggers SSE) -- `deleteAppState(key)` — delete state (triggers SSE) +- `writeAppState(key, value)` — write state (triggers UI sync) +- `deleteAppState(key)` — delete state (triggers UI sync) - `listAppState(prefix)` — list state by key prefix ## Authentication @@ -101,6 +101,29 @@ data/ # App data (SQLite DB at data/app.db) react-router.config.ts # React Router framework config ``` +## Client-Side-First Rendering + +All app content renders **client-side only**. The server renders only the HTML shell (``, `` with meta tags, `` with scripts) plus a loading spinner. This is enforced by the `ClientOnly` wrapper in every template's `root.tsx`: + +```tsx +import { ClientOnly, DefaultSpinner } from "@agent-native/core/client"; + +export default function Root() { + return ( + }> + {/* All providers and go inside ClientOnly */} + + ); +} +``` + +**Why:** This prevents hydration mismatches. The server never renders app components, so `window`, `localStorage`, `new Date()`, `next-themes`, and any browser API are safe to use anywhere in app code. + +**Do:** Keep the `ClientOnly` wrapper in `root.tsx`. Use `window`, `localStorage`, browser APIs freely in components. +**Don't:** Remove `ClientOnly` from `root.tsx`. Don't add server-side data fetching in route loaders (use React Query client-side instead). + +Route `meta()` functions still work for SEO — they're resolved at the `Layout` level which is server-rendered. + ## Scripts Create `scripts/my-script.ts`: @@ -154,12 +177,13 @@ Agent skills in `.agents/skills/` provide detailed guidance for architectural ru | Skill | When to use | | --------------------- | ---------------------------------------------------- | | `storing-data` | Adding data models, reading/writing config or state | -| `real-time-sync` | Wiring SSE, debugging UI not updating | +| `real-time-sync` | Wiring polling sync, debugging UI not updating | | `delegate-to-agent` | Delegating AI work from UI or scripts to the agent | | `scripts` | Creating or running agent scripts | | `self-modifying-code` | Editing app source, components, or styles | | `create-skill` | Adding new skills for the agent | | `capture-learnings` | Recording corrections and patterns | | `frontend-design` | Building or styling any web UI, components, or pages | +| `ship` | Commit, prep, push, check CI, fix PR feedback | The **`frontend-design`** skill (sourced from [Anthropic's skills library](https://github.com/anthropics/skills/blob/main/skills/frontend-design/SKILL.md)) applies whenever the agent generates or modifies UI. It enforces distinctive, production-grade aesthetics — avoiding generic AI-generated design patterns like purple gradients, overused fonts, and cookie-cutter layouts. diff --git a/learnings.md b/learnings.md new file mode 100644 index 00000000..7c7e52bf --- /dev/null +++ b/learnings.md @@ -0,0 +1,5 @@ +## Preferences + +- Prefers agent chat docked on the far right, fully absent when closed, with the toggle as the rightmost button in the app's actual top bar instead of a floating button +- Prefers the agent sidebar header as a single row: mode tabs, chat tabs, and new/clear actions together; keep CLI chooser inside the cog menu instead of inline +- Project-wide domain rule: always use `agent-native.com` for docs/site links; do not use `agent-native.dev` diff --git a/package.json b/package.json index bbad23d4..fe3bdd53 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,8 @@ "test": "pnpm --filter @agent-native/core test && pnpm --filter @agent-native/docs test", "lint": "pnpm fmt:check && pnpm typecheck", "prep": "concurrently -n fmt,types,test -c blue,cyan,green \"pnpm fmt\" \"pnpm typecheck\" \"pnpm test\"", - "dev:all": "pnpm --filter @agent-native/core build && node scripts/dev-all.ts", - "dev:all:single-port": "pnpm --filter @agent-native/core build && node scripts/dev-all-single-port.ts", + "dev:all": "(pnpm --filter @agent-native/core build || true) && node scripts/dev-all.ts", + "dev:all:single-port": "(pnpm --filter @agent-native/core build || true) && node scripts/dev-all-single-port.ts", "dev:docs": "pnpm --filter @agent-native/docs dev", "dev:electron": "node scripts/dev-electron.ts", "dev:electron:apps": "node scripts/dev-electron.ts --apps" diff --git a/packages/core/README.md b/packages/core/README.md index f7af20ca..426d1924 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -98,24 +98,25 @@ Or **[launch a template](https://agent-native.com/templates)** — no setup requ ## How It Works -Agent-native apps follow five rules: +Agent-native apps follow these rules: -- **Files as database** — All state lives in files. The agent and UI share the same source of truth. +- **Data lives in SQL** — All state lives in a SQL database (SQLite locally, cloud DB in production via `DATABASE_URL`). The agent and UI share the same database. - **AI through the agent** — No inline LLM calls. The UI delegates to the agent via a chat bridge. One AI, customizable with skills and instructions. - **Agent updates code** — The agent can modify the app itself. Your tools get better over time. -- **Real-time sync** — File watcher streams changes via SSE. Agent edits appear instantly. +- **Real-time sync** — SSE streams database changes to the UI. Agent writes appear instantly. +- **Production ready** — Built-in auth, OAuth, cloud databases, and deployment presets. Same code runs locally and in production. - **Agent + UI + Computer** — The powerful trio. Everything the UI can do, the agent can do — and vice versa. ## Harnesses Agent-native apps run inside a **harness** — a host that provides the AI agent alongside your app UI. -| | Local / Open Source | Builder Cloud | +| | Open Source | Builder Cloud | |---|---|---| -| **Run** | Claude Code CLI or any local harness | One-click launch from templates | -| **Collaboration** | Solo | Real-time multiplayer | -| **Features** | Full permissions, full control | Visual editing, roles & permissions | -| **Best for** | Solo dev, local testing, OSS | Teams, production | +| **Run** | CLI harness (Claude Code, Codex, Gemini, etc.) | One-click launch from templates | +| **Database** | Local SQLite or cloud DB via `DATABASE_URL` | Managed cloud database | +| **Auth** | Built-in (ACCESS_TOKEN) or bring your own | Managed auth + roles | +| **Best for** | Development, self-hosted production, OSS | Teams, managed production | Your app code is identical regardless of harness. Start local, go to cloud when you need teams. diff --git a/packages/core/package.json b/packages/core/package.json index 08a89ab6..d513fa96 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -25,6 +25,7 @@ "./scripts": "./dist/scripts/index.js", "./application-state": "./dist/application-state/index.js", "./settings": "./dist/settings/index.js", + "./resources": "./dist/resources/index.js", "./oauth-tokens": "./dist/oauth-tokens/index.js", "./adapters/sync": "./dist/adapters/sync/index.js", "./adapters/drizzle": "./dist/adapters/drizzle/index.js", @@ -56,6 +57,8 @@ "dependencies": { "@anthropic-ai/sdk": "^0.80.0", "@libsql/client": "^0.15.0", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-tooltip": "^1.2.8", "@react-router/dev": "^7.13.1", "@react-router/fs-routes": "^7.13.1", "@tailwindcss/typography": "^0.5.19", @@ -94,7 +97,8 @@ "tailwindcss-animate": ">=1", "typescript": ">=5", "vite": ">=7", - "ws": ">=8" + "ws": ">=8", + "postgres": ">=3" }, "peerDependenciesMeta": { "@assistant-ui/react": { @@ -147,6 +151,9 @@ }, "ws": { "optional": true + }, + "postgres": { + "optional": true } }, "devDependencies": { diff --git a/packages/core/src/adapters/sync/file-sync.ts b/packages/core/src/adapters/sync/file-sync.ts index 952315f3..27db5b0e 100644 --- a/packages/core/src/adapters/sync/file-sync.ts +++ b/packages/core/src/adapters/sync/file-sync.ts @@ -564,7 +564,7 @@ export class FileSync { if (legacyDocs.length > 0) { console.warn( `[file-sync] Found ${legacyDocs.length} document(s) with legacy '__' separator. ` + - `These will be treated as orphans. See: https://agent-native.dev/docs/file-sync#migration`, + `These will be treated as orphans. See: https://agent-native.com/docs/file-sync#migration`, ); } diff --git a/packages/core/src/application-state/handlers.ts b/packages/core/src/application-state/handlers.ts index 9c2f29e0..2e871c27 100644 --- a/packages/core/src/application-state/handlers.ts +++ b/packages/core/src/application-state/handlers.ts @@ -38,11 +38,7 @@ export const getState = defineEventHandler(async (event: H3Event) => { const sessionId = await getSessionId(event); const key = safeKey(String(getRouterParam(event, "key"))); const value = await appStateGet(sessionId, key); - if (!value) { - setResponseStatus(event, 404); - return { error: `No state for ${key}` }; - } - return value; + return value ?? null; }); export const putState = defineEventHandler(async (event: H3Event) => { @@ -78,11 +74,7 @@ export const getComposeDraft = defineEventHandler(async (event: H3Event) => { const sessionId = await getSessionId(event); const id = getRouterParam(event, "id") as string; const value = await appStateGet(sessionId, composeDraftKey(id)); - if (!value) { - setResponseStatus(event, 404); - return { error: "Draft not found" }; - } - return value; + return value ?? null; }); /** Create or update a compose draft */ diff --git a/packages/core/src/application-state/store.ts b/packages/core/src/application-state/store.ts index d7fe7efb..1dba04ac 100644 --- a/packages/core/src/application-state/store.ts +++ b/packages/core/src/application-state/store.ts @@ -1,52 +1,11 @@ -import { createClient, type Client } from "@libsql/client"; +import { getDbExec, isPostgres, type DbExec } from "../db/client.js"; import { emitAppStateChange, emitAppStateDelete } from "./emitter.js"; -interface DbExec { - execute( - sql: string | { sql: string; args: any[] }, - ): Promise<{ rows: any[]; rowsAffected: number }>; -} - -let _client: DbExec | undefined; - -function getClient(): DbExec { - if (!_client) { - // Check for Cloudflare D1 binding - const d1 = (globalThis as any).__cf_env?.DB; - if (d1) { - _client = { - async execute(sql) { - if (typeof sql === "string") { - const r = await d1.prepare(sql).all(); - return { - rows: r.results || [], - rowsAffected: r.meta?.changes ?? 0, - }; - } - const r = await d1 - .prepare(sql.sql) - .bind(...sql.args) - .all(); - return { rows: r.results || [], rowsAffected: r.meta?.changes ?? 0 }; - }, - }; - return _client; - } - - const url = process.env.DATABASE_URL || "file:./data/app.db"; - _client = createClient({ - url, - authToken: process.env.DATABASE_AUTH_TOKEN, - }); - } - return _client; -} - let _initialized = false; async function ensureTable(): Promise { if (_initialized) return; - const client = getClient(); + const client = getDbExec(); await client.execute(` CREATE TABLE IF NOT EXISTS application_state ( session_id TEXT NOT NULL, @@ -64,7 +23,7 @@ export async function appStateGet( key: string, ): Promise | null> { await ensureTable(); - const client = getClient(); + const client = getDbExec(); const { rows } = await client.execute({ sql: `SELECT value FROM application_state WHERE session_id = ? AND key = ?`, args: [sessionId, key], @@ -79,9 +38,11 @@ export async function appStatePut( value: Record, ): Promise { await ensureTable(); - const client = getClient(); + const client = getDbExec(); await client.execute({ - sql: `INSERT OR REPLACE INTO application_state (session_id, key, value, updated_at) VALUES (?, ?, ?, ?)`, + sql: isPostgres() + ? `INSERT INTO application_state (session_id, key, value, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT (session_id, key) DO UPDATE SET value=EXCLUDED.value, updated_at=EXCLUDED.updated_at` + : `INSERT OR REPLACE INTO application_state (session_id, key, value, updated_at) VALUES (?, ?, ?, ?)`, args: [sessionId, key, JSON.stringify(value), Date.now()], }); emitAppStateChange(key); @@ -92,7 +53,7 @@ export async function appStateDelete( key: string, ): Promise { await ensureTable(); - const client = getClient(); + const client = getDbExec(); const result = await client.execute({ sql: `DELETE FROM application_state WHERE session_id = ? AND key = ?`, args: [sessionId, key], @@ -107,7 +68,7 @@ export async function appStateList( keyPrefix: string, ): Promise }>> { await ensureTable(); - const client = getClient(); + const client = getDbExec(); const { rows } = await client.execute({ sql: `SELECT key, value FROM application_state WHERE session_id = ? AND key LIKE ?`, args: [sessionId, keyPrefix + "%"], @@ -123,7 +84,7 @@ export async function appStateDeleteByPrefix( keyPrefix: string, ): Promise { await ensureTable(); - const client = getClient(); + const client = getDbExec(); // Get keys first so we can emit events const { rows } = await client.execute({ diff --git a/packages/core/src/cli/create.ts b/packages/core/src/cli/create.ts index 9a643209..9711e8f6 100644 --- a/packages/core/src/cli/create.ts +++ b/packages/core/src/cli/create.ts @@ -107,7 +107,7 @@ export function createApp(name?: string): void { ); console.log(``); console.log( - `Need multi-user collaboration? See: https://agent-native.dev/docs/file-sync`, + `Need multi-user collaboration? See: https://agent-native.com/docs/file-sync`, ); } diff --git a/packages/core/src/client/AgentPanel.tsx b/packages/core/src/client/AgentPanel.tsx index ebdc690e..00927d11 100644 --- a/packages/core/src/client/AgentPanel.tsx +++ b/packages/core/src/client/AgentPanel.tsx @@ -21,9 +21,22 @@ * */ -import React, { useState, useEffect, lazy, Suspense } from "react"; -import { AssistantChat } from "./AssistantChat.js"; +import React, { + useState, + useEffect, + useRef, + useCallback, + lazy, + Suspense, +} from "react"; +import * as SelectPrimitive from "@radix-ui/react-select"; +import * as TooltipPrimitive from "@radix-ui/react-tooltip"; +import { + MultiTabAssistantChat, + type MultiTabAssistantChatHeaderProps, +} from "./MultiTabAssistantChat.js"; import type { AssistantChatProps } from "./AssistantChat.js"; +import { useDevMode } from "./use-dev-mode.js"; import { cn } from "./utils.js"; // Lazy-load AgentTerminal to avoid bundling xterm.js when not needed @@ -31,8 +44,32 @@ const AgentTerminal = lazy(() => import("./terminal/index.js").then((m) => ({ default: m.AgentTerminal })), ); +// Lazy-load ResourcesPanel to avoid bundling when not needed +const ResourcesPanel = lazy(() => + import("./resources/ResourcesPanel.js").then((m) => ({ + default: m.ResourcesPanel, + })), +); + const CLI_STORAGE_KEY = "agent-native-cli-command"; const CLI_DEFAULT = "builder"; +const AGENT_PANEL_FONT_FAMILY = + 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; +const AGENT_PANEL_ROOT_STYLE = { + fontFamily: AGENT_PANEL_FONT_FAMILY, + fontSize: 13, + lineHeight: 1.2, +} satisfies React.CSSProperties; +const AGENT_PANEL_HEADER_CLASS = + "flex h-11 shrink-0 items-center justify-between gap-2 border-b border-border"; +const AGENT_PANEL_HEADER_STYLE = { + paddingLeft: 12, + paddingRight: 8, +} satisfies React.CSSProperties; +const AGENT_PANEL_CONTROL_STYLE = { + fontSize: 12, + lineHeight: 1, +} satisfies React.CSSProperties; interface AvailableCli { command: string; @@ -43,6 +80,8 @@ interface AvailableCli { function useAvailableClis() { const [clis, setClis] = useState([]); useEffect(() => { + // Only fetch in dev mode — this endpoint is provided by the terminal plugin + if (!IS_DEV) return; fetch("/api/available-clis") .then((r) => (r.ok ? r.json() : [])) .then((data) => setClis(data)) @@ -52,12 +91,13 @@ function useAvailableClis() { } function useCliSelection() { - const [selected, setSelected] = useState(() => { - if (typeof localStorage !== "undefined") { - return localStorage.getItem(CLI_STORAGE_KEY) || CLI_DEFAULT; - } - return CLI_DEFAULT; - }); + const [selected, setSelected] = useState(CLI_DEFAULT); + useEffect(() => { + try { + const saved = localStorage.getItem(CLI_STORAGE_KEY); + if (saved) setSelected(saved); + } catch {} + }, []); const select = (cmd: string) => { setSelected(cmd); try { @@ -108,16 +148,360 @@ function TerminalIcon({ className }: { className?: string }) { ); } +function CogIcon({ className }: { className?: string }) { + return ( + + + + + ); +} + +function SidebarIcon({ className }: { className?: string }) { + return ( + + + + + ); +} + +function ChevronDownIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +function CheckIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +function PlusIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +function TrashIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +function FolderIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +function XIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +interface SettingsSelectOption { + value: string; + label: string; + description?: string; +} + +function SettingsSelect({ + label, + value, + options, + onValueChange, +}: { + label: string; + value: string; + options: SettingsSelectOption[]; + onValueChange: (value: string) => void; +}) { + const selected = options.find((option) => option.value === value); + + return ( +
+

{label}

+ + + + {selected?.label ?? value} + + + + + + + + + {options.map((option) => ( + + + + + + +
+ + {option.label} + + {option.description ? ( + + {option.description} + + ) : null} +
+
+ ))} +
+
+
+
+
+ ); +} + +function IconTooltip({ + content, + children, +}: { + content: string; + children: React.ReactNode; +}) { + return ( + + + {children} + + + {content} + + + + + + ); +} + +// ─── Agent Settings Popover ────────────────────────────────────────────────── + +function AgentSettingsPopover({ + isDevMode, + onToggle, + availableClis, + selectedCli, + onSelectCli, +}: { + isDevMode: boolean; + onToggle: () => void; + availableClis: AvailableCli[]; + selectedCli: string; + onSelectCli: (command: string) => void; +}) { + const [open, setOpen] = useState(false); + const popoverRef = useRef(null); + const buttonRef = useRef(null); + + // Close on outside click + useEffect(() => { + if (!open) return; + function handleClick(e: MouseEvent) { + if ( + popoverRef.current && + !popoverRef.current.contains(e.target as Node) && + buttonRef.current && + !buttonRef.current.contains(e.target as Node) + ) { + setOpen(false); + } + } + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, [open]); + + // Close on Escape + useEffect(() => { + if (!open) return; + function handleKey(e: KeyboardEvent) { + if (e.key === "Escape") setOpen(false); + } + document.addEventListener("keydown", handleKey); + return () => document.removeEventListener("keydown", handleKey); + }, [open]); + + const environmentOptions: SettingsSelectOption[] = [ + { + value: "production", + label: "Production", + description: "Restricted to app tools only.", + }, + { + value: "development", + label: "Development", + description: "Full access to code editing, shell, and files.", + }, + ]; + const cliOptions: SettingsSelectOption[] = availableClis.map((cli) => ({ + value: cli.command, + label: cli.label, + })); + + return ( +
+ + {open && ( +
+
+ { + const nextIsDev = next === "development"; + if (nextIsDev !== isDevMode) onToggle(); + }} + /> + {IS_DEV && cliOptions.length > 0 && ( + + )} +
+
+ )} +
+ ); +} + // ─── AgentPanel ───────────────────────────────────────────────────────────── export interface AgentPanelProps extends Omit< AssistantChatProps, - "onSwitchToCli" | "showDevHint" + "onSwitchToCli" > { /** Initial mode. Default: "chat" */ defaultMode?: "chat" | "cli"; /** CSS class for the outer container */ className?: string; + /** Called when the user clicks the collapse button. If provided, a collapse button appears in the header. */ + onCollapse?: () => void; +} + +function useClientOnly() { + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); + return mounted; } export function AgentPanel({ @@ -127,81 +511,235 @@ export function AgentPanel({ emptyStateText, suggestions, showHeader = true, + onCollapse, }: AgentPanelProps) { - const [mode, setMode] = useState<"chat" | "cli">(defaultMode); + const mounted = useClientOnly(); + const [mode, setMode] = useState<"chat" | "cli" | "resources">(defaultMode); const availableClis = useAvailableClis(); const [selectedCli, selectCli] = useCliSelection(); const selectedLabel = availableClis.find((c) => c.command === selectedCli)?.label || selectedCli; + const { isDevMode, canToggle, setDevMode } = useDevMode(apiUrl); + const isLocalhost = + mounted && + typeof window !== "undefined" && + (window.location.hostname === "localhost" || + window.location.hostname === "127.0.0.1" || + window.location.hostname === "::1"); + const showDevToggle = canToggle && isLocalhost; - return ( -
- {showHeader && ( -
-
- {/* Mode toggle — only show CLI option in dev mode */} + const renderModeButtons = useCallback( + (activeMode: "chat" | "cli" | "resources") => ( +
+ + {IS_DEV && ( + + )} +
+ ), + [], + ); + + const renderHeaderActions = useCallback( + () => ( +
+ + + + {showDevToggle && ( + +
+ setDevMode(!isDevMode)} + availableClis={availableClis} + selectedCli={selectedCli} + onSelectCli={selectCli} + /> +
+
+ )} + {onCollapse && ( + - {IS_DEV && ( + + )} +
+ ), + [ + availableClis, + isDevMode, + mode, + onCollapse, + selectCli, + selectedCli, + setDevMode, + showDevToggle, + ], + ); + + const renderChatHeader = useCallback( + ({ + tabs, + activeTabId, + setActiveTabId, + addTab, + closeTab, + }: MultiTabAssistantChatHeaderProps) => ( +
+
+ {renderModeButtons(mode)} +
+ {tabs.length > 1 && + tabs.map((tab) => ( +
setActiveTabId(tab.id)} + className={cn( + "group/tab flex shrink-0 items-center gap-1 rounded-md px-2 py-1 text-[12px] font-medium leading-none cursor-pointer", + tab.id === activeTabId + ? "bg-accent text-foreground" + : "text-muted-foreground hover:bg-accent/50 hover:text-foreground", + )} + style={AGENT_PANEL_CONTROL_STYLE} + > + {tab.label} + {tab.status === "running" && ( + + )} + {tab.status === "completed" && ( + + )} + +
+ ))} + - )} +
- {/* CLI selector */} - {IS_DEV && availableClis.length > 0 && ( - - )} + + +
- )} + ) : null, + [], + ); - {/* Chat view — always mounted to preserve conversation */} + return ( +
+ {/* Chat view — always mounted to preserve state. + Header (with tabs + mode buttons) is always visible. + Chat content is hidden when CLI or resources mode is active. + The wrapper collapses (no flex-1) when another mode is active + so it only takes the height of its header. */}
- setMode("cli") : undefined} - /> + {mounted && ( + setMode("cli") : undefined} + /> + )}
{/* CLI terminal — only rendered in dev mode */} @@ -223,6 +761,88 @@ export function AgentPanel({
)} + + {/* Resources view */} + {mode === "resources" && ( +
+ + Loading resources... +
+ } + > + + +
+ )} +
+ ); +} + +// ─── Resize handle ────────────────────────────────────────────────────────── + +const SIDEBAR_STORAGE_KEY = "agent-native-sidebar-width"; +const SIDEBAR_OPEN_KEY = "agent-native-sidebar-open"; +const SIDEBAR_MIN = 280; +const SIDEBAR_MAX = 700; + +function ResizeHandle({ + position, + onDrag, +}: { + position: "left" | "right"; + onDrag: (delta: number) => void; +}) { + const dragging = useRef(false); + const lastX = useRef(0); + + const onPointerDown = useCallback((e: React.PointerEvent) => { + e.preventDefault(); + dragging.current = true; + lastX.current = e.clientX; + e.currentTarget.setPointerCapture(e.pointerId); + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + }, []); + + const onPointerMove = useCallback( + (e: React.PointerEvent) => { + if (!dragging.current) return; + const delta = e.clientX - lastX.current; + lastX.current = e.clientX; + // For a left sidebar, dragging right = wider (positive delta) + // For a right sidebar, dragging left = wider (negative delta) + onDrag(position === "left" ? delta : -delta); + }, + [onDrag, position], + ); + + const onPointerUp = useCallback(() => { + dragging.current = false; + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + }, []); + + return ( +
+
+
); } @@ -255,35 +875,88 @@ export function AgentSidebar({ position = "right", defaultOpen = false, }: AgentSidebarProps) { - const [open, setOpen] = useState(defaultOpen); + const [open, setOpen] = useState(() => { + try { + const saved = localStorage.getItem(SIDEBAR_OPEN_KEY); + if (saved !== null) return saved === "true"; + } catch {} + return defaultOpen; + }); + const [width, setWidth] = useState(sidebarWidth); + useEffect(() => { + try { + const saved = localStorage.getItem(SIDEBAR_STORAGE_KEY); + if (saved) { + const n = parseInt(saved, 10); + if (n >= SIDEBAR_MIN && n <= SIDEBAR_MAX) setWidth(n); + } + } catch {} + }, []); + + const setOpenPersisted = useCallback( + (next: boolean | ((prev: boolean) => boolean)) => { + setOpen((prev) => { + const value = typeof next === "function" ? next(prev) : next; + try { + localStorage.setItem(SIDEBAR_OPEN_KEY, String(value)); + } catch {} + return value; + }); + }, + [], + ); useEffect(() => { - const handler = () => { - setOpen((prev) => !prev); + const toggleHandler = () => { + setOpenPersisted((prev) => !prev); + }; + const openHandler = () => { + setOpenPersisted(true); + }; + window.addEventListener("agent-panel:toggle", toggleHandler); + window.addEventListener("agent-panel:open", openHandler); + return () => { + window.removeEventListener("agent-panel:toggle", toggleHandler); + window.removeEventListener("agent-panel:open", openHandler); }; - window.addEventListener("agent-panel:toggle", handler); - return () => window.removeEventListener("agent-panel:toggle", handler); + }, [setOpenPersisted]); + + const handleDrag = useCallback((delta: number) => { + setWidth((prev) => { + const next = Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, prev + delta)); + try { + localStorage.setItem(SIDEBAR_STORAGE_KEY, String(next)); + } catch {} + return next; + }); }, []); const isLeft = position === "left"; - const borderClass = isLeft ? "border-r" : "border-l"; - const sidebar = open ? ( -
- -
- ) : null; + const sidebar = ( + <> + {isLeft ? null : } +
+ setOpenPersisted(false)} + /> +
+ {isLeft ? : null} + + ); return (
- {isLeft && sidebar} + {isLeft && open ? sidebar : null}
{children}
- {!isLeft && sidebar} + {!isLeft && open ? sidebar : null}
); } diff --git a/packages/core/src/client/AssistantChat.tsx b/packages/core/src/client/AssistantChat.tsx index bb9462a7..957e0e18 100644 --- a/packages/core/src/client/AssistantChat.tsx +++ b/packages/core/src/client/AssistantChat.tsx @@ -16,8 +16,15 @@ import { ThreadPrimitive, ComposerPrimitive, MessagePrimitive, + AttachmentPrimitive, } from "@assistant-ui/react"; import type { ToolCallMessagePartProps } from "@assistant-ui/react"; +import { + SimpleImageAttachmentAdapter, + SimpleTextAttachmentAdapter, + CompositeAttachmentAdapter, +} from "@assistant-ui/react"; +import { MarkdownTextPrimitive } from "@assistant-ui/react-markdown"; import { createAgentChatAdapter } from "./agent-chat-adapter.js"; import { cn } from "./utils.js"; @@ -55,6 +62,38 @@ function SendIcon({ className }: { className?: string }) { ); } +function PaperclipIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +function XIcon({ className }: { className?: string }) { + return ( + + + + ); +} + function StopIcon({ className }: { className?: string }) { return ( @@ -128,6 +167,66 @@ function CopyIcon({ className }: { className?: string }) { ); } +// ─── Markdown Text ────────────────────────────────────────────────────────── + +const markdownStyles = ` +.agent-markdown > :first-child { margin-top: 0; } +.agent-markdown > :last-child { margin-bottom: 0; } +.agent-markdown p { margin: 0.5em 0; } +.agent-markdown ul, .agent-markdown ol { margin: 0.5em 0; padding-left: 1.5em; } +.agent-markdown li { margin: 0.2em 0; } +.agent-markdown li > p { margin: 0; } +.agent-markdown h1 { font-size: 1.25em; font-weight: 600; margin: 0.75em 0 0.25em; } +.agent-markdown h2 { font-size: 1.125em; font-weight: 600; margin: 0.75em 0 0.25em; } +.agent-markdown h3 { font-size: 1em; font-weight: 600; margin: 0.75em 0 0.25em; } +.agent-markdown strong { font-weight: 600; } +.agent-markdown em { font-style: italic; } +.agent-markdown code { font-size: 0.875em; padding: 0.15em 0.35em; border-radius: 0.25em; background: var(--color-muted, hsl(0 0% 15%)); } +.agent-markdown pre { margin: 0.5em 0; padding: 0.75em 1em; border-radius: 0.375em; background: var(--color-muted, hsl(0 0% 15%)); overflow-x: auto; } +.agent-markdown pre code { padding: 0; background: transparent; font-size: 0.8125em; } +.agent-markdown hr { border: none; border-top: 1px solid var(--color-border, hsl(0 0% 20%)); margin: 0.75em 0; } +.agent-markdown a { text-decoration: underline; text-underline-offset: 2px; } +.agent-markdown blockquote { border-left: 2px solid var(--color-border, hsl(0 0% 20%)); padding-left: 0.75em; margin: 0.5em 0; opacity: 0.8; } +.agent-markdown table { border-collapse: collapse; margin: 0.5em 0; font-size: 0.875em; } +.agent-markdown th, .agent-markdown td { border: 1px solid var(--color-border, hsl(0 0% 20%)); padding: 0.35em 0.65em; text-align: left; } +.agent-markdown th { font-weight: 600; background: var(--color-muted, hsl(0 0% 15%)); } +`; + +let stylesInjected = false; +function injectMarkdownStyles() { + if (stylesInjected || typeof document === "undefined") return; + stylesInjected = true; + const style = document.createElement("style"); + style.textContent = markdownStyles; + document.head.appendChild(style); +} + +function MarkdownText() { + useEffect(() => { + injectMarkdownStyles(); + }, []); + return ( + + ); +} + +// ─── Composer Attachment Preview ───────────────────────────────────────────── + +function ComposerAttachmentPreview() { + return ( + + + + + + + + + ); +} + // ─── Tool Call Fallback ───────────────────────────────────────────────────── function ToolCallFallback({ @@ -200,7 +299,7 @@ function ToolCallFallback({ function UserMessage() { return ( -
+
0 && + thread.messages[thread.messages.length - 1].id === msg.id; + const isComplete = !isLast || !thread.isRunning; const handleCopy = useCallback(() => { - const msg = messageRuntime.getState(); - const text = msg.content + const m = messageRuntime.getState(); + const text = m.content .filter((p) => p.type === "text") .map((p) => (p as { text: string }).text) .join("\n"); @@ -228,32 +333,35 @@ function AssistantMessage() { }, [messageRuntime]); return ( -
+
( -
{text}
- ), + Text: MarkdownText, tools: { Fallback: ToolCallFallback, }, }} />
- {/* Action bar on hover */} -
- -
+ {/* Action bar — only show after message is complete */} + {isComplete && ( +
+ +
+ )}
); } @@ -261,18 +369,16 @@ function AssistantMessage() { // ─── Thinking Indicator ───────────────────────────────────────────────────── function ThinkingIndicator() { + const [dots, setDots] = useState(1); + useEffect(() => { + const interval = setInterval(() => { + setDots((d) => (d % 3) + 1); + }, 400); + return () => clearInterval(interval); + }, []); return ( -
-
- {[0, 1, 2].map((i) => ( - - ))} -
- Thinking... +
+ Thinking{".".repeat(dots)}
); } @@ -425,10 +531,10 @@ export interface AssistantChatProps { showHeader?: boolean; /** CSS class for the outer container */ className?: string; - /** Whether to show the "Use CLI" hint in dev mode. Default: true */ - showDevHint?: boolean; /** Callback when user clicks "Use CLI" button */ onSwitchToCli?: () => void; + /** Callback when message count changes */ + onMessageCountChange?: (count: number) => void; } // ─── Queue Composer ────────────────────────────────────────────────────────── @@ -439,10 +545,12 @@ function QueueComposer({ composerRef, addToQueue, queuedCount, + onStop, }: { composerRef: React.RefObject; addToQueue: (text: string) => void; queuedCount: number; + onStop: () => void; }) { const [value, setValue] = useState(""); @@ -455,50 +563,73 @@ function QueueComposer({ setTimeout(() => composerRef.current?.focus(), 0); }, [value, addToQueue, composerRef]); + const handleAutoResize = useCallback( + (e: React.ChangeEvent) => { + setValue(e.target.value); + e.target.style.height = "auto"; + e.target.style.height = `${Math.min(e.target.scrollHeight, 160)}px`; + }, + [], + ); + return ( -
-